@khorsheed/dsh-local-agent-dsh 0.2.1 → 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.
- package/CHANGELOG.md +4 -0
- package/lib/client.js +44 -44
- package/package.json +3 -3
- package/lib/dsh-cli-provider-B7s3zH0m.js +0 -1782
|
@@ -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 };
|