Skip to content

Commit f167089

Browse files
authored
feat(nodes): continue Claude catalog sessions on paired nodes via streaming CLI agent runs (#105833)
* feat(gateway): stream node.invoke progress with idle timeouts and cancel * feat(node-host): opt-in approval-gated claude cli agent run command * feat(agents): run node-placed claude-cli turns through streaming node invoke * feat(anthropic): continue claude catalog sessions on advertising nodes * docs(nodes): document paired-node claude agent runs and continuation * chore(plugin-sdk): admit spawn-invocation helper into surface budget * fix(nodes): harden streaming invoke and node run policy from review findings * test(node-host): prove multi-token tool-policy argv fails closed * fix(nodes): allowlist cancel event for apps and break node-host type cycle * chore(protocol): regenerate Swift gateway models for node invoke progress * chore(node-host): drop test-only export surface flagged by deadcode ratchet * style(node-host): merge invoke-types imports * refactor(nodes): split node agent run modules to satisfy the LOC ratchet * chore(protocol): regenerate android gateway models and sync export baseline * chore(plugin-sdk): admit spawn-invocation helper and inherited deprecated drift into surface budget * chore(plugin-sdk): tolerate inherited agent-core deprecated export * fix(anthropic): drop node param parser orphaned by upstream catalog split * ci: sync unused-export baseline with healed main * test(agents): adopt renamed cli backend prepare helper * style(node-host): format claude run spawn invocation * ci: retrigger checks * chore(plugin-sdk): pin surface budgets to healed-base counts * fix(protocol): restore untyped lazy validators after upstream style change
1 parent 1b43d71 commit f167089

68 files changed

Lines changed: 4241 additions & 658 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ enum class GatewayMethod(
287287
NodeInvoke("node.invoke"),
288288
NodePendingPull("node.pending.pull"),
289289
NodePendingAck("node.pending.ack"),
290+
NodeInvokeProgress("node.invoke.progress"),
290291
NodeInvokeResult("node.invoke.result"),
291292
NodeEvent("node.event"),
292293
CronGet("cron.get"),
@@ -393,6 +394,7 @@ enum class GatewayEvent(
393394
NodePairRequested("node.pair.requested"),
394395
NodePairResolved("node.pair.resolved"),
395396
NodePresence("node.presence"),
397+
NodeInvokeCancel("node.invoke.cancel"),
396398
NodeInvokeRequest("node.invoke.request"),
397399
DevicePairRequested("device.pair.requested"),
398400
DevicePairResolved("device.pair.resolved"),

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2118,6 +2118,32 @@ public struct NodeInvokeParams: Codable, Sendable {
21182118
}
21192119
}
21202120

2121+
public struct NodeInvokeProgressParams: Codable, Sendable {
2122+
public let invokeid: String
2123+
public let nodeid: String
2124+
public let seq: Int
2125+
public let chunk: String
2126+
2127+
public init(
2128+
invokeid: String,
2129+
nodeid: String,
2130+
seq: Int,
2131+
chunk: String)
2132+
{
2133+
self.invokeid = invokeid
2134+
self.nodeid = nodeid
2135+
self.seq = seq
2136+
self.chunk = chunk
2137+
}
2138+
2139+
private enum CodingKeys: String, CodingKey {
2140+
case invokeid = "invokeId"
2141+
case nodeid = "nodeId"
2142+
case seq
2143+
case chunk
2144+
}
2145+
}
2146+
21212147
public struct NodeInvokeResultParams: Codable, Sendable {
21222148
public let id: String
21232149
public let nodeid: String
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
52a3eb5ba5538fa684972c98970ff70e0a5af55b900b4ecd2a633f53943a0eab plugin-sdk-api-baseline.json
2-
07c99e2211a3ce675609d42ff4e12955f456b9c6ec6031fbcdaf98ab0d2d2973 plugin-sdk-api-baseline.jsonl
1+
4b1d6fda1248e19670ff55831283a1a565454b25bff0298058c49c76e899d4ca plugin-sdk-api-baseline.json
2+
f37f3afd49ddc4eef8a7b5bdee9091bb68c3818d3918fc2549bd787f7ac98a98 plugin-sdk-api-baseline.jsonl

docs/nodes/index.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,13 +331,40 @@ byte-offset cursors and bounded backward file reads, so selecting a large
331331
session or loading an older page does not read the whole JSONL history into one
332332
Gateway response.
333333

334-
Both node commands are read-only. They expose catalog metadata and transcript
334+
The list and read commands are read-only. They expose catalog metadata and transcript
335335
content only through the generic `sessions.catalog.list` and
336336
`sessions.catalog.read` methods to an authenticated operator connection with
337-
`operator.write`. Paired-node rows stay view-only. A Gateway-local Claude CLI
338-
row can be adopted from the normal Chat composer: OpenClaw imports bounded
339-
visible history, resumes with `--fork-session` on the first turn, and leaves the
340-
source transcript untouched. Claude Desktop rows remain view-only.
337+
`operator.write`. A Gateway-local Claude CLI row can be adopted from the normal
338+
Chat composer: OpenClaw imports bounded visible history, resumes with
339+
`--fork-session` on the first turn, and leaves the source transcript untouched.
340+
341+
A headless node host can opt into the same continuation flow:
342+
343+
```json5
344+
{
345+
nodeHost: {
346+
agentRuns: {
347+
claude: { enabled: true },
348+
},
349+
},
350+
}
351+
```
352+
353+
The node advertises `agent.cli.claude.run.v1` only when this node-local setting
354+
is enabled and the `claude` executable resolves on that node. The Gateway cannot
355+
enable it remotely. The command also passes through the node's existing exec
356+
approval policy. When all three Claude commands are advertised and permitted by
357+
the Gateway's node command policy, a Claude CLI
358+
row on that node becomes continuable: OpenClaw imports bounded history, binds
359+
the adopted session to the node and its catalog-reported working directory, and
360+
runs each one-shot `claude -p` turn there. The first turn still uses
361+
`--fork-session`, preserving the source transcript.
362+
363+
Node-placed turns use the node's Claude defaults. In v1 they do not receive the
364+
Gateway loopback MCP config or Gateway skills plugin, cannot reseed from a
365+
Gateway transcript, and reject attachments and images. Claude Desktop rows and
366+
nodes that do not advertise the run command remain view-only. The macOS app
367+
node does not advertise this command yet, so its rows remain view-only.
341368

342369
See [Anthropic: Claude sessions across computers](/providers/anthropic#claude-sessions-across-computers)
343370
for the Control UI behavior and storage sources.
@@ -352,6 +379,17 @@ openclaw nodes invoke --node <idOrNameOrIp> --command canvas.eval --params '{"ja
352379

353380
`nodes invoke` blocks `system.run` and `system.run.prepare`; those commands only run through the `exec` tool with `host=node` (see above). Higher-level helpers exist for the common "give the agent a MEDIA attachment" workflows (canvas, camera, screen, location, below).
354381

382+
Long-running streaming node commands use additive `node.invoke.progress`
383+
events. Each event carries the invoke ID, a zero-based sequence number, and a
384+
bounded UTF-8 text chunk; the Gateway orders chunks before delivering them to
385+
the caller. The existing `node.invoke.result` remains the single terminal
386+
response. Streaming callers can set an inactivity deadline that starts with the
387+
first progress event and resets after later progress while retaining the
388+
invoke's separate hard timeout during approval and execution. Result, hard
389+
timeout, inactivity timeout, and node disconnect all discard pending stream
390+
state. Caller cancellation emits `node.invoke.cancel`; the node host then
391+
terminates the matching process tree. Existing request/response commands are unchanged.
392+
355393
## Command policy
356394

357395
Node commands must pass two gates before they can be invoked:

docs/providers/anthropic.md

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,10 @@ Code sessions on the Gateway and on connected node hosts:
200200
- A CLI-only session has no archive flag, so it remains visible while its
201201
transcript is present.
202202

203-
No additional OpenClaw config is required. The Anthropic plugin is bundled and
204-
enabled by default; a native macOS node advertises the read-only Claude session
205-
commands when the local `~/.claude/projects/` directory exists. Approve the
206-
node pairing upgrade when those commands first appear.
203+
No additional OpenClaw config is required for discovery. The Anthropic plugin
204+
is bundled and enabled by default; a native macOS node advertises the read-only
205+
Claude session commands when the local `~/.claude/projects/` directory exists.
206+
Approve the node pairing upgrade when those commands first appear.
207207

208208
The sidebar starts with the newest bounded page from each host and refreshes on
209209
the normal 30-second cadence. Use **Load more sessions** below a catalog group
@@ -222,15 +222,39 @@ For a Gateway-local `claude-cli` row, typing in the normal composer calls
222222
creates or reuses a model-locked native session, imports at most 200 visible
223223
items or 512 KiB, and seeds the Claude CLI binding. The first turn resumes with
224224
`--fork-session`; Claude assigns the fork a new session ID, so later turns use
225-
the fork and the source session stays untouched. Claude Desktop and paired-node
226-
rows are view-only.
225+
the fork and the source session stays untouched.
226+
227+
A headless node host can also make its Claude CLI rows continuable by enabling
228+
the node-local setting below and restarting the node host:
229+
230+
```json5
231+
{
232+
nodeHost: {
233+
agentRuns: {
234+
claude: { enabled: true },
235+
},
236+
},
237+
}
238+
```
239+
240+
The node advertises `agent.cli.claude.run.v1` only when the setting is enabled
241+
and its local `claude` executable resolves. OpenClaw re-resolves the catalog
242+
record on that node, imports the same bounded history, and binds the adopted
243+
session to the node and catalog-reported working directory. Each turn runs the
244+
node's real `claude -p` process using that node's Claude files and login. The
245+
node's exec approval policy still applies; the Gateway cannot force the opt-in.
246+
247+
Node continuation v1 is one-shot only. It omits Gateway loopback MCP config and
248+
Gateway skills plugin arguments, does not reseed from a Gateway transcript, and
249+
rejects attachments and images. Claude Desktop rows remain view-only. Native
250+
macOS app nodes also remain view-only until the app advertises the run command.
227251

228252
<Note>
229-
Claude sessions on paired nodes are read-only. OpenClaw does not modify Claude
230-
Desktop metadata, archive Claude sessions, or start a second runner on the
231-
owning computer. The page requires an operator connection with write scope
232-
because it uses the authenticated `node.invoke` transport, even though both
233-
Claude node commands are read-only.
253+
Paired-node Claude sessions remain read-only unless the headless node explicitly
254+
advertises `agent.cli.claude.run.v1`. OpenClaw never modifies Claude Desktop
255+
metadata or archives Claude sessions. The page requires an operator connection
256+
with write scope because it uses authenticated `node.invoke`; list and read
257+
remain read-only even on a continuation-enabled node.
234258
</Note>
235259

236260
See [Nodes: Claude sessions and transcripts](/nodes#claude-sessions-and-transcripts)
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
2+
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
3+
4+
const MAX_TRANSCRIPT_ITEM_BYTES = 4 * 1024 * 1024;
5+
const MAX_TRANSCRIPT_TEXT_LENGTH = 1_000_000;
6+
7+
export type ClaudeTranscriptItem = {
8+
type: string;
9+
text?: string;
10+
content?: unknown;
11+
timestamp?: string;
12+
model?: string;
13+
uuid?: string;
14+
truncated?: true;
15+
};
16+
17+
function transcriptItemType(role: string, content: unknown): string {
18+
if (!Array.isArray(content)) {
19+
return role === "user" ? "userMessage" : "agentMessage";
20+
}
21+
const types = content.flatMap((block) =>
22+
isRecord(block) && typeof block.type === "string" ? [block.type] : [],
23+
);
24+
if (types.length > 0 && types.every((type) => type === "tool_result")) {
25+
return "toolResult";
26+
}
27+
if (types.length > 0 && types.every((type) => type === "tool_use")) {
28+
return "toolCall";
29+
}
30+
if (types.length > 0 && types.every((type) => type === "thinking")) {
31+
return "reasoning";
32+
}
33+
return role === "user" ? "userMessage" : "agentMessage";
34+
}
35+
36+
export function collectTranscriptText(value: unknown, fragments: string[]): void {
37+
if (typeof value === "string") {
38+
if (value.trim()) {
39+
fragments.push(value);
40+
}
41+
return;
42+
}
43+
if (Array.isArray(value)) {
44+
for (const item of value) {
45+
collectTranscriptText(item, fragments);
46+
}
47+
return;
48+
}
49+
if (!isRecord(value)) {
50+
return;
51+
}
52+
for (const key of ["text", "thinking", "content", "input"]) {
53+
if (key in value) {
54+
collectTranscriptText(value[key], fragments);
55+
}
56+
}
57+
}
58+
59+
export function parseTranscriptLine(
60+
line: Buffer,
61+
optionalString: (value: unknown, maxLength?: number) => string | undefined,
62+
): ClaudeTranscriptItem | undefined {
63+
let raw: unknown;
64+
try {
65+
raw = JSON.parse(line.toString("utf8")) as unknown;
66+
} catch {
67+
return undefined;
68+
}
69+
if (!isRecord(raw) || raw.isSidechain === true || !isRecord(raw.message)) {
70+
return undefined;
71+
}
72+
const role = raw.message.role;
73+
if ((role !== "user" && role !== "assistant") || raw.type !== role) {
74+
return undefined;
75+
}
76+
const content = raw.message.content;
77+
if (typeof content !== "string" && !Array.isArray(content)) {
78+
return undefined;
79+
}
80+
const fragments: string[] = [];
81+
collectTranscriptText(content, fragments);
82+
const text = [...new Set(fragments)].join("\n\n");
83+
const item: ClaudeTranscriptItem = {
84+
type: transcriptItemType(role, content),
85+
...(text ? { text } : {}),
86+
content,
87+
...(optionalString(raw.timestamp, 128)
88+
? { timestamp: optionalString(raw.timestamp, 128) }
89+
: {}),
90+
...(optionalString(raw.message.model, 256)
91+
? { model: optionalString(raw.message.model, 256) }
92+
: {}),
93+
...(optionalString(raw.uuid, 256) ? { uuid: optionalString(raw.uuid, 256) } : {}),
94+
};
95+
if (Buffer.byteLength(JSON.stringify(item), "utf8") <= MAX_TRANSCRIPT_ITEM_BYTES) {
96+
return item;
97+
}
98+
return {
99+
type: item.type,
100+
text: `${truncateUtf16Safe(text, MAX_TRANSCRIPT_TEXT_LENGTH)}\n\n[oversized Claude item truncated]`,
101+
...(item.timestamp ? { timestamp: item.timestamp } : {}),
102+
...(item.model ? { model: item.model } : {}),
103+
...(item.uuid ? { uuid: item.uuid } : {}),
104+
truncated: true,
105+
};
106+
}

0 commit comments

Comments
 (0)