@khorsheed/dsh-local-agent-claude-code 0.1.0-rc.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +120 -0
- package/README.i18n.yaml +6 -0
- package/README.md +120 -0
- package/cordis.patch.yml +42 -0
- package/lib/client.js +1065 -0
- package/lib/index.js +3771 -0
- package/lib/invariant.js +31 -0
- package/lib/types/claude-cli-provider.d.ts +389 -0
- package/lib/types/claude-cli-provider.js +1509 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +198 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +36 -0
- package/lib/types/client/index.js +87 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/control-requests.d.ts +15 -0
- package/lib/types/control-requests.js +62 -0
- package/lib/types/index.d.ts +81 -0
- package/lib/types/index.js +300 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/live-driver.d.ts +172 -0
- package/lib/types/live-driver.js +1057 -0
- package/lib/types/live-switch.d.ts +67 -0
- package/lib/types/live-switch.js +85 -0
- package/lib/types/model-broker.d.ts +131 -0
- package/lib/types/model-broker.js +248 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +118 -0
- package/lib/types/provision.d.ts +63 -0
- package/lib/types/provision.js +132 -0
- package/lib/types/records.d.ts +120 -0
- package/lib/types/records.js +687 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +106 -0
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
import { claudeDirectory } from './model-catalog.js';
|
|
2
|
+
/**
|
|
3
|
+
* The claude provider's live driver: one resident Claude Code stream-json
|
|
4
|
+
* process per member (child session), driven over the vendor's
|
|
5
|
+
* `--input-format stream-json --output-format stream-json` realtime mode
|
|
6
|
+
* (protocol-probed against claude 2.1.236; the Agent SDK's streaming-input
|
|
7
|
+
* mode is this same channel, driven here directly so no SDK dependency
|
|
8
|
+
* lands). A delegation round is one `user` message on the resident process's
|
|
9
|
+
* stdin instead of a fresh `claude -p` — which buys what the process boundary
|
|
10
|
+
* could not: runtime-level graceful interrupt (`control_request
|
|
11
|
+
* {subtype:'interrupt'}`; the process survives and the session stays
|
|
12
|
+
* continuable) and the same-shape push stream folded live per turn.
|
|
13
|
+
*
|
|
14
|
+
* Auth discipline (a hard requirement): the resident process runs under the
|
|
15
|
+
* family's scoped `CLAUDE_CONFIG_DIR` and nothing else — the OAuth marker in
|
|
16
|
+
* the scoped `.claude.json` and the keychain entry hashed to that path are
|
|
17
|
+
* exactly what the exec mode's one-shot processes already use. The driver
|
|
18
|
+
* never touches the user's global `~/.claude`, never runs any `auth` verb,
|
|
19
|
+
* and only adds `ANTHROPIC_BASE_URL` when the plugin config supplies it (the
|
|
20
|
+
* exec path's exact env rule).
|
|
21
|
+
*
|
|
22
|
+
* Protocol facts (all probed): the first stdin `user` message triggers
|
|
23
|
+
* `system/init` carrying the server-assigned session id (EVERY turn emits a
|
|
24
|
+
* fresh init — the delegation records the first one); each turn closes with
|
|
25
|
+
* a `result` event (`is_error`, usage, session id); the interrupt control
|
|
26
|
+
* request answers with a `control_response` success; a new process with
|
|
27
|
+
* `--resume <session_id>` reattaches the on-disk session in the same mode
|
|
28
|
+
* (crash recovery); stdin EOF quiesces the process (the reclaim ladder).
|
|
29
|
+
*
|
|
30
|
+
* Fold: the resident stream speaks the exec path's exact event vocabulary
|
|
31
|
+
* (`system`/`assistant`/`user`/`result`), so each turn folds through the
|
|
32
|
+
* shared `ClaudeStreamParser` with the exec live mirror's hold-back rule (the
|
|
33
|
+
* volatile last line waits for `result`, which carries the round's usage).
|
|
34
|
+
* Every line folds 1:1. The always-enabled
|
|
35
|
+
* `--include-partial-messages` adds incremental output on top: each `stream_event` partial accumulates into its kind's
|
|
36
|
+
* stream (native message id + block index, with ordered legacy fallback),
|
|
37
|
+
* which reserves a (turn, step) at its first delta and
|
|
38
|
+
* appends throttled snapshot `assistant/message`s there — the host folds
|
|
39
|
+
* repeated settles at one coordinate into one live-updating chat node, the
|
|
40
|
+
* only streaming channel left after 0.1.5 retired the durable per-chunk
|
|
41
|
+
* event. A streamed line's completion folds at its stream's reserved step and
|
|
42
|
+
* finalizes it (no duplicated content); a stream whose completion never
|
|
43
|
+
* arrives is force-finalized at settle (interrupted on a non-completed round,
|
|
44
|
+
* so a cancelled turn reads 已停止 legitimately), the last one carrying the
|
|
45
|
+
* round's usage when no folded line did.
|
|
46
|
+
*
|
|
47
|
+
* Lifecycle mirrors M1–M3's discipline: lazy spawn, one in-flight spawn per
|
|
48
|
+
* member, idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), crash
|
|
49
|
+
* re-spawn with `--resume`, a channel breaker with cooldown, `disposeAll` on
|
|
50
|
+
* unload, and cancellation honored in every window (abort listener before
|
|
51
|
+
* any await; the init wait races the abort signal).
|
|
52
|
+
*
|
|
53
|
+
* Two member-aware additions on top: the spawn binds the member's EFFECTIVE
|
|
54
|
+
* model (session-level override, then the round's delegation model, then the
|
|
55
|
+
* settings key — resolved by the config callback per spawn, scratched into
|
|
56
|
+
* the scoped settings.json because a `--resume` respawn honors the file over
|
|
57
|
+
* the flag), and a runtime whose bound model no longer matches is retired so
|
|
58
|
+
* the round respawns onto the right one; and server→client control requests
|
|
59
|
+
* (a `permissionMode: 'normal'` spawn's `can_use_tool` approval surface) are
|
|
60
|
+
* auto-answered so an unattended runtime never hangs on a permission prompt.
|
|
61
|
+
* @module @khorsheed/dsh-local-agent-claude-code/live-driver
|
|
62
|
+
*/
|
|
63
|
+
import { ClaudeControlRequests } from './control-requests.js';
|
|
64
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
65
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
66
|
+
import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
|
|
67
|
+
import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
|
|
68
|
+
import { appendClaudeTranscriptLine, assistantEvent, claudeLineText, ClaudeStreamParser, claudeVersionFromInit, DEFAULT_DISPOSE_GRACE_MS, registerClaudeMemberRun, textTask, toolCallsOf, } from './claude-cli-provider.js';
|
|
69
|
+
import { syncClaudeCredentialFile } from './records.js';
|
|
70
|
+
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
71
|
+
export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
|
|
72
|
+
/** Default timeout for the turn's system/init ack (a cold boot can be slow). */
|
|
73
|
+
export const DEFAULT_LIVE_INIT_TIMEOUT_MS = 60_000;
|
|
74
|
+
/** Bounded wait for an interrupted turn to converge while a run disposes. */
|
|
75
|
+
export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
|
|
76
|
+
/** How long a tripped channel breaker stays on the exec fallback. */
|
|
77
|
+
export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
|
|
78
|
+
/** Grace between stdin EOF and SIGTERM when reclaiming a stream-json process. */
|
|
79
|
+
const RECLAIM_EOF_GRACE_MS = 1_000;
|
|
80
|
+
/** Default minimum interval between one streaming kind's snapshot messages. */
|
|
81
|
+
export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
|
|
82
|
+
/** Default minimum text growth between one streaming kind's snapshot messages. */
|
|
83
|
+
export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
|
|
84
|
+
/**
|
|
85
|
+
* The stream-json channel could not come up (spawn failure or the first
|
|
86
|
+
* turn's init never arrived). The provider catches exactly this and falls
|
|
87
|
+
* back to the exec one-shot; any other error is a real round failure.
|
|
88
|
+
*/
|
|
89
|
+
export class LiveChannelUnavailableError extends Error {
|
|
90
|
+
constructor(message) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = 'LiveChannelUnavailableError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const DEFAULT_TIMEOUTS = {
|
|
96
|
+
initMs: DEFAULT_LIVE_INIT_TIMEOUT_MS,
|
|
97
|
+
convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
|
|
98
|
+
channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
|
|
99
|
+
};
|
|
100
|
+
function thrown(value) {
|
|
101
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
102
|
+
}
|
|
103
|
+
function delay(ms) {
|
|
104
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One resident stream-json process: the NDJSON frame pump, the session id it
|
|
108
|
+
* carries, the per-session turn chain (one turn at a time — claude queues
|
|
109
|
+
* stdin user messages, but the cancelled turn's result must land before the
|
|
110
|
+
* next turn's message goes out, keeping the stop-then-rephrase gesture's
|
|
111
|
+
* streams unambiguous), and the reclaim ladder (stdin EOF → grace → SIGTERM).
|
|
112
|
+
*/
|
|
113
|
+
class ClaudeLiveRuntime {
|
|
114
|
+
child;
|
|
115
|
+
warn;
|
|
116
|
+
dead = false;
|
|
117
|
+
reclaimed = false;
|
|
118
|
+
/** The member's claude session id (first turn's system/init). */
|
|
119
|
+
sessionId;
|
|
120
|
+
/** The model this runtime's process was spawned with (undefined = no flag). */
|
|
121
|
+
configurationKey;
|
|
122
|
+
model;
|
|
123
|
+
/** Serializes turns per member (converge-before-next-message). */
|
|
124
|
+
turnChain = Promise.resolve();
|
|
125
|
+
/** The active round's event sink; installed per round, cleared at settle. */
|
|
126
|
+
onEvent;
|
|
127
|
+
controls = new ClaudeControlRequests(message => this.send(message));
|
|
128
|
+
onDead;
|
|
129
|
+
buffer = '';
|
|
130
|
+
decoder = new StringDecoder('utf8');
|
|
131
|
+
stderrTail = '';
|
|
132
|
+
stderrDecoder = new StringDecoder('utf8');
|
|
133
|
+
constructor(child, warn) {
|
|
134
|
+
this.child = child;
|
|
135
|
+
this.warn = warn;
|
|
136
|
+
child.stdout?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
|
|
137
|
+
child.stderr?.on('data', (chunk) => {
|
|
138
|
+
this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
|
|
139
|
+
});
|
|
140
|
+
void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
|
|
141
|
+
}
|
|
142
|
+
get diagnostics() {
|
|
143
|
+
return this.stderrTail.trim();
|
|
144
|
+
}
|
|
145
|
+
feed(text) {
|
|
146
|
+
this.buffer += text;
|
|
147
|
+
let index = this.buffer.indexOf('\n');
|
|
148
|
+
while (index >= 0) {
|
|
149
|
+
const line = this.buffer.slice(0, index);
|
|
150
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
151
|
+
index = this.buffer.indexOf('\n');
|
|
152
|
+
if (line.trim() === '')
|
|
153
|
+
continue;
|
|
154
|
+
let event;
|
|
155
|
+
try {
|
|
156
|
+
event = JSON.parse(line);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
this.warn('subagent-claude live: ignored a malformed stream line');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
this.dispatchEvent(event);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
this.warn(`subagent-claude live: stream handler failed: ${thrown(error).message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
dispatchEvent(event) {
|
|
171
|
+
if (this.controls.accept(event))
|
|
172
|
+
return;
|
|
173
|
+
if (event['type'] === 'control_request') {
|
|
174
|
+
this.answerControlRequest(event);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.onEvent?.(event);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Answer a server→client control request so the runtime never hangs on
|
|
181
|
+
* one. A `permissionMode: 'normal'` spawn omits
|
|
182
|
+
* `--dangerously-skip-permissions`, so the CLI routes its approval surface
|
|
183
|
+
* (`can_use_tool`) here — and a subagent has no human to approve. The
|
|
184
|
+
* auto-allow mirrors the exec path's effective behavior (its one-shot
|
|
185
|
+
* spawns pre-allow the member bridge tool and otherwise run the same
|
|
186
|
+
* unattended policy) and the kimi live driver's auto-allow of
|
|
187
|
+
* `session/request_permission`. Any other subtype answers with an error so
|
|
188
|
+
* the CLI's own fallback path runs instead of waiting forever.
|
|
189
|
+
*/
|
|
190
|
+
answerControlRequest(event) {
|
|
191
|
+
const requestId = event['request_id'];
|
|
192
|
+
if (typeof requestId !== 'string')
|
|
193
|
+
return;
|
|
194
|
+
const request = event['request'];
|
|
195
|
+
const subtype = request?.['subtype'];
|
|
196
|
+
if (subtype === 'can_use_tool') {
|
|
197
|
+
const input = request?.['input'];
|
|
198
|
+
this.send({
|
|
199
|
+
type: 'control_response',
|
|
200
|
+
response: {
|
|
201
|
+
subtype: 'success',
|
|
202
|
+
request_id: requestId,
|
|
203
|
+
response: {
|
|
204
|
+
behavior: 'allow',
|
|
205
|
+
updatedInput: typeof input === 'object' && input !== null ? input : {},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.send({
|
|
212
|
+
type: 'control_response',
|
|
213
|
+
response: {
|
|
214
|
+
subtype: 'error',
|
|
215
|
+
request_id: requestId,
|
|
216
|
+
error: `subagent-claude live: unsupported control request ${String(subtype)}`,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
/** Write one stdin frame. */
|
|
221
|
+
send(message) {
|
|
222
|
+
this.child.stdin?.write(JSON.stringify(message) + '\n');
|
|
223
|
+
}
|
|
224
|
+
/** Native initialization and model binding precede any user prompt. */
|
|
225
|
+
async configure(configuration, signal) {
|
|
226
|
+
const initialized = await this.controls.request({ subtype: 'initialize', hooks: {} }, signal);
|
|
227
|
+
const directory = claudeDirectory(initialized);
|
|
228
|
+
const entry = directory.entries.find(entry => entry.value === configuration.model || entry.resolvedModel === configuration.model);
|
|
229
|
+
if (configuration.effort !== undefined && !entry?.reasoning?.options.some(option => option.value === configuration.effort)) {
|
|
230
|
+
throw new Error('The running Claude CLI does not advertise the requested model/effort combination');
|
|
231
|
+
}
|
|
232
|
+
if (configuration.model !== undefined)
|
|
233
|
+
await this.controls.request({ subtype: 'set_model', model: configuration.model }, signal);
|
|
234
|
+
}
|
|
235
|
+
/** The graceful runtime interrupt, bounded and checked for an actual success ack. */
|
|
236
|
+
async interrupt() {
|
|
237
|
+
if (this.dead)
|
|
238
|
+
return;
|
|
239
|
+
await this.controls.request({ subtype: 'interrupt' });
|
|
240
|
+
}
|
|
241
|
+
markDead() {
|
|
242
|
+
if (this.dead)
|
|
243
|
+
return;
|
|
244
|
+
this.dead = true;
|
|
245
|
+
this.controls.close();
|
|
246
|
+
this.onDead?.();
|
|
247
|
+
}
|
|
248
|
+
/** Graceful teardown: EOF stdin, grace, then the terminate ladder. */
|
|
249
|
+
async reclaim() {
|
|
250
|
+
if (this.reclaimed)
|
|
251
|
+
return;
|
|
252
|
+
this.reclaimed = true;
|
|
253
|
+
if (!this.dead) {
|
|
254
|
+
this.child.stdin?.end();
|
|
255
|
+
await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_EOF_GRACE_MS)]);
|
|
256
|
+
}
|
|
257
|
+
if (!this.dead) {
|
|
258
|
+
// Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
|
|
259
|
+
// once the managed range is gone.
|
|
260
|
+
this.child.terminate();
|
|
261
|
+
await this.child.waitForExit();
|
|
262
|
+
}
|
|
263
|
+
await this.child.done.catch(() => { });
|
|
264
|
+
this.markDead();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The claude live driver owns every resident stream-json runtime of this
|
|
269
|
+
* provider generation. Created only when `live: true`; disposed with the
|
|
270
|
+
* plugin.
|
|
271
|
+
*/
|
|
272
|
+
export class ClaudeLiveDriver {
|
|
273
|
+
ctx;
|
|
274
|
+
config;
|
|
275
|
+
timeouts;
|
|
276
|
+
runtimes = new Map();
|
|
277
|
+
ensuring = new Map();
|
|
278
|
+
idleTimers = new Map();
|
|
279
|
+
/** Per-member round serialization (the resume lock covers resume-vs-resume only). */
|
|
280
|
+
roundChains = new Map();
|
|
281
|
+
channelBrokenAt;
|
|
282
|
+
disposed = false;
|
|
283
|
+
/**
|
|
284
|
+
* Set by drain() (a settings-driven generation handoff): new rounds are
|
|
285
|
+
* refused so the provider falls back to exec, while in-flight rounds finish
|
|
286
|
+
* on their runtime undisturbed. Unlike `disposed`, the driver still serves
|
|
287
|
+
* what it already accepted.
|
|
288
|
+
*/
|
|
289
|
+
draining = false;
|
|
290
|
+
disposeController = new AbortController();
|
|
291
|
+
constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
|
|
292
|
+
this.ctx = ctx;
|
|
293
|
+
this.config = config;
|
|
294
|
+
this.timeouts = timeouts;
|
|
295
|
+
}
|
|
296
|
+
/** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
|
|
297
|
+
get disabled() {
|
|
298
|
+
return this.channelBrokenAt !== undefined
|
|
299
|
+
&& Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
|
|
300
|
+
}
|
|
301
|
+
/** Count of resident runtimes currently registered (zombie accounting in tests). */
|
|
302
|
+
get liveCount() {
|
|
303
|
+
return this.runtimes.size;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* True while the member's runtime exists or is being spawned. The settings
|
|
307
|
+
* controller gates a new generation on this: a fresh driver must not serve
|
|
308
|
+
* a member whose retiring generation still hosts the (same) claude session.
|
|
309
|
+
*/
|
|
310
|
+
hasRuntime(key) {
|
|
311
|
+
return this.runtimes.has(key) || this.ensuring.has(key);
|
|
312
|
+
}
|
|
313
|
+
/** @deprecated Compatibility no-op: live output is always incremental. */
|
|
314
|
+
setLiveMirrorGranularity(_granularity) { }
|
|
315
|
+
/**
|
|
316
|
+
* Drain for a settings-driven generation handoff: refuse new rounds (the
|
|
317
|
+
* provider's catch falls back to exec), let every in-flight round finish on
|
|
318
|
+
* its runtime, then reclaim. Resolves when no runtime or spawn remains.
|
|
319
|
+
* Unlike disposeAll, in-flight work is never interrupted.
|
|
320
|
+
*/
|
|
321
|
+
async drain() {
|
|
322
|
+
this.draining = true;
|
|
323
|
+
// Snapshot: rounds chain synchronously at startRound, so every accepted
|
|
324
|
+
// round is already in roundChains; anything later is refused.
|
|
325
|
+
const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
|
|
326
|
+
await Promise.all([...keys].map(async (key) => {
|
|
327
|
+
await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
328
|
+
await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
329
|
+
await this.reclaim(key);
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
clearIdleTimer(key) {
|
|
333
|
+
const timer = this.idleTimers.get(key);
|
|
334
|
+
if (timer !== undefined)
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
this.idleTimers.delete(key);
|
|
337
|
+
}
|
|
338
|
+
armIdleTimer(key) {
|
|
339
|
+
this.clearIdleTimer(key);
|
|
340
|
+
const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
|
|
341
|
+
const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
|
|
342
|
+
timer.unref();
|
|
343
|
+
this.idleTimers.set(key, timer);
|
|
344
|
+
}
|
|
345
|
+
async reclaim(key) {
|
|
346
|
+
const runtime = this.runtimes.get(key);
|
|
347
|
+
this.clearIdleTimer(key);
|
|
348
|
+
if (runtime === undefined)
|
|
349
|
+
return;
|
|
350
|
+
this.runtimes.delete(key);
|
|
351
|
+
await runtime.reclaim();
|
|
352
|
+
}
|
|
353
|
+
/** Reclaim every runtime (plugin unload); no process survives the profile. */
|
|
354
|
+
async disposeAll() {
|
|
355
|
+
this.disposed = true;
|
|
356
|
+
this.disposeController.abort();
|
|
357
|
+
await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
|
|
358
|
+
for (const key of [...this.runtimes.keys()]) {
|
|
359
|
+
await this.reclaim(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
ensureRuntime(spec, signal) {
|
|
363
|
+
const key = String(spec.childSession.id);
|
|
364
|
+
const existing = this.runtimes.get(key);
|
|
365
|
+
if (existing !== undefined && !existing.dead && existing.model === this.spawnModel(key, spec)
|
|
366
|
+
&& existing.configurationKey === (spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration))) {
|
|
367
|
+
this.clearIdleTimer(key);
|
|
368
|
+
return Promise.resolve(existing);
|
|
369
|
+
}
|
|
370
|
+
if (existing !== undefined)
|
|
371
|
+
this.runtimes.delete(key);
|
|
372
|
+
const pending = this.ensuring.get(key);
|
|
373
|
+
if (pending !== undefined)
|
|
374
|
+
return pending;
|
|
375
|
+
// A live runtime bound to a DIFFERENT model than this round would spawn
|
|
376
|
+
// with (a composer switch the broker did not retire eagerly, or a
|
|
377
|
+
// delegation naming its own model) is retired first — the respawn resumes
|
|
378
|
+
// the same CLI session from disk, so the conversation carries over.
|
|
379
|
+
const retire = existing !== undefined && !existing.dead ? existing.reclaim() : Promise.resolve();
|
|
380
|
+
const spawn = retire
|
|
381
|
+
.then(() => this.spawnRuntime(spec, signal))
|
|
382
|
+
.finally(() => { this.ensuring.delete(key); });
|
|
383
|
+
this.ensuring.set(key, spawn);
|
|
384
|
+
return spawn;
|
|
385
|
+
}
|
|
386
|
+
/** The model a spawn for this round would bind (the argv `--model` value). */
|
|
387
|
+
spawnModel(key, spec) {
|
|
388
|
+
if (spec.configuration !== undefined)
|
|
389
|
+
return spec.configuration.model;
|
|
390
|
+
const model = this.config.model?.(key, spec.model)?.trim();
|
|
391
|
+
return model === undefined || model === '' ? undefined : model;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* The model the member's resident runtime was spawned with, or undefined
|
|
395
|
+
* when the member has no live runtime (or it binds none). The model broker
|
|
396
|
+
* compares this against a switch's new effective model.
|
|
397
|
+
*/
|
|
398
|
+
runtimeModel(childSessionId) {
|
|
399
|
+
return this.runtimes.get(childSessionId)?.model;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Retire the member's resident runtime so the next round respawns with its
|
|
403
|
+
* new effective model (the model broker's switch path). The caller refuses
|
|
404
|
+
* switches while a round is in flight, so the reclaim never kills a run;
|
|
405
|
+
* the CLI session survives on disk and the respawn resumes it.
|
|
406
|
+
*/
|
|
407
|
+
async retireRuntime(childSessionId) {
|
|
408
|
+
await (this.ensuring.get(childSessionId) ?? Promise.resolve()).catch(() => undefined);
|
|
409
|
+
await this.reclaim(childSessionId);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Spawn the member's resident stream-json process. There is no handshake
|
|
413
|
+
* message — the channel proves itself with the first turn's system/init —
|
|
414
|
+
* so spawn failure is the only breaker trip here; the init wait lives in
|
|
415
|
+
* the round's accept window.
|
|
416
|
+
*/
|
|
417
|
+
async spawnRuntime(spec, signal) {
|
|
418
|
+
const key = String(spec.childSession.id);
|
|
419
|
+
if (this.disposed) {
|
|
420
|
+
throw new LiveChannelUnavailableError('the live driver is disposed');
|
|
421
|
+
}
|
|
422
|
+
// Keychain→file sync before EVERY spawn: claude 2.1.236 reads
|
|
423
|
+
// <home>/.credentials.json at runtime while login and the process's own
|
|
424
|
+
// refresh write the keychain — the file goes stale on every rotation
|
|
425
|
+
// (single-rotation refresh tokens make a consumed copy poison for the
|
|
426
|
+
// next spawn). The login watch's sync alone cannot keep a resident
|
|
427
|
+
// runtime authenticatable across rotations. Best-effort: a missing grant
|
|
428
|
+
// fails the turn with the CLI's own "Not logged in", not here.
|
|
429
|
+
await syncClaudeCredentialFile(spec.homeDir, message => { this.ctx.logger.warn(message); }).catch(() => false);
|
|
430
|
+
// The member bridge rides the spawn argv (`--mcp-config` + `--allowedTools`),
|
|
431
|
+
// exactly as in the exec path; the resident process serves one member, so
|
|
432
|
+
// its token lives with the process.
|
|
433
|
+
const member = registerClaudeMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
|
|
434
|
+
// The resident process serves one member, so the model resolved here binds
|
|
435
|
+
// that member's runtime; a later change reaches it when the runtime is next
|
|
436
|
+
// respawned (idle reclaim, crash, a broker-initiated retire, or the
|
|
437
|
+
// model-aware retire in ensureRuntime above).
|
|
438
|
+
const model = this.spawnModel(key, spec);
|
|
439
|
+
// The settings.json scratch: a --resume respawn restores the session's
|
|
440
|
+
// stored model over the --model flag, so the effective model also goes
|
|
441
|
+
// into the scoped file (best-effort — the argv flag still applies).
|
|
442
|
+
if (spec.configuration === undefined && this.config.provisionModel !== undefined) {
|
|
443
|
+
await this.config.provisionModel(spec.homeDir, model).catch(() => undefined);
|
|
444
|
+
}
|
|
445
|
+
const argv = [
|
|
446
|
+
'claude', '-p', '--verbose',
|
|
447
|
+
...model === undefined ? [] : ['--model', model],
|
|
448
|
+
'--input-format', 'stream-json',
|
|
449
|
+
'--output-format', 'stream-json',
|
|
450
|
+
'--include-partial-messages',
|
|
451
|
+
...(this.config.permissionMode ?? 'skip') === 'skip' ? ['--dangerously-skip-permissions'] : [],
|
|
452
|
+
...spec.resume === undefined ? [] : ['--resume', spec.resume.cliSessionId],
|
|
453
|
+
// `--allowedTools` is variadic: without the `--` terminator it swallows
|
|
454
|
+
// every following flag (the exec path's e191e27 lesson, same shape here).
|
|
455
|
+
...member === undefined ? [] : ['--mcp-config', member.mcpConfig, '--allowedTools', member.allowedTool, '--'],
|
|
456
|
+
];
|
|
457
|
+
const spawnSpec = {
|
|
458
|
+
argv,
|
|
459
|
+
cwd: spec.cwd,
|
|
460
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
461
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
462
|
+
// The auth discipline: exactly the exec path's env — the scoped config
|
|
463
|
+
// dir, plus the configured base URL override only.
|
|
464
|
+
env: delegationEnv({
|
|
465
|
+
CLAUDE_CONFIG_DIR: spec.homeDir,
|
|
466
|
+
...spec.configuration === undefined ? {} : { CLAUDE_CODE_EFFORT_LEVEL: spec.configuration.effort ?? 'auto' },
|
|
467
|
+
...this.config.baseUrl === undefined ? {} : { ANTHROPIC_BASE_URL: this.config.baseUrl },
|
|
468
|
+
}),
|
|
469
|
+
};
|
|
470
|
+
let child;
|
|
471
|
+
try {
|
|
472
|
+
child = this.ctx.subprocess.spawn(spawnSpec);
|
|
473
|
+
}
|
|
474
|
+
catch (error) {
|
|
475
|
+
member?.release();
|
|
476
|
+
this.markChannelBroken();
|
|
477
|
+
throw new LiveChannelUnavailableError(`the stream-json process failed to spawn: ${thrown(error).message}`);
|
|
478
|
+
}
|
|
479
|
+
const runtime = new ClaudeLiveRuntime(child, message => { this.ctx.logger.warn(message); });
|
|
480
|
+
runtime.model = model;
|
|
481
|
+
runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
|
|
482
|
+
runtime.onDead = () => {
|
|
483
|
+
// Delete only OUR registration (crash-then-respawn interleave safety).
|
|
484
|
+
if (this.runtimes.get(key) === runtime)
|
|
485
|
+
this.runtimes.delete(key);
|
|
486
|
+
this.clearIdleTimer(key);
|
|
487
|
+
member?.release();
|
|
488
|
+
};
|
|
489
|
+
if (signal.aborted || this.disposeController.signal.aborted) {
|
|
490
|
+
await runtime.reclaim();
|
|
491
|
+
if (signal.aborted)
|
|
492
|
+
throw new Error('subagent-claude: run cancelled locally');
|
|
493
|
+
throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
|
|
494
|
+
}
|
|
495
|
+
if (spec.configuration !== undefined) {
|
|
496
|
+
try {
|
|
497
|
+
await runtime.configure(spec.configuration, signal);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
await runtime.reclaim();
|
|
501
|
+
throw error;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
this.channelBrokenAt = undefined;
|
|
505
|
+
this.runtimes.set(key, runtime);
|
|
506
|
+
return runtime;
|
|
507
|
+
}
|
|
508
|
+
markChannelBroken() {
|
|
509
|
+
this.channelBrokenAt = Date.now();
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Drive one delegation round on the member's resident stream-json process.
|
|
513
|
+
* Mirrors the exec run's settlement contract exactly (settleRunResult +
|
|
514
|
+
* turn/end bookkeeping); cancel is the interrupt control request and the
|
|
515
|
+
* process survives.
|
|
516
|
+
*/
|
|
517
|
+
/** Initialize native controls without sending a user prompt. Native history starts on its first real turn. */
|
|
518
|
+
async prepare(spec, signal) {
|
|
519
|
+
signal.throwIfAborted();
|
|
520
|
+
if (this.draining || this.disabled)
|
|
521
|
+
throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
|
|
522
|
+
try {
|
|
523
|
+
await this.ensureRuntime(spec, signal);
|
|
524
|
+
signal.throwIfAborted();
|
|
525
|
+
this.armIdleTimer(String(spec.childSession.id));
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
await this.reclaim(String(spec.childSession.id));
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
async startRound(request, spec) {
|
|
533
|
+
// A draining generation refuses new rounds BEFORE chaining so the
|
|
534
|
+
// provider's exec fallback does not queue behind an in-flight round.
|
|
535
|
+
if (this.draining) {
|
|
536
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
537
|
+
}
|
|
538
|
+
const key = String(spec.childSession.id);
|
|
539
|
+
const previous = this.roundChains.get(key) ?? Promise.resolve();
|
|
540
|
+
const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
|
|
541
|
+
this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
|
|
542
|
+
return round;
|
|
543
|
+
}
|
|
544
|
+
/** The serialized round body. */
|
|
545
|
+
async startRoundLocked(request, spec) {
|
|
546
|
+
const task = textTask(request.prompt);
|
|
547
|
+
if (request.signal.aborted) {
|
|
548
|
+
throw new Error('subagent-claude: request was aborted before the run started');
|
|
549
|
+
}
|
|
550
|
+
// The drain race: the round chained before drain() but dequeued after it.
|
|
551
|
+
// Refuse so the provider falls back to exec instead of reusing a runtime
|
|
552
|
+
// the handoff is about to reclaim.
|
|
553
|
+
if (this.draining) {
|
|
554
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
555
|
+
}
|
|
556
|
+
if (this.disposed) {
|
|
557
|
+
throw new Error('subagent-claude: the live driver is disposed');
|
|
558
|
+
}
|
|
559
|
+
const turn = spec.resume?.turn ?? 1;
|
|
560
|
+
const childSession = spec.childSession;
|
|
561
|
+
const localAgent = this.ctx.get('localAgent');
|
|
562
|
+
const runAbort = new AbortController();
|
|
563
|
+
let roundSettled = false;
|
|
564
|
+
let runtime;
|
|
565
|
+
/** True while this round's turn is in flight (interrupt targets it). */
|
|
566
|
+
let turnInFlight = false;
|
|
567
|
+
/** True from the message going out until its result lands — the sink must
|
|
568
|
+
* survive settlement in that window so the chain can converge. */
|
|
569
|
+
let awaitingResult = false;
|
|
570
|
+
let turnOpened = false;
|
|
571
|
+
/** This turn's fold (the exec live mirror's exact parser). */
|
|
572
|
+
const parser = new ClaudeStreamParser();
|
|
573
|
+
let mirrored = 0;
|
|
574
|
+
/**
|
|
575
|
+
* The model and CLI build this turn's system/init named — the live
|
|
576
|
+
* drive's half of the exec settle parse's read-back, reported as the
|
|
577
|
+
* round's settled observation. EVERY turn re-emits its init, so a round
|
|
578
|
+
* always observes its own.
|
|
579
|
+
*/
|
|
580
|
+
let roundModel;
|
|
581
|
+
let roundCliVersion;
|
|
582
|
+
/** Whether a folded line already carries the round's usage (no backfill event exists). */
|
|
583
|
+
let usageCarried = false;
|
|
584
|
+
/**
|
|
585
|
+
* The step ledger: `reservedSteps` holds every step a stream reservation
|
|
586
|
+
* consumed (in increasing order — permanently, whether the stream later
|
|
587
|
+
* completes or stays an orphan snapshot), and a sequential line folds at
|
|
588
|
+
* its index shifted past every reservation before it. A tool completed
|
|
589
|
+
* mid-stream therefore folds ABOVE the stream's step and the projection
|
|
590
|
+
* renders the round's chronological order.
|
|
591
|
+
*/
|
|
592
|
+
const reservedSteps = [];
|
|
593
|
+
/**
|
|
594
|
+
* The streaming kinds seen this round by stream key:
|
|
595
|
+
* deltas accumulate into throttled snapshot assistant/messages appended
|
|
596
|
+
* at the kind's reserved (turn, step) — the host folds repeated settles
|
|
597
|
+
* at one coordinate into one live-updating chat node, which is the only
|
|
598
|
+
* streaming channel left after 0.1.5 retired the durable per-chunk event.
|
|
599
|
+
* The kind's completed line folds at the same step and finalizes it; an
|
|
600
|
+
* entry whose completion never arrives is force-finalized at settle.
|
|
601
|
+
*/
|
|
602
|
+
const streams = new Map();
|
|
603
|
+
/** The stream deltas currently accumulate into (kinds stream sequentially). */
|
|
604
|
+
let activeStream;
|
|
605
|
+
let persistQueue = Promise.resolve();
|
|
606
|
+
const persist = () => {
|
|
607
|
+
// Live sessions sync through the core's cached write handle, standalone
|
|
608
|
+
// ones through a one-shot handle — the suffix append is idempotent on
|
|
609
|
+
// both paths (the kimi session-mirror root cause).
|
|
610
|
+
persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
|
|
611
|
+
this.ctx.logger.warn(`subagent-claude: live persistence failed: ${thrown(error).message}`);
|
|
612
|
+
});
|
|
613
|
+
};
|
|
614
|
+
const requestCancel = () => {
|
|
615
|
+
if (roundSettled || runAbort.signal.aborted)
|
|
616
|
+
return;
|
|
617
|
+
runAbort.abort(new Error('subagent-claude: run cancelled locally'));
|
|
618
|
+
// The live driver's core win: a graceful runtime interrupt instead of a
|
|
619
|
+
// process kill. Best-effort; local settlement does not wait for it.
|
|
620
|
+
if (turnInFlight && runtime !== undefined && !runtime.dead) {
|
|
621
|
+
void runtime.interrupt().catch(() => { });
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const onAbort = () => { requestCancel(); };
|
|
625
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
626
|
+
const abortBranch = new Promise((_, reject) => {
|
|
627
|
+
runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-claude: run cancelled locally')), { once: true });
|
|
628
|
+
});
|
|
629
|
+
const collectOutput = () => {
|
|
630
|
+
const text = parser.text?.trim();
|
|
631
|
+
return text === undefined || text === '' ? [] : [{ type: 'text', text: parser.text }];
|
|
632
|
+
};
|
|
633
|
+
// Native message identity plus content-block index separates same-kind
|
|
634
|
+
// blocks. Older partials without those fields use a message-generation
|
|
635
|
+
// key, paired with their completion in arrival order.
|
|
636
|
+
let messageKey;
|
|
637
|
+
let legacyMessage = 0;
|
|
638
|
+
const streamKey = (kind, index) => `${messageKey ?? `legacy-${turn}-${legacyMessage}`}:${typeof index === 'number' ? index : kind}`;
|
|
639
|
+
/**
|
|
640
|
+
* Append one snapshot of a streaming kind at its reserved (turn, step).
|
|
641
|
+
* Batched per stream on a bounded deadline unless `force`; the forced
|
|
642
|
+
* final snapshot carries `interrupted` (a cancelled turn reads 已停止
|
|
643
|
+
* legitimately) and, when `withUsage` and no folded line carried it, the
|
|
644
|
+
* round's usage.
|
|
645
|
+
*/
|
|
646
|
+
const streamPublisher = localAgent?.liveStreams === undefined ? undefined
|
|
647
|
+
: new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
|
|
648
|
+
const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
|
|
649
|
+
const pendingStreamArrival = new Map();
|
|
650
|
+
const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
|
|
651
|
+
if (stream.text.trim() === '')
|
|
652
|
+
return;
|
|
653
|
+
const now = Date.now();
|
|
654
|
+
if (!force) {
|
|
655
|
+
if (!pendingStreamArrival.has(stream.step))
|
|
656
|
+
pendingStreamArrival.set(stream.step, now);
|
|
657
|
+
liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
liveFlush.cancel(stream.step);
|
|
661
|
+
const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
|
|
662
|
+
pendingStreamArrival.delete(stream.step);
|
|
663
|
+
if (streamPublisher !== undefined && !final) {
|
|
664
|
+
if (!stream.opened) {
|
|
665
|
+
childSession.append('step/start', { turn, step: stream.step });
|
|
666
|
+
stream.opened = true;
|
|
667
|
+
}
|
|
668
|
+
streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
|
|
669
|
+
stream.lastSnapshotAt = now;
|
|
670
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (!stream.opened) {
|
|
674
|
+
childSession.append('step/start', { turn, step: stream.step });
|
|
675
|
+
stream.opened = true;
|
|
676
|
+
}
|
|
677
|
+
const usage = parser.usage;
|
|
678
|
+
childSession.append('assistant/message', {
|
|
679
|
+
turn,
|
|
680
|
+
step: stream.step,
|
|
681
|
+
message: assistantEvent([
|
|
682
|
+
stream.kind === 'think'
|
|
683
|
+
? { type: 'reasoning', text: stream.text }
|
|
684
|
+
: { type: 'text', text: stream.text },
|
|
685
|
+
]),
|
|
686
|
+
stream: [],
|
|
687
|
+
...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
|
|
688
|
+
...interrupted ? { interrupted: true } : {},
|
|
689
|
+
}, { surfaceOp: 'append' });
|
|
690
|
+
if (withUsage && !usageCarried && usage !== undefined)
|
|
691
|
+
usageCarried = true;
|
|
692
|
+
stream.lastSnapshotAt = now;
|
|
693
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
694
|
+
persist();
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* Reserve the step one streaming kind will occupy — past every completed
|
|
698
|
+
* line (folded or held back) and every earlier reservation. A different
|
|
699
|
+
* kind's deltas force one final snapshot of the current stream (its
|
|
700
|
+
* completion still folds at its own reserved step when it lands).
|
|
701
|
+
*/
|
|
702
|
+
const reserveStream = (itemId, kind) => {
|
|
703
|
+
if (activeStream !== undefined && activeStream !== itemId) {
|
|
704
|
+
const previous = streams.get(activeStream);
|
|
705
|
+
// A freshness snapshot on the kind switch, skipped when the text has
|
|
706
|
+
// not grown since the last one (the settle force always lands).
|
|
707
|
+
if (previous !== undefined && previous.text.length !== previous.lastSnapshotLen) {
|
|
708
|
+
appendStreamSnapshot(previous, true, false);
|
|
709
|
+
}
|
|
710
|
+
activeStream = undefined;
|
|
711
|
+
}
|
|
712
|
+
let stream = streams.get(itemId);
|
|
713
|
+
if (stream === undefined) {
|
|
714
|
+
const step = parser.lines.length + reservedSteps.length + 1;
|
|
715
|
+
reservedSteps.push(step);
|
|
716
|
+
stream = { itemId, kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
|
|
717
|
+
streams.set(itemId, stream);
|
|
718
|
+
}
|
|
719
|
+
activeStream = itemId;
|
|
720
|
+
};
|
|
721
|
+
/** The sequential fold step for `parser.lines[index]`: its position shifted past every reservation before it. */
|
|
722
|
+
const foldStep = (index) => {
|
|
723
|
+
let step = index + 1;
|
|
724
|
+
for (const reserved of reservedSteps) {
|
|
725
|
+
if (reserved <= step)
|
|
726
|
+
step += 1;
|
|
727
|
+
else
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
return step;
|
|
731
|
+
};
|
|
732
|
+
/** Mirror folded lines [mirrored, upto); the last line is held back until `result`. */
|
|
733
|
+
const mirrorUpTo = (upto, withUsage) => {
|
|
734
|
+
// The usage rides the last NON-tool line (tool events carry no usage
|
|
735
|
+
// slot, and a kill mid-command ends the transcript with a tool line).
|
|
736
|
+
// A carrier mirrored in an earlier flush (before the usage was knowable)
|
|
737
|
+
// loses the accounting to the settle's final snapshot — host 0.1.5 has
|
|
738
|
+
// no usage-backfill event.
|
|
739
|
+
let usageIndex = -1;
|
|
740
|
+
if (withUsage) {
|
|
741
|
+
for (let index = 0; index < parser.lines.length; index += 1) {
|
|
742
|
+
if (parser.lines[index]?.kind !== 'tool')
|
|
743
|
+
usageIndex = index;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
for (let index = mirrored; index < upto; index += 1) {
|
|
747
|
+
const line = parser.lines[index];
|
|
748
|
+
if (line === undefined)
|
|
749
|
+
continue;
|
|
750
|
+
const lineUsage = withUsage && index === usageIndex ? parser.usage : undefined;
|
|
751
|
+
if (lineUsage !== undefined)
|
|
752
|
+
usageCarried = true;
|
|
753
|
+
const stream = line.kind === 'tool' ? undefined
|
|
754
|
+
: (line.streamId === undefined ? undefined : streams.get(line.streamId))
|
|
755
|
+
?? [...streams.values()].find(candidate => candidate.kind === line.kind && (line.streamId === undefined || candidate.itemId.startsWith('legacy-')));
|
|
756
|
+
const itemId = stream?.itemId;
|
|
757
|
+
if (itemId !== undefined && stream !== undefined) {
|
|
758
|
+
// A streamed line folds at its reserved step, finalizing the
|
|
759
|
+
// snapshots: the step opens only if no snapshot ever landed (a
|
|
760
|
+
// fast stream that stayed under the throttle), and closes here.
|
|
761
|
+
liveFlush.cancel(stream.step);
|
|
762
|
+
streams.delete(itemId);
|
|
763
|
+
if (activeStream === itemId)
|
|
764
|
+
activeStream = undefined;
|
|
765
|
+
if (!stream.opened)
|
|
766
|
+
childSession.append('step/start', { turn, step: stream.step });
|
|
767
|
+
const blocks = stream.kind === 'think'
|
|
768
|
+
? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
|
|
769
|
+
: [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
|
|
770
|
+
childSession.append('assistant/message', {
|
|
771
|
+
turn,
|
|
772
|
+
step: stream.step,
|
|
773
|
+
message: assistantEvent(blocks),
|
|
774
|
+
stream: [],
|
|
775
|
+
...lineUsage === undefined ? {} : { usage: lineUsage },
|
|
776
|
+
}, { surfaceOp: 'append' });
|
|
777
|
+
streamPublisher?.finish(stream.step);
|
|
778
|
+
childSession.append('step/end', { turn, step: stream.step });
|
|
779
|
+
}
|
|
780
|
+
else {
|
|
781
|
+
appendClaudeTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
|
|
782
|
+
}
|
|
783
|
+
mirrored = index + 1;
|
|
784
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: claudeLineText(line) });
|
|
785
|
+
}
|
|
786
|
+
if (upto > 0)
|
|
787
|
+
persist();
|
|
788
|
+
};
|
|
789
|
+
let resolveResult;
|
|
790
|
+
const resultEvent = new Promise((resolve) => { resolveResult = resolve; });
|
|
791
|
+
let resolveInit;
|
|
792
|
+
let rejectInit;
|
|
793
|
+
const initArrived = new Promise((resolve, reject) => { resolveInit = resolve; rejectInit = reject; });
|
|
794
|
+
/** The round's event sink (installed on the runtime while it runs). */
|
|
795
|
+
const onEvent = (event) => {
|
|
796
|
+
const type = event['type'];
|
|
797
|
+
if (type === 'system' && typeof event['session_id'] === 'string') {
|
|
798
|
+
// EVERY turn emits an init; the first one mints the delegation id.
|
|
799
|
+
// Its model/build fields are this round's settled observation (the
|
|
800
|
+
// exec settle parse reads the same fields off the same event).
|
|
801
|
+
if (runtime !== undefined && runtime.sessionId === undefined) {
|
|
802
|
+
runtime.sessionId = event['session_id'];
|
|
803
|
+
resolveInit(runtime.sessionId);
|
|
804
|
+
}
|
|
805
|
+
if (typeof event['model'] === 'string' && event['model'] !== '')
|
|
806
|
+
roundModel = event['model'];
|
|
807
|
+
roundCliVersion = claudeVersionFromInit(event['claude_code_version']) ?? roundCliVersion;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (type === 'result') {
|
|
811
|
+
// The turn closes: fold the result event itself (it carries the
|
|
812
|
+
// round's usage — the fold's usage carrier rule needs it in both
|
|
813
|
+
// granularities), then flush the held-back last line WITH the usage.
|
|
814
|
+
awaitingResult = false;
|
|
815
|
+
parser.push(JSON.stringify(event) + '\n');
|
|
816
|
+
mirrorUpTo(parser.lines.length, true);
|
|
817
|
+
resolveResult(event);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (type === 'stream_event') {
|
|
821
|
+
const inner = event['event'];
|
|
822
|
+
if (inner?.['type'] === 'message_start') {
|
|
823
|
+
const message = inner['message'];
|
|
824
|
+
messageKey = typeof message?.['id'] === 'string' ? message['id'] : `legacy-${turn}-${++legacyMessage}`;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const delta = inner?.['delta'];
|
|
828
|
+
const deltaType = delta?.['type'];
|
|
829
|
+
const text = delta?.['text'] ?? delta?.['thinking'];
|
|
830
|
+
if ((deltaType === 'text_delta' || deltaType === 'thinking_delta') && typeof text === 'string' && text !== '') {
|
|
831
|
+
const kind = deltaType === 'thinking_delta' ? 'think' : 'text';
|
|
832
|
+
reserveStream(streamKey(kind, inner?.['index']), kind);
|
|
833
|
+
const stream = activeStream === undefined ? undefined : streams.get(activeStream);
|
|
834
|
+
if (stream !== undefined) {
|
|
835
|
+
stream.text += text;
|
|
836
|
+
appendStreamSnapshot(stream, false, false);
|
|
837
|
+
}
|
|
838
|
+
// The delta still rides the run-progress channel live.
|
|
839
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
|
|
840
|
+
}
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (type !== 'assistant' && type !== 'user')
|
|
844
|
+
return;
|
|
845
|
+
// Feed the exec path's exact parser: one line per event, as the exec
|
|
846
|
+
// stdout carried them.
|
|
847
|
+
parser.push(JSON.stringify(event) + '\n');
|
|
848
|
+
mirrorUpTo(parser.lines.length - 1, false);
|
|
849
|
+
if (type === 'assistant') {
|
|
850
|
+
messageKey = undefined;
|
|
851
|
+
legacyMessage++;
|
|
852
|
+
}
|
|
853
|
+
};
|
|
854
|
+
const sendAndInit = async () => {
|
|
855
|
+
if (runAbort.signal.aborted)
|
|
856
|
+
throw new Error('subagent-claude: run cancelled locally');
|
|
857
|
+
const rt = runtime;
|
|
858
|
+
// The sink installs only when this turn actually starts: an earlier
|
|
859
|
+
// turn's result must land in ITS OWN round's sink, never in this one
|
|
860
|
+
// (the chain below guarantees the previous result already landed).
|
|
861
|
+
rt.onEvent = onEvent;
|
|
862
|
+
turnInFlight = true;
|
|
863
|
+
awaitingResult = true;
|
|
864
|
+
rt.send({
|
|
865
|
+
type: 'user',
|
|
866
|
+
message: { role: 'user', content: [{ type: 'text', text: task }] },
|
|
867
|
+
});
|
|
868
|
+
if (rt.sessionId === undefined) {
|
|
869
|
+
const initWatchdog = setTimeout(() => {
|
|
870
|
+
// The channel's only proof: without an init the runtime cannot
|
|
871
|
+
// serve — trip the breaker and reclaim it.
|
|
872
|
+
void (async () => {
|
|
873
|
+
this.markChannelBroken();
|
|
874
|
+
await this.reclaim(String(childSession.id));
|
|
875
|
+
rejectInit(new LiveChannelUnavailableError('the stream-json init never arrived'));
|
|
876
|
+
})();
|
|
877
|
+
}, this.timeouts.initMs);
|
|
878
|
+
initWatchdog.unref();
|
|
879
|
+
try {
|
|
880
|
+
const cancelled = new Promise((_, reject) => {
|
|
881
|
+
runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-claude: run cancelled locally')), { once: true });
|
|
882
|
+
});
|
|
883
|
+
const sessionId = await Promise.race([initArrived, cancelled]).catch(async (error) => {
|
|
884
|
+
// A cancel inside the init window reclaims the never-proven
|
|
885
|
+
// runtime (the turn was sent but never acknowledged).
|
|
886
|
+
if (runAbort.signal.aborted && !(error instanceof LiveChannelUnavailableError)) {
|
|
887
|
+
await this.reclaim(String(childSession.id));
|
|
888
|
+
}
|
|
889
|
+
throw error;
|
|
890
|
+
});
|
|
891
|
+
spec.onSessionId?.(sessionId);
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
clearTimeout(initWatchdog);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
const accepted = (async () => {
|
|
899
|
+
const rt = await this.ensureRuntime(spec, request.signal);
|
|
900
|
+
runtime = rt;
|
|
901
|
+
if (runAbort.signal.aborted) {
|
|
902
|
+
await this.reclaim(String(childSession.id));
|
|
903
|
+
throw new Error('subagent-claude: run cancelled locally');
|
|
904
|
+
}
|
|
905
|
+
// The turn boundary opens before the message goes out (exec parity: the
|
|
906
|
+
// exec path opens at spawn).
|
|
907
|
+
childSession.append('turn/start', { turn });
|
|
908
|
+
childSession.append('user/message', createUserMessage({
|
|
909
|
+
content: [{ type: 'text', text: task }],
|
|
910
|
+
source: { kind: 'user' },
|
|
911
|
+
}), { surfaceOp: 'append' });
|
|
912
|
+
turnOpened = true;
|
|
913
|
+
persist();
|
|
914
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: task });
|
|
915
|
+
// One turn at a time per member: this message goes out only after the
|
|
916
|
+
// previous turn's result landed (claude would queue the stdin frame,
|
|
917
|
+
// but its result must never land in this round's sink).
|
|
918
|
+
rt.turnChain = rt.turnChain.catch(() => { }).then(sendAndInit);
|
|
919
|
+
await rt.turnChain;
|
|
920
|
+
})();
|
|
921
|
+
const attempt = accepted.then(async () => {
|
|
922
|
+
if (runAbort.signal.aborted)
|
|
923
|
+
throw new Error('subagent-claude: run cancelled locally');
|
|
924
|
+
const rt = runtime;
|
|
925
|
+
const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-claude: the live runtime exited mid-round '
|
|
926
|
+
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
927
|
+
processFailure.catch(() => { });
|
|
928
|
+
const resultPromise = Promise.race([resultEvent, processFailure]);
|
|
929
|
+
// Hold the chain until THIS turn's result lands, so the next round's
|
|
930
|
+
// message never starts mid-turn.
|
|
931
|
+
rt.turnChain = resultPromise;
|
|
932
|
+
const event = await resultPromise;
|
|
933
|
+
if (event['is_error'] === true) {
|
|
934
|
+
const message = typeof event['error'] === 'string' ? event['error'] : 'claude reported an error';
|
|
935
|
+
throw new Error(`subagent-claude live: ${message}`);
|
|
936
|
+
}
|
|
937
|
+
const output = collectOutput();
|
|
938
|
+
if (output.length === 0) {
|
|
939
|
+
throw new Error('subagent-claude live: the turn completed but produced no answer');
|
|
940
|
+
}
|
|
941
|
+
return { output, stopReason: 'completed' };
|
|
942
|
+
});
|
|
943
|
+
const result = settleRunResult({
|
|
944
|
+
attempt: () => Promise.race([attempt, abortBranch]),
|
|
945
|
+
collectOutput,
|
|
946
|
+
cancelled: () => runAbort.signal.aborted,
|
|
947
|
+
onError: (error, stopReason) => {
|
|
948
|
+
const diagnostics = runtime?.diagnostics ?? '';
|
|
949
|
+
const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
|
|
950
|
+
this.ctx.logger.warn(`subagent-claude: live round failed (${stopReason}): ${error.message}${suffix}`);
|
|
951
|
+
},
|
|
952
|
+
signal: request.signal,
|
|
953
|
+
onAbort,
|
|
954
|
+
}).then(async (settled) => {
|
|
955
|
+
roundSettled = true;
|
|
956
|
+
liveFlush.dispose();
|
|
957
|
+
turnInFlight = false;
|
|
958
|
+
if (turnOpened) {
|
|
959
|
+
// An aborted or failed turn never sees the result event, so its
|
|
960
|
+
// held-back line (and the usage already observed) would be lost —
|
|
961
|
+
// flush it now (the exec settle-mirror's partial-work contract).
|
|
962
|
+
if (mirrored < parser.lines.length)
|
|
963
|
+
mirrorUpTo(parser.lines.length, true);
|
|
964
|
+
// Streams whose completion never arrived (abort, or the turn ended
|
|
965
|
+
// mid-message) still finalize INSIDE the turn window: one forced
|
|
966
|
+
// snapshot each — interrupted on a non-completed round, the LAST one
|
|
967
|
+
// carrying the usage when no folded line did — then step/end.
|
|
968
|
+
if (streams.size > 0) {
|
|
969
|
+
const remaining = [...streams.values()];
|
|
970
|
+
for (const [position, stream] of remaining.entries()) {
|
|
971
|
+
appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
|
|
972
|
+
streamPublisher?.finish(stream.step);
|
|
973
|
+
if (stream.opened)
|
|
974
|
+
childSession.append('step/end', { turn, step: stream.step });
|
|
975
|
+
}
|
|
976
|
+
streams.clear();
|
|
977
|
+
activeStream = undefined;
|
|
978
|
+
}
|
|
979
|
+
if (settled.stopReason === 'completed') {
|
|
980
|
+
childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
|
|
981
|
+
}
|
|
982
|
+
else if (settled.stopReason === 'aborted') {
|
|
983
|
+
childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
childSession.append('turn/end', {
|
|
987
|
+
turn,
|
|
988
|
+
reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
// Settlement clears the round's event sink — by identity, and only once
|
|
993
|
+
// the turn's result landed: an in-flight turn's late result must still
|
|
994
|
+
// reach this round's resolver so the chain can converge.
|
|
995
|
+
if (runtime !== undefined && runtime.onEvent === onEvent && !awaitingResult) {
|
|
996
|
+
runtime.onEvent = undefined;
|
|
997
|
+
}
|
|
998
|
+
streamPublisher?.dispose();
|
|
999
|
+
// A completed answer is not a durable turn until its final boundary
|
|
1000
|
+
// and any closing stream checkpoints have reached the child store.
|
|
1001
|
+
if (turnOpened) {
|
|
1002
|
+
persist();
|
|
1003
|
+
await persistQueue;
|
|
1004
|
+
}
|
|
1005
|
+
return settled;
|
|
1006
|
+
});
|
|
1007
|
+
// Final mirror report + reaper re-arm, mirroring the exec settle pass's
|
|
1008
|
+
// progress contract. A round that never opened owns no span — skip it.
|
|
1009
|
+
void result.then(async () => {
|
|
1010
|
+
try {
|
|
1011
|
+
if (turnOpened) {
|
|
1012
|
+
await persistQueue.catch(() => { });
|
|
1013
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: mirrored });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
finally {
|
|
1017
|
+
// Every settled round reports its observation through the registry's
|
|
1018
|
+
// channel (record merge + `settled` event) — the live drive's half of
|
|
1019
|
+
// the exec path's onRoundSettled: the init event's model and build,
|
|
1020
|
+
// the result event's usage, and the round's tool-call accounting, each
|
|
1021
|
+
// absent when the stream never yielded it. Degrades silently on a
|
|
1022
|
+
// core predating recordRoundSettled (the round still settles).
|
|
1023
|
+
if (turnOpened) {
|
|
1024
|
+
const toolCalls = toolCallsOf(parser.toolCalls);
|
|
1025
|
+
const round = {
|
|
1026
|
+
...roundModel === undefined ? {} : { observedModel: roundModel },
|
|
1027
|
+
...roundCliVersion === undefined ? {} : { cliVersion: roundCliVersion },
|
|
1028
|
+
...parser.usage === undefined ? {} : { usage: parser.usage },
|
|
1029
|
+
...toolCalls === undefined ? {} : { toolCalls },
|
|
1030
|
+
};
|
|
1031
|
+
const registry = localAgent;
|
|
1032
|
+
registry?.recordRoundSettled?.(childSession.id, round);
|
|
1033
|
+
}
|
|
1034
|
+
this.armIdleTimer(String(childSession.id));
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
// Publication gate: hold start() until the session is known and the turn
|
|
1038
|
+
// is in flight (channel errors still throw for the exec fallback), but
|
|
1039
|
+
// never hold it hostage — a cancel returns immediately.
|
|
1040
|
+
await Promise.race([accepted, abortBranch.catch(() => undefined)]);
|
|
1041
|
+
return subprocessRunHandle({
|
|
1042
|
+
id: childSession.id,
|
|
1043
|
+
result,
|
|
1044
|
+
signal: request.signal,
|
|
1045
|
+
onAbort,
|
|
1046
|
+
requestCancel,
|
|
1047
|
+
// The live teardown contract: NEVER kill the runtime here. The graceful
|
|
1048
|
+
// interrupt already went out; wait (bounded) for the turn's result to
|
|
1049
|
+
// land so the next round starts clean.
|
|
1050
|
+
teardown: async () => {
|
|
1051
|
+
if (roundSettled)
|
|
1052
|
+
return;
|
|
1053
|
+
await Promise.race([resultEvent.catch(() => ({})), delay(this.timeouts.convergeMs)]);
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|