@khorsheed/dsh-local-agent-dsh 0.2.0 → 0.2.2

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.
@@ -1,1782 +0,0 @@
1
- import { provisionDshScope, unitSubProfileName } from "./provision.js";
2
- import { t as resolveDshSessionLog } from "./session-log-DOsSz56-.js";
3
- import { join } from "node:path";
4
- import { LiveFlush, LiveStreamPublisher, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, subagentDelegationLabel } from "@khorsheed/dsh-local-agent";
5
- import { createHash, randomUUID } from "node:crypto";
6
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
7
- import { SessionId } from "@deepseek-ai/dsh-session";
8
- import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
9
- import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from "@khorsheed/dsh-local-agent/types";
10
- import { StringDecoder } from "node:string_decoder";
11
- import { BlockAssembler, createAssistantMessage } from "@deepseek-ai/dsh-llm";
12
- import { LIVE_SERVER_NAME, LIVE_WIRE_PROTOCOL_VERSION } from "@khorsheed/dsh-local-agent-dsh-headless/wire";
13
- import { readFile, readdir } from "node:fs/promises";
14
- import { promisify } from "node:util";
15
- import { zstdDecompress } from "node:zlib";
16
- //#region lib/types/model-launch.js
17
- /** Private launch environment: never persisted in a profile, transcript or argv. */
18
- const MODEL_CONFIG_ENV = "DSH_LOCAL_AGENT_MODEL_CONFIG";
19
- const MODEL_KEY_ENV = "DSH_LOCAL_AGENT_MODEL_API_KEY";
20
- function object(value) {
21
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
22
- }
23
- function settings(ctx) {
24
- return new Map((ctx.get("settings")?.describe?.() ?? []).flatMap((row) => {
25
- const value = object(row.value);
26
- return value === void 0 ? [] : [[String(row.ns), value]];
27
- }));
28
- }
29
- function piProfile(ctx, provider, values) {
30
- const directory = ctx.get("llm")?.listConfigurableProviders?.().find((row) => row.provider === provider);
31
- const ns = directory?.settingsNs ?? "llm-pi-ai";
32
- if (ns !== "llm-pi-ai") return void 0;
33
- if (directory && (directory.settingsPath.length !== 2 || directory.settingsPath[0] !== "providers" || directory.settingsPath[1] !== provider)) return void 0;
34
- return object(object(values.get(ns)?.providers)?.[provider]);
35
- }
36
- /** Whether this provider has a supported local configuration transfer contract. */
37
- function canInheritModelProvider(ctx, provider) {
38
- if (provider === "deepseek-official") return true;
39
- const profile = piProfile(ctx, provider, settings(ctx));
40
- return typeof profile?.apiKeyEnv === "string" && profile.apiKeyEnv.trim() !== "";
41
- }
42
- /** Snapshot only the selected local route and resolve only its explicitly named credential. */
43
- async function localModelLaunch(ctx, config, model) {
44
- const provider = model?.includes("/") ? model.slice(0, model.indexOf("/")) : "deepseek-official";
45
- const values = settings(ctx);
46
- let inherited, ref, keyName;
47
- if (provider === "deepseek-official") {
48
- const profile = structuredClone(values.get("llm-deepseek") ?? {});
49
- ref = config.apiKeyRef ?? (typeof profile.apiKeyEnv === "string" ? profile.apiKeyEnv : "DEEPSEEK_API_KEY");
50
- inherited = { deepseek: {
51
- ...profile,
52
- apiKeyEnv: "DEEPSEEK_API_KEY"
53
- } };
54
- keyName = "DEEPSEEK_API_KEY";
55
- } else {
56
- const profile = piProfile(ctx, provider, values);
57
- if (typeof profile?.apiKeyEnv !== "string" || profile.apiKeyEnv.trim() === "") throw new Error(`subagent-dsh: model provider "${provider}" has no inheritable API-key route; configure its credential reference in the host Models settings`);
58
- ref = profile.apiKeyEnv;
59
- inherited = { piAi: { providers: { [provider]: {
60
- ...structuredClone(profile),
61
- apiKeyEnv: MODEL_KEY_ENV
62
- } } } };
63
- keyName = MODEL_KEY_ENV;
64
- }
65
- const credential = await ctx.get("credentials")?.resolve(credentialRef(ref));
66
- if (!credential?.value) throw new Error(`subagent-dsh: credential ${ref} is unavailable for model provider "${provider}"`);
67
- const env = {
68
- [MODEL_CONFIG_ENV]: JSON.stringify(inherited),
69
- [keyName]: credential.value
70
- };
71
- return {
72
- env,
73
- fingerprint: createHash("sha256").update(JSON.stringify(env)).digest("hex")
74
- };
75
- }
76
- //#endregion
77
- //#region lib/types/session-mirror.js
78
- /**
79
- * Mirror a sub-dsh session's events into the parent-side dsh subagent session,
80
- * so a dsh delegation shows its full conversation (task, replies, thinking)
81
- * and real token usage in the standard 子代理 surface — the same fidelity the
82
- * other family harnesses get from their CLI transcript files. The sub-dsh
83
- * writes a real dsh session into the harness scoped home with the SAME id as
84
- * the parent-side child session and the same SessionEvent format, so the
85
- * mirror copies finalized `user/message`/`assistant/message` events verbatim
86
- * (no parsing, no re-encoding): reasoning blocks render natively and usage
87
- * rides the assistant events' own `usage` field into the tokenUsage
88
- * projection.
89
- *
90
- * Round scoping: the parent provider appends one `turn/start` per delegation
91
- * round, and the sub-dsh session numbers its turns identically (fresh = 1,
92
- * each resume +1), so a round's span is the sub-dsh events from its
93
- * `turn/start` with `turn === round` to the next `turn/start`. The sub-dsh's
94
- * own scaffolding user messages (agent-instructions, plugin, skill-catalog
95
- * sources) are filtered — only the caller task (`source.kind === 'user'`)
96
- * crosses. The sub-dsh's own `step/start`/`step/end` pairs cross verbatim
97
- * (each carries its (turn, step) coordinates): the real-time conversation
98
- * assembler only registers a step — and materializes its assistant message —
99
- * at a boundary, so without them the live subsession view renders the prompt
100
- * and tool cards but no replies. Turn boundaries are NOT copied: the parent's
101
- * real-time spawn→settle boundaries stay the authoritative timing.
102
- *
103
- * `todo/write` passthrough: the sub-dsh's task list is a standing whole-list
104
- * snapshot (last-wins). Each pass appends the round's LATEST snapshot only
105
- * when it differs from the child session's last mirrored one, so repeated
106
- * passes never duplicate an identical snapshot and the child's `todos`
107
- * projection (and the official TodoPanel in non-takeover views) reads the
108
- * member's task list for free.
109
- * @module @khorsheed/dsh-local-agent-dsh/session-mirror
110
- */
111
- /** Decompress one zstd session log (Node ≥22.15 built-in; engines require ^22.19). */
112
- const decompressZstd = promisify(zstdDecompress);
113
- /** Every zstd frame's little-endian magic number, marking a frame start in the stream. */
114
- const ZSTD_FRAME_MAGIC = 4247762216;
115
- /**
116
- * Decompress a multi-frame zstd session log. The persistence layer appends
117
- * one frame per flush batch, and Node's single-shot `zstdDecompress` stops at
118
- * the first frame's end (a streaming decoder does the same), so each frame
119
- * start is scanned and decompressed independently. A magic-looking byte
120
- * sequence inside compressed data fails decompression and is skipped.
121
- * @param content - the raw `.zstd` file bytes.
122
- * @returns the concatenated plaintext of every frame.
123
- */
124
- async function decompressZstdFrames(content) {
125
- const parts = [];
126
- for (let offset = 0; offset + 4 <= content.length; offset += 1) {
127
- if (content.readUInt32LE(offset) !== ZSTD_FRAME_MAGIC) continue;
128
- try {
129
- parts.push((await decompressZstd(content.subarray(offset))).toString("utf8"));
130
- } catch {}
131
- }
132
- return parts.join("");
133
- }
134
- /**
135
- * Read a sub-dsh session's event log from the scoped home. The session lives
136
- * at `<homeDir>/sessions/<workspace>/<id>/` (the runner passes the bare uuid);
137
- * WHICH file inside that directory holds the history is the host's choice —
138
- * `session.jsonl` on the original generation, `session.v3.jsonl.zstd` on host
139
- * 0.1.5 — so the name is resolved, never assumed (see
140
- * {@link resolveDshSessionLog}). The first line is the header, the rest are
141
- * events.
142
- * @param homeDir - the `dsh` harness's scoped home.
143
- * @param sessionId - the sub-dsh session id (same uuid as the child session).
144
- * @returns the events and their source file, or undefined when the session is
145
- * absent or unreadable.
146
- */
147
- async function readSubDshEvents(homeDir, sessionId) {
148
- let workspaces;
149
- try {
150
- workspaces = await readdir(join(homeDir, "sessions"));
151
- } catch {
152
- return;
153
- }
154
- for (const workspace of workspaces) for (const name of [sessionId, `session-${sessionId}`]) {
155
- const log = await resolveDshSessionLog(join(homeDir, "sessions", workspace, name));
156
- if (log === void 0) continue;
157
- let text;
158
- try {
159
- const content = await readFile(log.path);
160
- text = log.compressed ? await decompressZstdFrames(content) : content.toString("utf8");
161
- } catch {
162
- continue;
163
- }
164
- const events = [];
165
- for (const line of text.split("\n")) {
166
- if (line === "") continue;
167
- try {
168
- const parsed = JSON.parse(line);
169
- if (parsed.type === "session") continue;
170
- events.push(parsed);
171
- } catch {}
172
- }
173
- if (events.length > 0) return {
174
- events,
175
- log
176
- };
177
- }
178
- }
179
- /**
180
- * Sum one more assistant event's usage into a running round total. Each
181
- * sub-dsh assistant event carries ONE LLM call's accounting; the round's
182
- * windows (live polls, settle pass) recompute it from the round's span, so
183
- * the total is span-derived and never double counts across passes.
184
- */
185
- function addEventUsage(total, usage) {
186
- const raw = usage;
187
- if (raw === void 0 || typeof raw !== "object") return total;
188
- const num = (value) => {
189
- const n = Number(value);
190
- return Number.isFinite(n) && n > 0 ? n : 0;
191
- };
192
- const merged = {
193
- inputTokens: (total?.inputTokens ?? 0) + num(raw.inputTokens),
194
- outputTokens: (total?.outputTokens ?? 0) + num(raw.outputTokens)
195
- };
196
- const cacheRead = (total?.cacheReadTokens ?? 0) + num(raw.cacheReadTokens);
197
- const cacheWrite = (total?.cacheWriteTokens ?? 0) + num(raw.cacheWriteTokens);
198
- if (cacheRead > 0) merged.cacheReadTokens = cacheRead;
199
- if (cacheWrite > 0) merged.cacheWriteTokens = cacheWrite;
200
- return merged;
201
- }
202
- /**
203
- * Extract the round's observed model and token usage from its event span:
204
- the LAST assistant event's `message.source` names the model that ran, and
205
- * the assistant events' `usage` fields sum to the round's accounting.
206
- */
207
- function roundObservation(span) {
208
- let observedModel;
209
- let usage;
210
- const byName = {};
211
- let toolCount = 0;
212
- for (const event of span) {
213
- if (event.type === "tool/call") {
214
- const name = event.data.name;
215
- toolCount += 1;
216
- const key = typeof name === "string" && name !== "" ? name : "tool";
217
- byName[key] = (byName[key] ?? 0) + 1;
218
- continue;
219
- }
220
- if (event.type !== "assistant/message") continue;
221
- const data = event.data;
222
- const source = data.message?.source;
223
- if (source !== void 0 && typeof source === "object") {
224
- const model = typeof source.model === "string" && source.model !== "" ? source.model : void 0;
225
- if (model !== void 0) {
226
- const provider = typeof source.provider === "string" && source.provider !== "" ? source.provider : void 0;
227
- observedModel = provider === void 0 ? model : `${provider}/${model}`;
228
- }
229
- }
230
- usage = addEventUsage(usage, data.usage);
231
- }
232
- return {
233
- ...observedModel === void 0 ? {} : { observedModel },
234
- ...usage === void 0 ? {} : { usage },
235
- ...toolCount === 0 ? {} : { toolCalls: {
236
- count: toolCount,
237
- byName
238
- } }
239
- };
240
- }
241
- /** Visible answer text for progress/results; reasoning stays in the verbatim transcript. */
242
- function mirroredEventText(event) {
243
- const data = event.data;
244
- return (data.message?.content ?? data.content ?? []).filter((block) => block.type === "text" && typeof block.text === "string").map((block) => block.text).join("");
245
- }
246
- /** The delta-progress text for one mirrored tool event. */
247
- function mirroredToolText(event) {
248
- if (event.type === "tool/call") return `[工具 ${event.data.name ?? "tool"}]`;
249
- const result = event.data.message?.content?.[0]?.content?.map((block) => block.text ?? "").join("") ?? "";
250
- return result === "" ? "[工具结果]" : `[工具结果] ${result}`;
251
- }
252
- /**
253
- * Find the child-session seq of the mirrored `tool/call` carrying `callId`,
254
- * so a mirrored `tool/result`'s `sourceEventSeqs` points at the CHILD's call
255
- * event — the source event's own seqs reference the sub-dsh session's
256
- * numbering and are meaningless here.
257
- */
258
- function findMirroredCallSeq(childSession, callId) {
259
- for (let index = childSession.snapshotEvents().length - 1; index >= 0; index -= 1) {
260
- const event = childSession.snapshotEvents()[index];
261
- if (event?.type !== "tool/call") continue;
262
- if (event.data.callId === callId) return event.seq;
263
- }
264
- }
265
- /**
266
- * Append one mirrored `tool/call` or `tool/result` event — the native tool
267
- * card pair, so the child session renders the sub-dsh's tool activity with
268
- * the standard conversation's tool rows instead of not at all. Shared by the
269
- * file mirror's span loop and the live driver's per-event mirror.
270
- */
271
- function appendMirroredToolEvent(childSession, event) {
272
- if (event.type === "tool/call") childSession.append("tool/call", event.data);
273
- else if (event.type === "tool/result") {
274
- const data = event.data;
275
- const callSeq = findMirroredCallSeq(childSession, String(data.message.source.callId));
276
- childSession.append("tool/result", event.data, {
277
- surfaceOp: "append",
278
- ...callSeq === void 0 ? {} : { sourceEventSeqs: [callSeq] }
279
- });
280
- }
281
- return mirroredToolText(event);
282
- }
283
- /**
284
- * Append one mirrored `step/start` or `step/end` boundary verbatim — the event
285
- * carries its own (turn, step) coordinates, so the child session's location
286
- * index registers the same step the sub-dsh wrote. Shared by the file
287
- * mirror's span loop and the live driver's per-event mirror. An interrupted
288
- * step crosses as an OPEN pair (start without end): the location resolves to
289
- * the step either way, and no boundary is ever synthesized.
290
- */
291
- function appendMirroredStepBoundary(childSession, event) {
292
- if (event.type === "step/start") childSession.append("step/start", event.data);
293
- else if (event.type === "step/end") childSession.append("step/end", event.data);
294
- }
295
- /**
296
- * Append one appendable message event verbatim and report its delta text —
297
- * the single append path shared by the file mirror's span loop and the live
298
- * driver's per-event mirror, so both transports produce identical child
299
- * sessions.
300
- */
301
- function appendMirroredMessageEvent(childSession, event) {
302
- if (event.type === "user/message") childSession.append("user/message", event.data, { surfaceOp: "append" });
303
- else if (event.type === "assistant/message") childSession.append("assistant/message", event.data, { surfaceOp: "append" });
304
- return mirroredEventText(event);
305
- }
306
- /**
307
- * Mirror ONE live-pushed sub-dsh session event (the serve mode's
308
- * `session/event` wire notification) into the child session. This is the
309
- * live driver's transport-side entry into the SAME fold the file mirror
310
- * owns: the filter (only the caller task's `user/message`, every
311
- * `assistant/message`, the tool events, and the `step/start`/`step/end`
312
- * boundaries cross — verbatim; turn boundaries stay the parent's; scaffolding
313
- * stays behind) and the verbatim append are exactly `mirrorDshSession`'s
314
- * span-loop rules. The 0.1.5 sub-dsh emits no per-chunk events (the
315
- * `assistant/chunk` type is retired — an interrupted attempt settles as
316
- * `assistant/attempt`, which carries no surface content and never crosses),
317
- * so the `token` granularity no longer streams deltas into the child log;
318
- * live text rides the run-progress channel only.
319
- * The caller owns offset/dedupe (the live runtime pushes
320
- * each event once) and persistence batching.
321
- * @param childSession - the parent-side dsh subagent session.
322
- * @param event - the live event from the resident sub-dsh.
323
- * @param _options - granularity; retained for caller compatibility — the
324
- * retirement of per-chunk events leaves the mirror granularity-invariant.
325
- * @returns the mirrored text for delta progress, or undefined when the event
326
- * was filtered out or carries no progress text (a step boundary crosses but
327
- * is structure, not content).
328
- */
329
- function mirrorDshLiveEvent(childSession, event, _options) {
330
- if (event.type === "user/message" && event.data.source.kind === "user") return appendMirroredMessageEvent(childSession, event);
331
- if (event.type === "assistant/message") return appendMirroredMessageEvent(childSession, event);
332
- if (event.type === "tool/call" || event.type === "tool/result") return appendMirroredToolEvent(childSession, event);
333
- if (event.type === "step/start" || event.type === "step/end") {
334
- appendMirroredStepBoundary(childSession, event);
335
- return;
336
- }
337
- }
338
- /**
339
- * Mirror the current round's events from the sub-dsh session into the child
340
- * session, then persist (a live session syncs through the core's cached write
341
- * handle, a standalone one through a one-shot handle; see
342
- * {@link persistIfStandalone}). Runs both
343
- * from the provider's live poll (while the
344
- * sub-dsh writes its log in batches — a torn final zstd frame is skipped
345
- * until the next pass) and after the child process exits (the settle pass);
346
- * the round's already-mirrored prefix in the child session is the offset, so
347
- * the settle pass is a no-op when polling kept up. An aborted round still
348
- * preserves its partial work and real usage. All failures degrade to a warn:
349
- * the delegation result is already settled.
350
- * @param ctx - host context carrying session persistence.
351
- * @param childSession - the parent-side dsh subagent session.
352
- * @param homeDir - the `dsh` harness's scoped home.
353
- * @param subSessionId - the sub-dsh session id (same uuid as the child session).
354
- * @returns the newly mirrored events' texts and the round's mirrored total.
355
- */
356
- async function mirrorDshSession(ctx, childSession, homeDir, subSessionId) {
357
- const empty = {
358
- texts: [],
359
- total: 0
360
- };
361
- try {
362
- const read = await readSubDshEvents(homeDir, subSessionId);
363
- if (read === void 0) return empty;
364
- const { events, log } = read;
365
- const round = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length;
366
- if (round === 0) return empty;
367
- let start = -1;
368
- let end = events.length;
369
- for (let index = 0; index < events.length; index += 1) {
370
- const event = events[index];
371
- if (event?.type !== "turn/start") continue;
372
- if (start === -1 && event.data.turn === round) {
373
- start = index;
374
- continue;
375
- }
376
- if (start !== -1) {
377
- end = index;
378
- break;
379
- }
380
- }
381
- if (start === -1) return empty;
382
- let lastTurnStart = -1;
383
- for (let index = 0; index < childSession.snapshotEvents().length; index += 1) if (childSession.snapshotEvents()[index]?.type === "turn/start") lastTurnStart = index;
384
- const mirrored = childSession.snapshotEvents().slice(lastTurnStart + 1).filter((event) => event.type === "user/message" || event.type === "assistant/message" || event.type === "tool/call" || event.type === "tool/result" || event.type === "step/start" || event.type === "step/end").length;
385
- const roundSpan = events.slice(start, end).filter((event) => event.type === "user/message" && event.data.source.kind === "user" || event.type === "assistant/message" || event.type === "tool/call" || event.type === "tool/result" || event.type === "step/start" || event.type === "step/end");
386
- const span = roundSpan.slice(mirrored);
387
- const texts = [];
388
- let appended = 0;
389
- for (const event of span) {
390
- appended += 1;
391
- if (event.type === "step/start" || event.type === "step/end") {
392
- appendMirroredStepBoundary(childSession, event);
393
- continue;
394
- }
395
- texts.push(event.type === "tool/call" || event.type === "tool/result" ? appendMirroredToolEvent(childSession, event) : appendMirroredMessageEvent(childSession, event));
396
- }
397
- const observation = roundObservation(roundSpan);
398
- let todosAppended = 0;
399
- const latestTodos = events.slice(start, end).filter((event) => event.type === "todo/write").at(-1);
400
- const mirroredTodos = childSession.snapshotEvents().slice(lastTurnStart + 1).filter((event) => event.type === "todo/write");
401
- if (latestTodos !== void 0 && latestTodos.type === "todo/write") {
402
- if (JSON.stringify(mirroredTodos.at(-1)?.data) !== JSON.stringify(latestTodos.data)) {
403
- childSession.append("todo/write", latestTodos.data);
404
- todosAppended = 1;
405
- }
406
- }
407
- if (appended > 0 || todosAppended > 0) await persistChildSession(ctx, childSession);
408
- return {
409
- texts,
410
- total: mirrored + mirroredTodos.length + appended + todosAppended,
411
- sessionLogFile: log.filename,
412
- ...observation
413
- };
414
- } catch (error) {
415
- ctx.logger.warn(`subagent-dsh: session mirror failed: ${error instanceof Error ? error.message : String(error)}`);
416
- return empty;
417
- }
418
- }
419
- //#endregion
420
- //#region lib/types/live-driver.js
421
- /**
422
- * The dsh provider's live driver: one resident sub-dsh serve process per
423
- * member (child session), driven over the family-internal wire
424
- * (`@khorsheed/dsh-local-agent-dsh-headless/wire`). A delegation round is a
425
- * `turn/start` request to the member's resident runtime instead of a fresh
426
- * one-shot process, which buys what the process boundary could not:
427
- * runtime-level graceful interrupt (`turn/interrupt` → in-process
428
- * `Agent.cancel`; the process survives and the session stays continuable)
429
- * and push-mode mirroring (session events arrive as `session/event`
430
- * notifications and are mirrored event-by-event through the same fold the
431
- * file mirror owns, with the file-based `mirrorDshSession` as the settle
432
- * reconciliation pass).
433
- *
434
- * Lifecycle (the mode's main cost): the runtime spawns lazily on the member's
435
- * first live round, is reclaimed after an idle timeout or on plugin unload,
436
- * and is re-spawned — resuming the on-disk session — after a crash. Every
437
- * process is registered in {@link DshLiveDriver.runtimes} until reclaimed, so
438
- * no zombies survive a profile restart.
439
- * @module @khorsheed/dsh-local-agent-dsh/live-driver
440
- */
441
- /** Default idle lifetime of an unused resident runtime before reclaim. */
442
- const DEFAULT_LIVE_IDLE_MS = 18e5;
443
- /** Default timeout for a wire request acknowledgement. */
444
- const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 3e4;
445
- /** Default timeout for the serve handshake (a cold sub-dsh boot can be slow). */
446
- const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 6e4;
447
- /** Bounded wait for an interrupted turn to converge while a run disposes. */
448
- const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5e3;
449
- /** Bounded wait for a reclaimed runtime's own shutdown before SIGTERM. */
450
- const RECLAIM_SHUTDOWN_GRACE_MS = 1e3;
451
- /** How long the channel stays broken before a round retries live. */
452
- const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
453
- /**
454
- * The serve channel could not come up (spawn failure or handshake
455
- * timeout/mismatch). The provider catches exactly this and falls back to the
456
- * exec one-shot; any other error is a real round failure.
457
- */
458
- var LiveChannelUnavailableError = class extends Error {
459
- constructor(message) {
460
- super(message);
461
- this.name = "LiveChannelUnavailableError";
462
- }
463
- };
464
- const DEFAULT_TIMEOUTS = {
465
- initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
466
- requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
467
- convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
468
- channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS
469
- };
470
- function delay(ms) {
471
- return new Promise((resolve) => setTimeout(resolve, ms));
472
- }
473
- function thrown$1(value) {
474
- return value instanceof Error ? value : new Error(String(value));
475
- }
476
- /**
477
- * One resident serve process: the wire client, the process registry entry,
478
- * and the reclaim ladder (wire shutdown → grace → SIGTERM → grace → SIGKILL
479
- * via the subprocess handle's own terminate).
480
- */
481
- var LiveRuntime = class {
482
- child;
483
- timeouts;
484
- warn;
485
- /** Set once the process exited or was reclaimed; a dead runtime never serves again. */
486
- dead = false;
487
- /**
488
- * The model this process bound at spawn (`--model`), or undefined when it
489
- * spawned with no model flag at all. A round whose start model differs
490
- * retires the runtime instead of silently running the old one.
491
- */
492
- modelLaunchFingerprint;
493
- configurationKey;
494
- boundModel;
495
- reclaimed = false;
496
- pending = /* @__PURE__ */ new Map();
497
- nextId = 0;
498
- buffer = "";
499
- decoder = new StringDecoder("utf8");
500
- stderrDecoder = new StringDecoder("utf8");
501
- stderrTail = "";
502
- /**
503
- * Per-round handlers, installed by the active round (one at a time per
504
- * member) and CLEARED when it settles — a notification with no active
505
- * round is dropped, never delivered into a stale closure.
506
- */
507
- onEvent;
508
- onStream;
509
- onIdle;
510
- /** Fires once when the process dies or is reclaimed (driver bookkeeping). */
511
- onDead;
512
- constructor(child, timeouts, warn) {
513
- this.child = child;
514
- this.timeouts = timeouts;
515
- this.warn = warn;
516
- child.stdout?.on("data", (chunk) => {
517
- this.feed(this.decoder.write(chunk));
518
- });
519
- child.stderr?.on("data", (chunk) => {
520
- this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
521
- });
522
- child.done.then(() => {
523
- this.markDead();
524
- }, () => {
525
- this.markDead();
526
- });
527
- }
528
- /** Diagnostic tail for round-failure logs. */
529
- get diagnostics() {
530
- return this.stderrTail.trim();
531
- }
532
- feed(text) {
533
- this.buffer += text;
534
- let index = this.buffer.indexOf("\n");
535
- while (index >= 0) {
536
- const line = this.buffer.slice(0, index);
537
- this.buffer = this.buffer.slice(index + 1);
538
- index = this.buffer.indexOf("\n");
539
- if (line.trim() === "") continue;
540
- this.dispatchLine(line);
541
- }
542
- }
543
- /** Handle one wire line; a malformed or hostile line never escapes this frame. */
544
- dispatchLine(line) {
545
- let message;
546
- try {
547
- message = JSON.parse(line);
548
- } catch {
549
- this.warn("subagent-dsh live: ignored a malformed wire line");
550
- return;
551
- }
552
- try {
553
- if (typeof message.id === "number" && ("result" in message || "error" in message)) {
554
- const request = this.pending.get(message.id);
555
- if (request === void 0) return;
556
- this.pending.delete(message.id);
557
- clearTimeout(request.timer);
558
- const wireError = message.error;
559
- if (wireError !== void 0 && wireError !== null) request.reject(/* @__PURE__ */ new Error(`subagent-dsh live: ${wireError.message ?? "wire error"}`));
560
- else request.resolve(message.result);
561
- return;
562
- }
563
- const params = message.params ?? {};
564
- if (typeof params.sessionId !== "string") return;
565
- if (message.method === "session/event") {
566
- const event = params.event;
567
- if (event === null || typeof event !== "object" || typeof event.type !== "string") return;
568
- this.onEvent?.(params.sessionId, typeof params.turn === "number" ? params.turn : null, event);
569
- } else if (message.method === "session/assistant-stream") {
570
- if (typeof params.turn !== "number" || params.frame === null || typeof params.frame !== "object") return;
571
- this.onStream?.(params.sessionId, params.turn, params.frame);
572
- } else if (message.method === "session/idle") {
573
- if (typeof params.turn !== "number") return;
574
- this.onIdle?.(params.sessionId, params.turn, params.reason ?? null);
575
- }
576
- } catch (error) {
577
- this.warn(`subagent-dsh live: notification handler failed: ${thrown$1(error).message}`);
578
- }
579
- }
580
- /** Send one request and await its acknowledgement. */
581
- request(method, params, timeoutMs) {
582
- if (this.dead) return Promise.reject(/* @__PURE__ */ new Error("subagent-dsh live: the runtime process is gone"));
583
- this.nextId += 1;
584
- const id = this.nextId;
585
- return new Promise((resolve, reject) => {
586
- const timer = setTimeout(() => {
587
- this.pending.delete(id);
588
- reject(/* @__PURE__ */ new Error(`subagent-dsh live: ${method} timed out`));
589
- timer.unref();
590
- }, timeoutMs ?? this.timeouts.requestMs);
591
- timer.unref();
592
- this.pending.set(id, {
593
- resolve: (result) => resolve(result),
594
- reject,
595
- timer
596
- });
597
- this.child.stdin?.write(JSON.stringify({
598
- jsonrpc: "2.0",
599
- id,
600
- method,
601
- params
602
- }) + "\n");
603
- });
604
- }
605
- markDead() {
606
- if (this.dead) return;
607
- this.dead = true;
608
- for (const request of this.pending.values()) {
609
- clearTimeout(request.timer);
610
- request.reject(/* @__PURE__ */ new Error("subagent-dsh live: the runtime process exited"));
611
- }
612
- this.pending.clear();
613
- this.onDead?.();
614
- }
615
- /** Graceful teardown: wire shutdown, a beat to exit on its own, then the terminate ladder. */
616
- async reclaim() {
617
- if (this.reclaimed) return;
618
- this.reclaimed = true;
619
- if (!this.dead) {
620
- await this.request("shutdown", {}, RECLAIM_SHUTDOWN_GRACE_MS).catch(() => {});
621
- await Promise.race([this.child.done.catch(() => {}), delay(RECLAIM_SHUTDOWN_GRACE_MS)]);
622
- }
623
- if (!this.dead) {
624
- this.child.terminate();
625
- await this.child.waitForExit();
626
- }
627
- await this.child.done.catch(() => {});
628
- this.markDead();
629
- }
630
- };
631
- /**
632
- * The live driver owns every resident runtime of this provider generation.
633
- * The LiveDriverSwitch builds a generation when the settings resolve live on,
634
- * retires one with {@link DshLiveDriver.drain} (never interrupts), and
635
- * disposes with the enabled toggle (disposeAll).
636
- */
637
- var DshLiveDriver = class {
638
- ctx;
639
- config;
640
- timeouts;
641
- runtimes = /* @__PURE__ */ new Map();
642
- /** In-flight spawns by member: concurrent rounds share one, disposeAll waits them out. */
643
- ensuring = /* @__PURE__ */ new Map();
644
- idleTimers = /* @__PURE__ */ new Map();
645
- /** Per-member round serialization (the resume lock covers resume-vs-resume only). */
646
- roundChains = /* @__PURE__ */ new Map();
647
- /** When the channel last failed its spawn/handshake probe (breaker with cooldown). */
648
- channelBrokenAt;
649
- disposed = false;
650
- /**
651
- * Set by drain() (a settings-driven generation handoff): new rounds are
652
- * refused so the provider falls back to exec, while in-flight rounds finish
653
- * on their runtime undisturbed. Unlike `disposed`, the driver still serves
654
- * what it already accepted.
655
- */
656
- draining = false;
657
- /** Aborts in-flight spawns when the driver is disposed mid-handshake. */
658
- disposeController = new AbortController();
659
- constructor(ctx, config, timeouts = DEFAULT_TIMEOUTS) {
660
- this.ctx = ctx;
661
- this.config = config;
662
- this.timeouts = timeouts;
663
- }
664
- /**
665
- * Whether the channel is in its post-failure cooldown. A spawn/handshake
666
- * failure trips the breaker (rounds fall back to exec), but only for
667
- * `channelRetryMs` — a transient boot fault must not disable live driving
668
- * until the next plugin reload.
669
- */
670
- get disabled() {
671
- return this.channelBrokenAt !== void 0 && Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? 3e5);
672
- }
673
- /** Count of resident runtimes currently registered (zombie accounting in tests). */
674
- get liveCount() {
675
- return this.runtimes.size;
676
- }
677
- clearIdleTimer(key) {
678
- const timer = this.idleTimers.get(key);
679
- if (timer !== void 0) clearTimeout(timer);
680
- this.idleTimers.delete(key);
681
- }
682
- /** Arm the idle reaper; every round settlement re-arms it. */
683
- armIdleTimer(key) {
684
- this.clearIdleTimer(key);
685
- const idleMs = this.config.liveIdleMs ?? 18e5;
686
- const timer = setTimeout(() => {
687
- this.reclaim(key);
688
- }, idleMs);
689
- timer.unref();
690
- this.idleTimers.set(key, timer);
691
- }
692
- /** Reclaim one runtime: deregister, wire shutdown, terminate ladder, member release. */
693
- async reclaim(key) {
694
- const runtime = this.runtimes.get(key);
695
- this.clearIdleTimer(key);
696
- if (runtime === void 0) return;
697
- this.runtimes.delete(key);
698
- await runtime.reclaim();
699
- }
700
- /** Reclaim every runtime (plugin unload); no process survives the profile. */
701
- async disposeAll() {
702
- this.disposed = true;
703
- this.disposeController.abort();
704
- await Promise.all([...this.ensuring.values()].map((pending) => pending.catch(() => void 0)));
705
- for (const key of [...this.runtimes.keys()]) await this.reclaim(key);
706
- }
707
- /**
708
- * True while the member's runtime exists or is being spawned. The settings
709
- * controller gates a new generation on this: a fresh driver must not serve
710
- * a member whose retiring generation still hosts the (same) sub-dsh session.
711
- */
712
- hasRuntime(key) {
713
- return this.runtimes.has(key) || this.ensuring.has(key);
714
- }
715
- /** @deprecated Compatibility no-op: live output is always incremental. */
716
- setLiveMirrorGranularity(_granularity) {}
717
- /**
718
- * Drain for a settings-driven generation handoff: refuse new rounds (the
719
- * provider's catch falls back to exec), let every in-flight round finish on
720
- * its runtime, then reclaim. Resolves when no runtime or spawn remains.
721
- * Unlike disposeAll, in-flight work is never interrupted.
722
- */
723
- async drain() {
724
- this.draining = true;
725
- const keys = /* @__PURE__ */ new Set([
726
- ...this.runtimes.keys(),
727
- ...this.ensuring.keys(),
728
- ...this.roundChains.keys()
729
- ]);
730
- await Promise.all([...keys].map(async (key) => {
731
- await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => void 0);
732
- await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => void 0);
733
- await this.reclaim(key);
734
- }));
735
- }
736
- /**
737
- * The model the member's live runtime bound at spawn: the identifier, or
738
- * undefined when the runtime spawned with no `--model`. Null when the
739
- * member has no live runtime in this generation — the broker's switch check
740
- * distinguishes "no runtime to retire" from "runtime bound to no model".
741
- */
742
- boundModelOf(key) {
743
- const runtime = this.runtimes.get(key);
744
- if (runtime === void 0 || runtime.dead) return null;
745
- return runtime.boundModel;
746
- }
747
- /**
748
- * Retire the member's resident runtime so the NEXT round respawns (the
749
- * sub-dsh session itself carries over via the on-disk resume). The
750
- * composer's model switch calls this after storing the new override; the
751
- * caller guarantees no round is in flight for the member.
752
- */
753
- async retireRuntime(key) {
754
- await this.reclaim(key);
755
- }
756
- /**
757
- * The member's live runtime, spawning it (once per member at a time) when
758
- * absent or dead. `signal` abandons the spawn on cancellation.
759
- */
760
- async ensureRuntime(spec, launch, signal) {
761
- const key = spec.sessionId;
762
- const startModel = spec.startModel?.trim();
763
- const existing = this.runtimes.get(key);
764
- if (existing !== void 0 && !existing.dead) {
765
- if (existing.modelLaunchFingerprint !== launch.fingerprint || spec.configuration !== void 0 && existing.configurationKey !== JSON.stringify(spec.configuration) || spec.configuration === void 0 && existing.configurationKey !== void 0 || startModel !== void 0 && startModel !== "" && existing.boundModel !== startModel) await this.reclaim(key);
766
- else {
767
- this.clearIdleTimer(key);
768
- return existing;
769
- }
770
- } else if (existing !== void 0) this.runtimes.delete(key);
771
- const pending = this.ensuring.get(key);
772
- if (pending !== void 0) return pending;
773
- const spawn = this.spawnRuntime(spec, launch, signal).finally(() => {
774
- this.ensuring.delete(key);
775
- });
776
- this.ensuring.set(key, spawn);
777
- return spawn;
778
- }
779
- /**
780
- * Spawn the member's resident runtime and prove the wire with a handshake.
781
- * Spawn/handshake failure trips the channel breaker (exec fallback) and
782
- * throws {@link LiveChannelUnavailableError}; a later crash is NOT a broken
783
- * channel — the next round re-spawns and resumes the on-disk session. A
784
- * cancellation mid-spawn reclaims the half-started runtime and throws the
785
- * abort instead, without touching the breaker.
786
- */
787
- async spawnRuntime(spec, launch, signal) {
788
- const key = spec.sessionId;
789
- if (this.disposed) throw new LiveChannelUnavailableError("the live driver is disposed");
790
- const profileName = this.config.profileName ?? "headless-local-agent-dsh";
791
- const member = registerMemberRun(this.ctx, "dsh-cli", spec.sessionId, spec.parentSessionId);
792
- const startModel = spec.startModel?.trim();
793
- const model = spec.configuration !== void 0 ? spec.configuration.model : startModel !== void 0 && startModel !== "" ? startModel : this.config.modelFor?.(key)?.trim();
794
- const spawnSpec = {
795
- argv: [
796
- ...dshLaunchArgv(this.config),
797
- "--profile",
798
- profileName,
799
- "--serve",
800
- ...model === void 0 || model === "" ? [] : ["--model", model],
801
- ...spec.configuration?.effort === void 0 ? [] : ["--effort", spec.configuration.effort]
802
- ],
803
- cwd: spec.cwd,
804
- stdio: {
805
- stdin: "pipe",
806
- stdout: "pipe",
807
- stderr: "pipe"
808
- },
809
- graceMs: DEFAULT_DISPOSE_GRACE_MS,
810
- env: delegationEnv({
811
- DSH_HOME: spec.homeDir,
812
- ...launch.env,
813
- ...process.env.TSX_TSCONFIG_PATH === void 0 ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
814
- ...member === void 0 ? {} : member.env
815
- })
816
- };
817
- let child;
818
- try {
819
- child = this.ctx.subprocess.spawn(spawnSpec);
820
- } catch (error) {
821
- member?.release();
822
- this.markChannelBroken();
823
- throw new LiveChannelUnavailableError(`the serve process failed to spawn: ${thrown$1(error).message}`);
824
- }
825
- const runtime = new LiveRuntime(child, this.timeouts, (message) => {
826
- this.ctx.logger.warn(message);
827
- });
828
- runtime.boundModel = model === void 0 || model === "" ? void 0 : model;
829
- runtime.modelLaunchFingerprint = launch.fingerprint;
830
- runtime.configurationKey = spec.configuration === void 0 ? void 0 : JSON.stringify(spec.configuration);
831
- runtime.onDead = () => {
832
- if (this.runtimes.get(key) === runtime) this.runtimes.delete(key);
833
- this.clearIdleTimer(key);
834
- member?.release();
835
- };
836
- const aborted = new Promise((_, reject) => {
837
- const cancel = () => {
838
- reject(/* @__PURE__ */ new Error("subagent-dsh: run cancelled locally"));
839
- };
840
- const unload = () => {
841
- reject(new LiveChannelUnavailableError("the live driver was disposed during spawn"));
842
- };
843
- if (signal.aborted) {
844
- cancel();
845
- return;
846
- }
847
- if (this.disposeController.signal.aborted) {
848
- unload();
849
- return;
850
- }
851
- signal.addEventListener("abort", cancel, { once: true });
852
- this.disposeController.signal.addEventListener("abort", unload, { once: true });
853
- });
854
- aborted.catch(() => {});
855
- try {
856
- const hello = await Promise.race([runtime.request("initialize", {}, this.timeouts.initializeMs), aborted]);
857
- if (hello.protocolVersion !== LIVE_WIRE_PROTOCOL_VERSION || hello.serverInfo.name !== LIVE_SERVER_NAME) throw new Error("the serve handshake reported an unexpected identity");
858
- } catch (error) {
859
- await runtime.reclaim();
860
- if (signal.aborted) throw thrown$1(error);
861
- this.markChannelBroken();
862
- throw new LiveChannelUnavailableError(`the serve handshake failed: ${thrown$1(error).message}`);
863
- }
864
- if (this.disposed) {
865
- await runtime.reclaim();
866
- throw new LiveChannelUnavailableError("the live driver was disposed during spawn");
867
- }
868
- this.channelBrokenAt = void 0;
869
- this.runtimes.set(key, runtime);
870
- return runtime;
871
- }
872
- markChannelBroken() {
873
- this.channelBrokenAt = Date.now();
874
- }
875
- /**
876
- * Drive one delegation round on the member's resident runtime. Rounds of
877
- * one member are strictly serialized: the facade's resume lock covers
878
- * resume-vs-resume only, and a resume racing an in-flight fresh round would
879
- * otherwise overwrite the runtime's single notification sink and strand
880
- * the earlier round forever.
881
- */
882
- /** Prepare the owned headless agent and validate its adapter without a model turn. */
883
- async prepare(spec, signal) {
884
- signal.throwIfAborted();
885
- if (this.draining || this.disabled) throw new LiveChannelUnavailableError("the live driver is unavailable for preparation");
886
- try {
887
- provisionDshScope(spec.homeDir, {
888
- ...this.config,
889
- inheritModelConfiguration: true
890
- });
891
- const launch = await localModelLaunch(this.ctx, this.config, spec.configuration?.model ?? spec.startModel ?? this.config.modelFor?.(spec.sessionId));
892
- await (await this.ensureRuntime(spec, launch, signal)).request("session/prepare", {
893
- sessionId: spec.sessionId,
894
- resume: spec.resume !== void 0
895
- });
896
- signal.throwIfAborted();
897
- this.armIdleTimer(spec.sessionId);
898
- } catch (error) {
899
- await this.reclaim(spec.sessionId);
900
- throw error;
901
- }
902
- }
903
- async startRound(request, spec) {
904
- if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
905
- const key = String(spec.childSession.id);
906
- const round = (this.roundChains.get(key) ?? Promise.resolve()).catch(() => {}).then(() => this.startRoundLocked(request, spec));
907
- this.roundChains.set(key, round.then((handle) => handle.result.catch(() => ({})), () => ({})));
908
- return round;
909
- }
910
- /**
911
- * The serialized round body. Mirrors the exec run's settlement contract
912
- * exactly (settleRunResult + turn/end bookkeeping) so the facade, the
913
- * tool, and the projections cannot tell the difference — except `cancel`
914
- * is a runtime interrupt and the process survives. Cancellation is honored
915
- * in EVERY window: a slow spawn/handshake or a slow accept races the abort
916
- * signal and settles aborted instead of running the turn to completion
917
- * unwatched.
918
- */
919
- async startRoundLocked(request, spec) {
920
- const task = dshTextTask(request.prompt);
921
- if (request.signal.aborted) throw new Error("subagent-dsh: request was aborted before the run started");
922
- if (this.draining) throw new LiveChannelUnavailableError("the live driver is draining (a settings change retired this generation)");
923
- if (this.disposed) throw new Error("subagent-dsh: the live driver is disposed");
924
- const turn = spec.resume?.turn ?? 1;
925
- const childSession = spec.childSession;
926
- const localAgent = this.ctx.get("localAgent");
927
- const runAbort = new AbortController();
928
- let roundSettled = false;
929
- let runtime;
930
- /** True once turn/start may have reached the sub-dsh (interrupt becomes meaningful). */
931
- let acceptSent = false;
932
- /** True once the parent's turn/start boundary is in the child session. */
933
- let turnOpened = false;
934
- let lastText = "";
935
- let mirroredMessages = 0;
936
- /** Events of this round that arrived before the boundary opened (same-chunk batching). */
937
- const bufferedEvents = [];
938
- let persistQueue = Promise.resolve();
939
- const persist = () => {
940
- persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
941
- this.ctx.logger.warn(`subagent-dsh: live persistence failed: ${thrown$1(error).message}`);
942
- });
943
- };
944
- const streamPublisher = localAgent?.liveStreams === void 0 ? void 0 : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, (error) => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
945
- const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`subagent-dsh: live flush failed: ${String(error)}`));
946
- let streaming;
947
- const publishStream = () => {
948
- if (streaming === void 0 || streamPublisher === void 0) return;
949
- const blocks = streaming.assembler.interruptedBlocks();
950
- const receivedAt = streaming.receivedAt ?? Date.now();
951
- for (const [index, block] of blocks.entries()) if ((block.type === "reasoning" || block.type === "text") && block.text !== "") streamPublisher.update(streaming.step, block.type === "reasoning" ? "think" : "text", block.text, {
952
- itemId: `${streaming.id}:${index}`,
953
- receivedAt
954
- });
955
- delete streaming.receivedAt;
956
- };
957
- const acceptStream = (frame) => {
958
- if (streamPublisher === void 0) return;
959
- if (frame.type === "start") {
960
- if (streaming !== void 0) liveFlush.cancel(streaming.step);
961
- streaming = {
962
- id: String(frame.attemptId),
963
- step: frame.step,
964
- assembler: new BlockAssembler(),
965
- next: 0
966
- };
967
- } else if (frame.type === "chunk" && streaming?.id === frame.attemptId) {
968
- if (frame.index < streaming.next) return;
969
- if (frame.index !== streaming.next) throw new Error("non-contiguous DSH assistant stream");
970
- streaming.next++;
971
- streaming.receivedAt ??= Date.now();
972
- streaming.assembler.push(frame.chunk);
973
- liveFlush.schedule(streaming.step, publishStream);
974
- }
975
- };
976
- const requestCancel = () => {
977
- if (roundSettled || runAbort.signal.aborted) return;
978
- runAbort.abort(/* @__PURE__ */ new Error("subagent-dsh: run cancelled locally"));
979
- if (acceptSent && runtime !== void 0 && !runtime.dead) runtime.request("turn/interrupt", { sessionId: spec.sessionId }).catch(() => {});
980
- };
981
- const onAbort = () => {
982
- requestCancel();
983
- };
984
- request.signal.addEventListener("abort", onAbort, { once: true });
985
- const abortBranch = new Promise((_, reject) => {
986
- runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-dsh: run cancelled locally")), { once: true });
987
- });
988
- const mirrorOne = (event) => {
989
- const text = mirrorDshLiveEvent(childSession, event);
990
- if (event.type === "assistant/message") {
991
- liveFlush.cancel(event.data.step);
992
- streamPublisher?.finish(event.data.step);
993
- if (streaming?.step === event.data.step) streaming = void 0;
994
- }
995
- if (event.type === "user/message" || event.type === "assistant/message") {
996
- mirroredMessages += 1;
997
- persist();
998
- }
999
- if (text !== void 0) localAgent?.reportRunProgress(childSession.id, {
1000
- kind: "delta",
1001
- text
1002
- });
1003
- if (event.type === "assistant/message" && text !== void 0 && text !== "") lastText = text;
1004
- };
1005
- const openTurn = () => {
1006
- childSession.append("turn/start", { turn });
1007
- turnOpened = true;
1008
- for (const entry of bufferedEvents.splice(0)) if (entry.kind === "event") mirrorOne(entry.event);
1009
- else acceptStream(entry.frame);
1010
- };
1011
- let nativeFailure;
1012
- let resolveIdle;
1013
- const idle = new Promise((resolve) => {
1014
- resolveIdle = resolve;
1015
- });
1016
- const installHandlers = (rt) => {
1017
- rt.onEvent = (sessionId, eventTurn, event) => {
1018
- if (sessionId !== spec.sessionId || eventTurn !== turn) return;
1019
- if (!turnOpened) {
1020
- bufferedEvents.push({
1021
- kind: "event",
1022
- event
1023
- });
1024
- return;
1025
- }
1026
- mirrorOne(event);
1027
- };
1028
- rt.onStream = (sessionId, eventTurn, frame) => {
1029
- if (sessionId !== spec.sessionId || eventTurn !== turn || roundSettled) return;
1030
- if (!turnOpened) bufferedEvents.push({
1031
- kind: "stream",
1032
- frame
1033
- });
1034
- else acceptStream(frame);
1035
- };
1036
- rt.onIdle = (sessionId, idleTurn, reason) => {
1037
- if (sessionId !== spec.sessionId || idleTurn !== turn) return;
1038
- resolveIdle({ reason });
1039
- };
1040
- };
1041
- provisionDshScope(spec.homeDir, {
1042
- ...this.config,
1043
- inheritModelConfiguration: true
1044
- });
1045
- const launch = await localModelLaunch(this.ctx, this.config, spec.configuration?.model ?? spec.startModel ?? this.config.modelFor?.(spec.sessionId));
1046
- const accepted = (async () => {
1047
- const rt = await this.ensureRuntime(spec, launch, request.signal);
1048
- runtime = rt;
1049
- if (runAbort.signal.aborted) {
1050
- await this.reclaim(spec.sessionId);
1051
- throw new Error("subagent-dsh: run cancelled locally");
1052
- }
1053
- installHandlers(rt);
1054
- acceptSent = true;
1055
- try {
1056
- await rt.request("turn/start", {
1057
- sessionId: spec.sessionId,
1058
- text: task,
1059
- resume: spec.resume !== void 0,
1060
- turn
1061
- });
1062
- } catch (error) {
1063
- if (!runAbort.signal.aborted) await this.reclaim(spec.sessionId);
1064
- throw thrown$1(error);
1065
- }
1066
- openTurn();
1067
- })();
1068
- const attempt = accepted.then(async () => {
1069
- if (runAbort.signal.aborted) throw new Error("subagent-dsh: run cancelled locally");
1070
- const processFailure = runtime.child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-dsh: the live runtime exited mid-round (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown$1(error)));
1071
- processFailure.catch(() => {});
1072
- const { reason } = await Promise.race([idle, processFailure]);
1073
- if (reason?.kind === "aborted") return {
1074
- output: collectOutput(),
1075
- stopReason: "aborted"
1076
- };
1077
- if (reason?.kind === "error") {
1078
- nativeFailure = {
1079
- message: reason.error.message,
1080
- code: reason.error.code
1081
- };
1082
- throw new Error(`subagent-dsh live: the turn failed: ${reason.error.message}`);
1083
- }
1084
- if (reason?.kind !== "completed") throw new Error("subagent-dsh live: the turn produced no outcome");
1085
- const output = collectOutput();
1086
- if (output.length === 0) throw new Error("subagent-dsh live: the turn completed but produced no answer");
1087
- return {
1088
- output,
1089
- stopReason: "completed"
1090
- };
1091
- });
1092
- const collectOutput = () => lastText === "" ? [] : [{
1093
- type: "text",
1094
- text: lastText
1095
- }];
1096
- const result = settleRunResult({
1097
- attempt: () => Promise.race([attempt, abortBranch]),
1098
- collectOutput,
1099
- cancelled: () => runAbort.signal.aborted,
1100
- onError: (error, stopReason) => {
1101
- const diagnostics = runtime?.diagnostics ?? "";
1102
- const suffix = diagnostics === "" ? "" : `; ${diagnostics}`;
1103
- this.ctx.logger.warn(`subagent-dsh: live round failed (${stopReason}): ${error.message}${suffix}`);
1104
- },
1105
- signal: request.signal,
1106
- onAbort
1107
- }).then(async (settled) => {
1108
- roundSettled = true;
1109
- liveFlush.dispose();
1110
- if (turnOpened && streaming !== void 0 && streamPublisher !== void 0) {
1111
- const blocks = streaming.assembler.interruptedBlocks();
1112
- if (blocks.length > 0) {
1113
- childSession.append("assistant/message", {
1114
- turn,
1115
- step: streaming.step,
1116
- message: createAssistantMessage({
1117
- content: blocks,
1118
- source: {
1119
- provider: "dsh-local",
1120
- model: "unobserved"
1121
- }
1122
- }),
1123
- stream: [],
1124
- interrupted: true
1125
- }, { surfaceOp: "append" });
1126
- streamPublisher.finish(streaming.step);
1127
- persist();
1128
- }
1129
- }
1130
- streamPublisher?.dispose();
1131
- if (turnOpened) {
1132
- if (settled.stopReason === "completed") childSession.append("turn/end", {
1133
- turn,
1134
- reason: { kind: "completed" }
1135
- });
1136
- else if (settled.stopReason === "aborted") childSession.append("turn/end", {
1137
- turn,
1138
- reason: {
1139
- kind: "aborted",
1140
- reason: { kind: "parent" }
1141
- }
1142
- });
1143
- else childSession.append("turn/end", {
1144
- turn,
1145
- reason: {
1146
- kind: "error",
1147
- error: nativeFailure ?? {
1148
- message: "the live round did not complete",
1149
- code: "UNKNOWN"
1150
- }
1151
- }
1152
- });
1153
- }
1154
- if (runtime !== void 0) {
1155
- runtime.onStream = void 0;
1156
- runtime.onEvent = void 0;
1157
- runtime.onIdle = void 0;
1158
- }
1159
- if (turnOpened) {
1160
- persist();
1161
- await persistQueue;
1162
- }
1163
- return settled;
1164
- });
1165
- result.then(async () => {
1166
- try {
1167
- if (turnOpened) {
1168
- await persistQueue;
1169
- if (mirroredMessages > 0) persist();
1170
- await persistQueue.catch(() => {});
1171
- const delta = await mirrorDshSession(this.ctx, childSession, spec.homeDir, spec.sessionId);
1172
- if (localAgent !== void 0) {
1173
- for (const text of delta.texts) localAgent.reportRunProgress(childSession.id, {
1174
- kind: "delta",
1175
- text
1176
- });
1177
- localAgent.reportRunProgress(childSession.id, {
1178
- kind: "mirror",
1179
- mirroredLines: delta.total
1180
- });
1181
- const round = {
1182
- ...delta.observedModel === void 0 ? {} : { observedModel: delta.observedModel },
1183
- ...delta.usage === void 0 ? {} : { usage: delta.usage },
1184
- ...delta.toolCalls === void 0 ? {} : { toolCalls: delta.toolCalls }
1185
- };
1186
- localAgent?.recordRoundSettled?.(childSession.id, round);
1187
- }
1188
- }
1189
- } catch (error) {
1190
- this.ctx.logger.warn(`subagent-dsh: live settle mirror failed: ${thrown$1(error).message}`);
1191
- } finally {
1192
- this.armIdleTimer(spec.sessionId);
1193
- }
1194
- });
1195
- await Promise.race([accepted, abortBranch.catch(() => void 0)]);
1196
- return subprocessRunHandle({
1197
- id: SessionId(randomUUID()),
1198
- result,
1199
- signal: request.signal,
1200
- onAbort,
1201
- requestCancel,
1202
- teardown: async () => {
1203
- if (roundSettled) return;
1204
- await Promise.race([idle.catch(() => ({ reason: null })), delay(this.timeouts.convergeMs)]);
1205
- }
1206
- });
1207
- }
1208
- };
1209
- //#endregion
1210
- //#region lib/types/dsh-cli-provider.js
1211
- /**
1212
- * One-shot and resumable dsh CLI subagent lifecycle: spawn the sub-dsh
1213
- * headless profile (`dsh --profile <name> --session-id <uuid> "<task>"` or
1214
- * `--resume <uuid>`) through the subprocess seam with the harness scoped home
1215
- * as the child's `$DSH_HOME` and the parent's resolved DeepSeek key injected
1216
- * explicitly (the shared env scrub drops credential-shaped names and every
1217
- * `DSH_*` fact; the explicit env layer is the sanctioned override). The
1218
- * caller-supplied session id is the same uuid on every round: the fresh round
1219
- * creates the sub-dsh session with it, and each resume round continues exactly
1220
- * that session. No stdout parsing — the sub-dsh session id is known a priori.
1221
- * @module @khorsheed/dsh-local-agent-dsh/dsh-cli-provider
1222
- */
1223
- /**
1224
- * Spawn-env name carrying the bridge entry path into the sub-dsh process,
1225
- * where the headless bundle's mcp-client row reads it (`!!js`). The name is a
1226
- * family-internal contract between this provider and
1227
- * `@khorsheed/dsh-local-agent-dsh-headless`'s cordis.patch.yml — the member
1228
- * bridge itself reads only the socket/token env.
1229
- */
1230
- const MEMBER_BRIDGE_ENTRY_ENV = "DSH_MEMBER_BRIDGE_ENTRY";
1231
- /**
1232
- * The `NODE_OPTIONS` a containerized round carries. dsh's HTTP client is
1233
- * node's `fetch` (undici), which does NOT read `HTTP(S)_PROXY` by default: in
1234
- * an evaluation unit whose only egress is a whitelist proxy the CLI dials the
1235
- * API directly and fails with a transport error while the proxy never even
1236
- * receives a `CONNECT`. `--use-env-proxy` opens undici's `EnvHttpProxyAgent`,
1237
- * which does read those variables. Measured on the T16 image: without it the
1238
- * round fails, with it the same round answers in 1.3 s. dsh is the only one
1239
- * of the four CLIs that needs an extra knob to run inside a unit — the other
1240
- * three carry their own proxy support — so the injection is automatic rather
1241
- * than something every caller must remember, and the harness reports it in
1242
- * its effective settings so the condition file can see it.
1243
- */
1244
- const CONTAINER_NODE_OPTIONS = "--use-env-proxy";
1245
- /** Default POSIX grace between subprocess termination tiers. */
1246
- const DEFAULT_DISPOSE_GRACE_MS = 3e3;
1247
- /** Default interval between live session-mirror polls during a run. */
1248
- const DEFAULT_LIVE_MIRROR_INTERVAL_MS = 2e3;
1249
- /** Register one member run with the channel; see {@link MemberRunHandle}. */
1250
- function registerMemberRun(ctx, providerName, childSessionId, parentSessionId) {
1251
- const registry = ctx.localAgent;
1252
- if (typeof registry.registerMemberRun !== "function" || typeof registry.memberBridgeSocketPath !== "function" || typeof registry.memberBridgeCommand !== "function") return void 0;
1253
- const token = registry.registerMemberRun({
1254
- childSessionId,
1255
- parentSessionId,
1256
- provider: providerName
1257
- });
1258
- const bridge = registry.memberBridgeCommand();
1259
- let released = false;
1260
- return {
1261
- env: {
1262
- [MEMBER_BRIDGE_SOCKET_ENV]: registry.memberBridgeSocketPath(),
1263
- [MEMBER_BRIDGE_TOKEN_ENV]: token,
1264
- [MEMBER_BRIDGE_ENTRY_ENV]: bridge.args[0] ?? ""
1265
- },
1266
- release: () => {
1267
- if (released) return;
1268
- released = true;
1269
- registry.unregisterMemberRun(token);
1270
- }
1271
- };
1272
- }
1273
- /**
1274
- * One-shot dsh CLI subagent provider: every accepted fresh run starts a fresh
1275
- * sub-dsh headless process in the delegating Session's workspace, under the
1276
- * harness scoped home; a resume round (the family tool's staged resume intent)
1277
- * continues the SAME sub-dsh session with `--resume <uuid>` inside the SAME
1278
- * dsh child session. Mirrors the other family providers' one-shot lifecycle,
1279
- * including `NO_START_CAPABILITIES` — continuation is the family's own resume
1280
- * mechanism, not the official Agent-type continuable seam. With the live
1281
- * driver configured (`live: true`), rounds instead go to the resident sub-dsh
1282
- * serve process (see live-driver.ts); the exec path below stays the fallback.
1283
- */
1284
- var DshCliProvider = class {
1285
- ctx;
1286
- config;
1287
- live;
1288
- model;
1289
- overrides;
1290
- name = "dsh-cli";
1291
- capabilities = NO_START_CAPABILITIES;
1292
- inheritsParentContext = false;
1293
- /**
1294
- * @param live - the live driver, or a resolver returning the current
1295
- * generation's driver per member (the settings toggle swaps generations;
1296
- * a resolver may return undefined to steer one member's round to exec
1297
- * while a retiring generation still hosts it).
1298
- */
1299
- /**
1300
- * @param model - resolver for the harness's `model` plugin-config key, read
1301
- * PER ROUND so a settings-card write reaches the next delegation without a
1302
- * reload. A delegation that names its own model outranks it.
1303
- * @param overrides - resolver for the member's session-level model override
1304
- * (the composer picker, owned by the model broker). It outranks BOTH the
1305
- * delegation's recorded model and the configured one: on the exec path it
1306
- * re-points the next round's `--model`, and on the live path it is the
1307
- * member's start model — a resident runtime bound to a different model is
1308
- * retired so the round respawns onto the override.
1309
- */
1310
- constructor(ctx, config, live, model, overrides) {
1311
- this.ctx = ctx;
1312
- this.config = config;
1313
- this.live = live;
1314
- this.model = model;
1315
- this.overrides = overrides;
1316
- }
1317
- /** Resolve the live driver for one round's member, if live is on for it. */
1318
- liveDriver(childSessionId) {
1319
- const live = this.live;
1320
- if (live === void 0) return void 0;
1321
- return typeof live === "function" ? live(childSessionId) : live;
1322
- }
1323
- /** Per-round member-channel registration for the exec path (see {@link registerMemberRun}). */
1324
- memberRun(childSessionId, parentSessionId) {
1325
- return registerMemberRun(this.ctx, this.name, childSessionId, parentSessionId);
1326
- }
1327
- async start(request) {
1328
- const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
1329
- const scope = intent?.scope;
1330
- const homeDir = this.ctx.localAgent.homeDir("dsh", scope);
1331
- const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
1332
- if (cwd === void 0) throw new Error("subagent-dsh: the parent session has no working directory to run the CLI in");
1333
- const exec = intent?.exec;
1334
- if (exec !== void 0) containerScopedHome(exec, "DSH_HOME", "subagent-dsh");
1335
- if (intent !== void 0 && intent.kind === "resume") {
1336
- const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
1337
- assertResumeCwdUnchanged(record, cwd, "subagent-dsh");
1338
- assertResumeScopeUnchanged(record, scope, "subagent-dsh");
1339
- if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") return this.startDshResume(request, intent, cwd, homeDir, exec, scope, record?.model);
1340
- return this.ctx.localAgent.withMemberConfigurationRound({
1341
- childSessionId: intent.childSessionId,
1342
- provider: this.name,
1343
- parentSessionId: request.parent.session.id,
1344
- cwd,
1345
- ...scope === void 0 ? {} : { scope },
1346
- ...record?.model === void 0 ? {} : { model: record.model },
1347
- ...record?.effort === void 0 ? {} : { effort: record.effort },
1348
- ...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
1349
- }, (configuration) => this.startDshResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
1350
- }
1351
- const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
1352
- if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
1353
- if (intent?.effort !== void 0) throw new Error("DSH effort requires the configuration admission core");
1354
- return this.startDshFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
1355
- }
1356
- return this.ctx.localAgent.withMemberConfigurationRound({
1357
- childSessionId,
1358
- provider: this.name,
1359
- parentSessionId: request.parent.session.id,
1360
- cwd,
1361
- ...scope === void 0 ? {} : { scope },
1362
- ...intent?.model === void 0 ? {} : { model: intent.model },
1363
- ...intent?.effort === void 0 ? {} : { effort: intent.effort },
1364
- ...intent?.configurationLock === void 0 ? {} : { configurationLock: intent.configurationLock }
1365
- }, (configuration) => this.startDshFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
1366
- }
1367
- /** Fresh round: record the child session and delegation, spawn the sub-dsh create. */
1368
- async startDshFresh(request, cwd, homeDir, exec, scope, requestedModel, runId, configuration) {
1369
- let childSession;
1370
- try {
1371
- const sessions = this.ctx.get("sessions");
1372
- if (sessions === void 0) throw new Error("the sessions service is not mounted");
1373
- childSession = sessions.get(runId) ?? sessions.create(runId, { meta: {
1374
- cwd,
1375
- parentSession: request.parent.session.id,
1376
- origin: "subagent",
1377
- delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
1378
- } });
1379
- const harness = this.ctx.localAgent.get("dsh");
1380
- const label = subagentDelegationLabel(harness?.displayName ?? "dsh", request.label || void 0);
1381
- childSession.append("subagent/descriptor", {
1382
- version: SUBAGENT_DESCRIPTOR_VERSION,
1383
- mode: "one-shot",
1384
- provider: this.name,
1385
- label
1386
- });
1387
- establishSubagentCatalogChild(request.parent.session, childSession.header, label);
1388
- } catch (error) {
1389
- this.ctx.logger.warn(`subagent-dsh: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
1390
- }
1391
- this.ctx.localAgent.recordDelegation({
1392
- childSessionId: runId,
1393
- provider: this.name,
1394
- parentSessionId: request.parent.session.id,
1395
- cliSessionId: runId,
1396
- cwd,
1397
- ...scope === void 0 ? {} : { scope },
1398
- ...requestedModel === void 0 ? {} : { model: requestedModel }
1399
- });
1400
- const live = exec === void 0 ? this.liveDriver(runId) : void 0;
1401
- if (live !== void 0 && childSession !== void 0 && !live.disabled) {
1402
- const startModel = configuration === void 0 ? this.overrides?.(runId) ?? requestedModel : configuration.resolved.model;
1403
- try {
1404
- return await live.startRound(request, {
1405
- cwd,
1406
- homeDir,
1407
- childSession,
1408
- sessionId: runId,
1409
- parentSessionId: request.parent.session.id,
1410
- ...startModel === void 0 ? {} : { startModel },
1411
- ...configuration === void 0 ? {} : { configuration: configuration.resolved }
1412
- });
1413
- } catch (error) {
1414
- if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
1415
- this.ctx.logger.warn(`subagent-dsh: live driver unavailable, using the exec one-shot: ${error.message}`);
1416
- }
1417
- }
1418
- const member = exec === void 0 ? this.memberRun(runId, request.parent.session.id) : void 0;
1419
- try {
1420
- const run = await startDshCliRun(request, {
1421
- cwd,
1422
- homeDir,
1423
- childSession,
1424
- sessionId: runId,
1425
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model),
1426
- resume: void 0,
1427
- config: this.config,
1428
- ctx: this.ctx,
1429
- ...member === void 0 ? {} : { memberEnv: member.env },
1430
- ...exec === void 0 ? {} : { exec },
1431
- cliVersion: () => dshCliVersion(this.ctx, this.config, homeDir),
1432
- ...scope === void 0 ? {} : { scope }
1433
- });
1434
- if (member !== void 0) run.result.then(member.release, member.release);
1435
- return run;
1436
- } catch (error) {
1437
- member?.release();
1438
- throw error;
1439
- }
1440
- }
1441
- /** Resume round: continue the recorded sub-dsh session inside the existing child session. */
1442
- async startDshResume(request, intent, cwd, homeDir, exec, scope, requestedModel, configuration) {
1443
- if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) throw new Error(`subagent-dsh: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
1444
- try {
1445
- const childSession = this.ctx.get("sessions")?.get(SessionId(intent.childSessionId));
1446
- if (childSession === void 0) throw new Error(`subagent-dsh: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
1447
- const nextTurn = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length + 1;
1448
- const live = exec === void 0 ? this.liveDriver(intent.childSessionId) : void 0;
1449
- if (live !== void 0 && !live.disabled) {
1450
- const startModel = configuration === void 0 ? this.overrides?.(intent.childSessionId) ?? requestedModel : configuration.resolved.model;
1451
- try {
1452
- const liveRun = await live.startRound(request, {
1453
- cwd,
1454
- homeDir,
1455
- childSession,
1456
- sessionId: intent.cliSessionId,
1457
- parentSessionId: request.parent.session.id,
1458
- resume: { turn: nextTurn },
1459
- ...startModel === void 0 ? {} : { startModel },
1460
- ...configuration === void 0 ? {} : { configuration: configuration.resolved }
1461
- });
1462
- liveRun.result.then(() => {
1463
- this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
1464
- }, () => {
1465
- this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
1466
- });
1467
- return liveRun;
1468
- } catch (error) {
1469
- if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted) throw error;
1470
- this.ctx.logger.warn(`subagent-dsh: live driver unavailable, using the exec one-shot: ${error.message}`);
1471
- }
1472
- }
1473
- const member = exec === void 0 ? this.memberRun(intent.childSessionId, request.parent.session.id) : void 0;
1474
- try {
1475
- const run = await startDshCliRun(request, {
1476
- cwd,
1477
- homeDir,
1478
- childSession,
1479
- sessionId: intent.cliSessionId,
1480
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model),
1481
- resume: {
1482
- cliSessionId: intent.cliSessionId,
1483
- turn: nextTurn
1484
- },
1485
- config: this.config,
1486
- ctx: this.ctx,
1487
- ...member === void 0 ? {} : { memberEnv: member.env },
1488
- ...exec === void 0 ? {} : { exec },
1489
- cliVersion: () => dshCliVersion(this.ctx, this.config, homeDir),
1490
- ...scope === void 0 ? {} : { scope }
1491
- });
1492
- run.result.then(() => {
1493
- this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
1494
- member?.release();
1495
- }, () => {
1496
- this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
1497
- member?.release();
1498
- });
1499
- return run;
1500
- } catch (error) {
1501
- member?.release();
1502
- throw error;
1503
- }
1504
- } catch (error) {
1505
- this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
1506
- throw error;
1507
- }
1508
- }
1509
- };
1510
- /** Validate and join the one-shot task before crossing the process boundary. */
1511
- function dshTextTask(prompt) {
1512
- if (prompt.length === 0) throw new Error("subagent-dsh: the one-shot task must contain only text blocks");
1513
- const texts = [];
1514
- for (const block of prompt) {
1515
- if (block.type !== "text") throw new Error("subagent-dsh: the one-shot task must contain only text blocks");
1516
- texts.push(block.text);
1517
- }
1518
- if (texts.every((text) => text.trim().length === 0)) throw new Error("subagent-dsh: the one-shot task must not be empty");
1519
- return texts.join("\n");
1520
- }
1521
- function thrown(value) {
1522
- /* v8 ignore next -- typed subprocess failures reject with Error. */
1523
- return value instanceof Error ? value : new Error(String(value));
1524
- }
1525
- /**
1526
- * The dsh launch argv the provider replicates: the parent instance's own
1527
- * node + tsx import + entry point, so the sub-dsh runs the same dsh build as
1528
- * its parent. A configured `cliLaunch` replaces this entirely.
1529
- * @param config - plugin config carrying the optional override.
1530
- * @returns the launch prefix before the profile flag.
1531
- */
1532
- function dshLaunchArgv(config) {
1533
- if (config.cliLaunch !== void 0 && config.cliLaunch.length > 0) return config.cliLaunch;
1534
- return [
1535
- process.execPath,
1536
- ...process.execArgv,
1537
- process.argv[1] ?? "dsh"
1538
- ];
1539
- }
1540
- /**
1541
- * Mark the harness credential verified after a completed round, degrading
1542
- * silently on a core that predates the mark (the family's companion-pair
1543
- * rule: a provider paired with an older core loses the grade, never the run).
1544
- * @param ctx - host context carrying the family registry.
1545
- */
1546
- function markCredentialVerified(ctx, scope) {
1547
- const registry = ctx.get("localAgent");
1548
- if (registry === void 0 || typeof registry.reportAuthSuccess !== "function") return;
1549
- registry.reportAuthSuccess("dsh", scope);
1550
- }
1551
- /**
1552
- * The dsh build the sub-dsh runs, probed by asking the very launch argv the
1553
- * delegation would spawn for its `--version` (the sub-dsh replicates the
1554
- * parent instance's own node + entry point, so the parent's build IS the
1555
- * child's). Cached by the family probe against that entry script's identity,
1556
- * so a harness upgrade re-probes and an unchanged one never spawns twice.
1557
- * @param ctx - host context carrying the subprocess seam.
1558
- * @param config - plugin config carrying the optional launch override.
1559
- * @param homeDir - the harness's scoped home.
1560
- * @returns the version, or undefined when the launch cannot be asked.
1561
- */
1562
- function dshCliVersion(ctx, config, homeDir) {
1563
- const subprocess = ctx.get("subprocess");
1564
- if (subprocess === void 0) return Promise.resolve(void 0);
1565
- return probeCliVersion({
1566
- argv: [...dshLaunchArgv(config), "--version"],
1567
- cwd: homeDir,
1568
- spawn: (spec) => subprocess.spawn(spec),
1569
- env: delegationEnv({ DSH_HOME: homeDir })
1570
- });
1571
- }
1572
- /**
1573
- * Start the real sub-dsh headless child and publish its run. The final answer
1574
- * is printed to stdout; stderr is inherited for diagnostics. The run id is
1575
- * the child session id for a session-backed run, so the tool's
1576
- * `resume="<id>"` self-description names the same handle the registry
1577
- * records.
1578
- * @param request - resolved shared subagent request.
1579
- * @param spec - workspace, scoped home, child session, and resume facts.
1580
- * @returns the published run after the child starts.
1581
- */
1582
- async function startDshCliRun(request, spec) {
1583
- const { config, ctx } = spec;
1584
- const task = dshTextTask(request.prompt);
1585
- if (request.signal.aborted) throw new Error("subagent-dsh: request was aborted before the CLI started");
1586
- const profileName = spec.exec === void 0 ? config.profileName ?? "headless-local-agent-dsh" : unitSubProfileName(config);
1587
- if (spec.exec === void 0) provisionDshScope(spec.homeDir, {
1588
- ...config,
1589
- inheritModelConfiguration: true
1590
- });
1591
- const modelEnv = spec.exec === void 0 ? (await localModelLaunch(ctx, config, spec.model)).env : { DEEPSEEK_API_KEY: await resolveApiKey(ctx, config) };
1592
- const turn = spec.resume?.turn ?? 1;
1593
- spec.childSession?.append("turn/start", { turn });
1594
- const launch = dshLaunchArgv(config);
1595
- const modelArgv = [...spec.model === void 0 ? [] : ["--model", spec.model], ...spec.effort === void 0 ? [] : ["--effort", spec.effort]];
1596
- const argv = spec.resume === void 0 ? [
1597
- ...launch,
1598
- "--profile",
1599
- profileName,
1600
- "--session-id",
1601
- spec.sessionId,
1602
- ...modelArgv,
1603
- task
1604
- ] : [
1605
- ...launch,
1606
- "--profile",
1607
- profileName,
1608
- "--resume",
1609
- spec.sessionId,
1610
- ...modelArgv,
1611
- task
1612
- ];
1613
- const env = delegationEnv({
1614
- DSH_HOME: spec.homeDir,
1615
- ...modelEnv,
1616
- ...spec.exec !== void 0 || process.env.TSX_TSCONFIG_PATH === void 0 ? {} : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
1617
- ...spec.exec === void 0 || spec.exec.env?.["NODE_OPTIONS"] !== void 0 ? {} : { NODE_OPTIONS: CONTAINER_NODE_OPTIONS },
1618
- ...spec.memberEnv
1619
- });
1620
- const spawnSpec = {
1621
- ...spec.exec === void 0 ? {
1622
- argv,
1623
- env
1624
- } : containerExecSpawn(spec.exec, {
1625
- argv,
1626
- env
1627
- }, "subagent-dsh"),
1628
- cwd: spec.cwd,
1629
- stdio: {
1630
- stdin: "ignore",
1631
- stdout: "pipe",
1632
- stderr: "pipe"
1633
- },
1634
- graceMs: DEFAULT_DISPOSE_GRACE_MS
1635
- };
1636
- const child = ctx.subprocess.spawn(spawnSpec);
1637
- let output = "";
1638
- child.stdout?.on("data", (chunk) => {
1639
- output += chunk.toString();
1640
- });
1641
- let stderr = "";
1642
- child.stderr?.on("data", (chunk) => {
1643
- stderr += chunk.toString();
1644
- });
1645
- let mirrorQueue = Promise.resolve();
1646
- const enqueueMirror = (task) => {
1647
- mirrorQueue = mirrorQueue.then(task);
1648
- };
1649
- const mirrorAndReport = async (childSession, alwaysReport) => {
1650
- const delta = await mirrorDshSession(ctx, childSession, spec.homeDir, spec.sessionId);
1651
- const localAgent = ctx.get("localAgent");
1652
- if (localAgent === void 0) return;
1653
- if (delta.texts.length === 0 && !alwaysReport) return;
1654
- for (const text of delta.texts) localAgent.reportRunProgress(childSession.id, {
1655
- kind: "delta",
1656
- text
1657
- });
1658
- localAgent.reportRunProgress(childSession.id, {
1659
- kind: "mirror",
1660
- mirroredLines: delta.total
1661
- });
1662
- if (alwaysReport) {
1663
- const cliVersion = await spec.cliVersion?.();
1664
- localAgent.recordRoundSettled(childSession.id, {
1665
- ...delta.observedModel === void 0 ? {} : { observedModel: delta.observedModel },
1666
- ...cliVersion === void 0 ? {} : { cliVersion },
1667
- ...delta.usage === void 0 ? {} : { usage: delta.usage },
1668
- ...delta.toolCalls === void 0 ? {} : { toolCalls: delta.toolCalls }
1669
- });
1670
- }
1671
- };
1672
- if (spec.childSession !== void 0) {
1673
- const childSession = spec.childSession;
1674
- const liveMirror = setInterval(() => {
1675
- enqueueMirror(() => mirrorAndReport(childSession, false));
1676
- }, spec.liveMirrorIntervalMs ?? 2e3);
1677
- liveMirror.unref();
1678
- child.done.then(() => {
1679
- clearInterval(liveMirror);
1680
- }, () => {
1681
- clearInterval(liveMirror);
1682
- });
1683
- }
1684
- const disposeProcess = async () => {
1685
- child.terminate();
1686
- await child.waitForExit().catch(() => false);
1687
- await child.done.catch(() => {});
1688
- };
1689
- const runAbort = new AbortController();
1690
- const requestCancel = () => {
1691
- if (runAbort.signal.aborted) return;
1692
- runAbort.abort(/* @__PURE__ */ new Error("subagent-dsh: run cancelled locally"));
1693
- };
1694
- const onAbort = () => {
1695
- requestCancel();
1696
- };
1697
- request.signal.addEventListener("abort", onAbort, { once: true });
1698
- const abortBranch = new Promise((_, reject) => {
1699
- runAbort.signal.addEventListener("abort", () => reject(/* @__PURE__ */ new Error("subagent-dsh: run cancelled locally")), { once: true });
1700
- });
1701
- const processFailure = child.done.then((outcome) => Promise.reject(/* @__PURE__ */ new Error(`subagent-dsh: CLI exited before the run settled (code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
1702
- processFailure.catch(() => {});
1703
- const collectOutput = () => {
1704
- const text = output.trim();
1705
- return text === "" ? [] : [{
1706
- type: "text",
1707
- text
1708
- }];
1709
- };
1710
- const result = settleRunResult({
1711
- attempt: () => Promise.race([
1712
- child.done.then((outcome) => {
1713
- if (outcome.exitCode !== 0) throw new Error(`subagent-dsh: dsh exited with code ${String(outcome.exitCode)}`);
1714
- const answer = collectOutput();
1715
- if (answer.length === 0) throw new Error("subagent-dsh: dsh exited 0 but produced no answer");
1716
- return {
1717
- output: answer,
1718
- stopReason: "completed"
1719
- };
1720
- }),
1721
- processFailure,
1722
- abortBranch
1723
- ]),
1724
- collectOutput,
1725
- cancelled: () => runAbort.signal.aborted,
1726
- onError: (error, stopReason) => {
1727
- const suffix = stderr.trim() === "" ? "" : `; ${stderr.trim()}`;
1728
- ctx.logger.warn(`subagent-dsh: child run failed (${stopReason}): ${error.message}${suffix}`);
1729
- },
1730
- signal: request.signal,
1731
- onAbort
1732
- }).then((settled) => {
1733
- if (settled.stopReason === "completed") markCredentialVerified(ctx, spec.scope);
1734
- if (spec.childSession !== void 0) {
1735
- if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
1736
- turn,
1737
- reason: { kind: "completed" }
1738
- });
1739
- else if (settled.stopReason === "aborted") spec.childSession.append("turn/end", {
1740
- turn,
1741
- reason: {
1742
- kind: "aborted",
1743
- reason: { kind: "parent" }
1744
- }
1745
- });
1746
- else spec.childSession.append("turn/end", {
1747
- turn,
1748
- reason: {
1749
- kind: "error",
1750
- error: {
1751
- message: "dsh exited before the run completed",
1752
- code: "UNKNOWN"
1753
- }
1754
- }
1755
- });
1756
- }
1757
- return settled;
1758
- });
1759
- result.then(() => child.done).then(() => {
1760
- if (spec.childSession !== void 0) {
1761
- const childSession = spec.childSession;
1762
- return enqueueMirror(() => mirrorAndReport(childSession, true));
1763
- }
1764
- }, () => {});
1765
- return Promise.resolve(subprocessRunHandle({
1766
- id: SessionId(randomUUID()),
1767
- result,
1768
- signal: request.signal,
1769
- onAbort,
1770
- requestCancel,
1771
- teardown: disposeProcess
1772
- }));
1773
- }
1774
- /** Resolve the sub-dsh credential or fail loud before any process spawns. */
1775
- async function resolveApiKey(ctx, config) {
1776
- const ref = config.apiKeyRef ?? "DEEPSEEK_API_KEY";
1777
- const resolved = await ctx.credentials.resolve(credentialRef(ref));
1778
- if (resolved === void 0) throw new Error(`subagent-dsh: ${ref} is not configured; set it in the credential settings to delegate to dsh`);
1779
- return resolved.value;
1780
- }
1781
- //#endregion
1782
- export { MEMBER_BRIDGE_ENTRY_ENV as a, dshTextTask as c, startDshCliRun as d, DEFAULT_LIVE_IDLE_MS as f, localModelLaunch as h, DshCliProvider as i, registerMemberRun as l, canInheritModelProvider as m, DEFAULT_DISPOSE_GRACE_MS as n, dshCliVersion as o, DshLiveDriver as p, DEFAULT_LIVE_MIRROR_INTERVAL_MS as r, dshLaunchArgv as s, CONTAINER_NODE_OPTIONS as t, resolveApiKey as u };