@khorsheed/dsh-local-agent-dsh 0.1.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.en.md +150 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +151 -0
  4. package/cordis.patch.yml +24 -0
  5. package/lib/client.js +1115 -0
  6. package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
  7. package/lib/dsh-cli-provider.js +3 -0
  8. package/lib/index.js +624 -0
  9. package/lib/invariant.js +33 -0
  10. package/lib/provision.js +602 -0
  11. package/lib/records.js +109 -0
  12. package/lib/session-log-DOsSz56-.js +88 -0
  13. package/lib/types/client/SettingsCard.d.ts +78 -0
  14. package/lib/types/client/SettingsCard.js +200 -0
  15. package/lib/types/client/icons.d.ts +18 -0
  16. package/lib/types/client/icons.js +2 -0
  17. package/lib/types/client/index.d.ts +45 -0
  18. package/lib/types/client/index.js +88 -0
  19. package/lib/types/client/locales.d.ts +76 -0
  20. package/lib/types/client/locales.js +67 -0
  21. package/lib/types/dsh-cli-provider.d.ts +213 -0
  22. package/lib/types/dsh-cli-provider.js +733 -0
  23. package/lib/types/index.d.ts +124 -0
  24. package/lib/types/index.js +347 -0
  25. package/lib/types/invariant.d.ts +16 -0
  26. package/lib/types/invariant.js +37 -0
  27. package/lib/types/live-driver.d.ts +180 -0
  28. package/lib/types/live-driver.js +887 -0
  29. package/lib/types/live-switch.d.ts +76 -0
  30. package/lib/types/live-switch.js +100 -0
  31. package/lib/types/model-broker.d.ts +86 -0
  32. package/lib/types/model-broker.js +149 -0
  33. package/lib/types/model-catalog.d.ts +27 -0
  34. package/lib/types/model-catalog.js +71 -0
  35. package/lib/types/provision.d.ts +382 -0
  36. package/lib/types/provision.js +656 -0
  37. package/lib/types/records.d.ts +41 -0
  38. package/lib/types/records.js +125 -0
  39. package/lib/types/session-log.d.ts +58 -0
  40. package/lib/types/session-log.js +83 -0
  41. package/lib/types/session-mirror.d.ts +155 -0
  42. package/lib/types/session-mirror.js +451 -0
  43. package/locale/en.json +6 -0
  44. package/locale/zh.json +6 -0
  45. package/package.json +121 -0
@@ -0,0 +1,733 @@
1
+ /**
2
+ * One-shot and resumable dsh CLI subagent lifecycle: spawn the sub-dsh
3
+ * headless profile (`dsh --profile <name> --session-id <uuid> "<task>"` or
4
+ * `--resume <uuid>`) through the subprocess seam with the harness scoped home
5
+ * as the child's `$DSH_HOME` and the parent's resolved DeepSeek key injected
6
+ * explicitly (the shared env scrub drops credential-shaped names and every
7
+ * `DSH_*` fact; the explicit env layer is the sanctioned override). The
8
+ * caller-supplied session id is the same uuid on every round: the fresh round
9
+ * creates the sub-dsh session with it, and each resume round continues exactly
10
+ * that session. No stdout parsing — the sub-dsh session id is known a priori.
11
+ * @module @khorsheed/dsh-local-agent-dsh/dsh-cli-provider
12
+ */
13
+ import { randomUUID } from 'node:crypto';
14
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
15
+ import { SessionId } from '@deepseek-ai/dsh-session';
16
+ import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
17
+ import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
18
+ import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
19
+ import { LiveChannelUnavailableError } from './live-driver.js';
20
+ import { DEFAULT_SUB_PROFILE_NAME, provisionDshScope } from './provision.js';
21
+ import { mirrorDshSession } from './session-mirror.js';
22
+ /**
23
+ * Spawn-env name carrying the bridge entry path into the sub-dsh process,
24
+ * where the headless bundle's mcp-client row reads it (`!!js`). The name is a
25
+ * family-internal contract between this provider and
26
+ * `@khorsheed/dsh-local-agent-dsh-headless`'s cordis.patch.yml — the member
27
+ * bridge itself reads only the socket/token env.
28
+ */
29
+ export const MEMBER_BRIDGE_ENTRY_ENV = 'DSH_MEMBER_BRIDGE_ENTRY';
30
+ /**
31
+ * The `NODE_OPTIONS` a containerized round carries. dsh's HTTP client is
32
+ * node's `fetch` (undici), which does NOT read `HTTP(S)_PROXY` by default: in
33
+ * an evaluation unit whose only egress is a whitelist proxy the CLI dials the
34
+ * API directly and fails with a transport error while the proxy never even
35
+ * receives a `CONNECT`. `--use-env-proxy` opens undici's `EnvHttpProxyAgent`,
36
+ * which does read those variables. Measured on the T16 image: without it the
37
+ * round fails, with it the same round answers in 1.3 s. dsh is the only one
38
+ * of the four CLIs that needs an extra knob to run inside a unit — the other
39
+ * three carry their own proxy support — so the injection is automatic rather
40
+ * than something every caller must remember, and the harness reports it in
41
+ * its effective settings so the condition file can see it.
42
+ */
43
+ export const CONTAINER_NODE_OPTIONS = '--use-env-proxy';
44
+ /** Default POSIX grace between subprocess termination tiers. */
45
+ export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
46
+ /** Default interval between live session-mirror polls during a run. */
47
+ export const DEFAULT_LIVE_MIRROR_INTERVAL_MS = 2_000;
48
+ /** Register one member run with the channel; see {@link MemberRunHandle}. */
49
+ export function registerMemberRun(ctx, providerName, childSessionId, parentSessionId) {
50
+ const registry = ctx.localAgent;
51
+ if (typeof registry.registerMemberRun !== 'function'
52
+ || typeof registry.memberBridgeSocketPath !== 'function'
53
+ || typeof registry.memberBridgeCommand !== 'function')
54
+ return undefined;
55
+ const token = registry.registerMemberRun({ childSessionId, parentSessionId, provider: providerName });
56
+ const bridge = registry.memberBridgeCommand();
57
+ let released = false;
58
+ return {
59
+ env: {
60
+ [MEMBER_BRIDGE_SOCKET_ENV]: registry.memberBridgeSocketPath(),
61
+ [MEMBER_BRIDGE_TOKEN_ENV]: token,
62
+ [MEMBER_BRIDGE_ENTRY_ENV]: bridge.args[0] ?? '',
63
+ },
64
+ release: () => {
65
+ if (released)
66
+ return;
67
+ released = true;
68
+ registry.unregisterMemberRun(token);
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * One-shot dsh CLI subagent provider: every accepted fresh run starts a fresh
74
+ * sub-dsh headless process in the delegating Session's workspace, under the
75
+ * harness scoped home; a resume round (the family tool's staged resume intent)
76
+ * continues the SAME sub-dsh session with `--resume <uuid>` inside the SAME
77
+ * dsh child session. Mirrors the other family providers' one-shot lifecycle,
78
+ * including `NO_START_CAPABILITIES` — continuation is the family's own resume
79
+ * mechanism, not the official Agent-type continuable seam. With the live
80
+ * driver configured (`live: true`), rounds instead go to the resident sub-dsh
81
+ * serve process (see live-driver.ts); the exec path below stays the fallback.
82
+ */
83
+ export class DshCliProvider {
84
+ ctx;
85
+ config;
86
+ live;
87
+ model;
88
+ overrides;
89
+ name = 'dsh-cli';
90
+ capabilities = NO_START_CAPABILITIES;
91
+ inheritsParentContext = false;
92
+ /**
93
+ * @param live - the live driver, or a resolver returning the current
94
+ * generation's driver per member (the settings toggle swaps generations;
95
+ * a resolver may return undefined to steer one member's round to exec
96
+ * while a retiring generation still hosts it).
97
+ */
98
+ /**
99
+ * @param model - resolver for the harness's `model` plugin-config key, read
100
+ * PER ROUND so a settings-card write reaches the next delegation without a
101
+ * reload. A delegation that names its own model outranks it.
102
+ * @param overrides - resolver for the member's session-level model override
103
+ * (the composer picker, owned by the model broker). It outranks BOTH the
104
+ * delegation's recorded model and the configured one: on the exec path it
105
+ * re-points the next round's `--model`, and on the live path it is the
106
+ * member's start model — a resident runtime bound to a different model is
107
+ * retired so the round respawns onto the override.
108
+ */
109
+ constructor(ctx, config, live, model, overrides) {
110
+ this.ctx = ctx;
111
+ this.config = config;
112
+ this.live = live;
113
+ this.model = model;
114
+ this.overrides = overrides;
115
+ }
116
+ /** Resolve the live driver for one round's member, if live is on for it. */
117
+ liveDriver(childSessionId) {
118
+ const live = this.live;
119
+ if (live === undefined)
120
+ return undefined;
121
+ return typeof live === 'function' ? live(childSessionId) : live;
122
+ }
123
+ /** Per-round member-channel registration for the exec path (see {@link registerMemberRun}). */
124
+ memberRun(childSessionId, parentSessionId) {
125
+ return registerMemberRun(this.ctx, this.name, childSessionId, parentSessionId);
126
+ }
127
+ async start(request) {
128
+ // The family tool stages exactly one intent per delegation call; the
129
+ // provider consumes exactly one per start. A resume intent continues the
130
+ // recorded sub-dsh session inside the existing child session.
131
+ const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
132
+ // The scoped home this round runs against: the staged intent's scope
133
+ // names a sibling directory under the homes root (with its own sub-
134
+ // profile), absent means the default one — the directory every round
135
+ // used before scopes existed.
136
+ const scope = intent?.scope;
137
+ const homeDir = this.ctx.localAgent.homeDir('dsh', scope);
138
+ // The effective cwd: the caller's override (the staged intent's `cwd`,
139
+ // riding DelegationCallOptions.cwd) when present, else the parent
140
+ // session's workspace — the behavior before overrides existed.
141
+ const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
142
+ if (cwd === undefined) {
143
+ throw new Error('subagent-dsh: the parent session has no working directory to run the CLI in');
144
+ }
145
+ // Container exec target: the same round, spawned through `docker exec`
146
+ // inside a unit the caller acquired. Validated here so a target missing
147
+ // the in-container scoped home fails before any session record is made.
148
+ const exec = intent?.exec;
149
+ if (exec !== undefined)
150
+ containerScopedHome(exec, 'DSH_HOME', 'subagent-dsh');
151
+ if (intent !== undefined && intent.kind === 'resume') {
152
+ // A CLI session continues in the directory its first round ran in; a
153
+ // round resolving elsewhere is rejected before any process spawns.
154
+ const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
155
+ assertResumeCwdUnchanged(record, cwd, 'subagent-dsh');
156
+ // …and in the scoped home its first round ran in.
157
+ assertResumeScopeUnchanged(record, scope, 'subagent-dsh');
158
+ // A resume re-requests the model the FIRST round recorded — the caller
159
+ // cannot name one (the facade refuses it), and a record without one is
160
+ // a delegation that named none, which this round repeats.
161
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function')
162
+ return this.startDshResume(request, intent, cwd, homeDir, exec, scope, record?.model);
163
+ return this.ctx.localAgent.withMemberConfigurationRound({
164
+ childSessionId: intent.childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
165
+ ...scope === undefined ? {} : { scope },
166
+ ...record?.model === undefined ? {} : { model: record.model },
167
+ ...record?.effort === undefined ? {} : { effort: record.effort },
168
+ ...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
169
+ }, configuration => this.startDshResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
170
+ }
171
+ const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
172
+ if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
173
+ if (intent?.effort !== undefined)
174
+ throw new Error('DSH effort requires the configuration admission core');
175
+ return this.startDshFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
176
+ }
177
+ return this.ctx.localAgent.withMemberConfigurationRound({
178
+ childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
179
+ ...scope === undefined ? {} : { scope },
180
+ ...intent?.model === undefined ? {} : { model: intent.model },
181
+ ...intent?.effort === undefined ? {} : { effort: intent.effort },
182
+ ...intent?.configurationLock === undefined ? {} : { configurationLock: intent.configurationLock },
183
+ }, configuration => this.startDshFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
184
+ }
185
+ /** Fresh round: record the child session and delegation, spawn the sub-dsh create. */
186
+ async startDshFresh(request, cwd, homeDir, exec, scope,
187
+ /** The model this DELEGATION requested, when the caller named one. */
188
+ requestedModel, runId, configuration) {
189
+ let childSession;
190
+ try {
191
+ // Strict global read, never the caller-scope `ctx.sessions` proxy: the
192
+ // bundle does not inject `sessions`, and the proxy would throw on access.
193
+ const sessions = this.ctx.get('sessions');
194
+ if (sessions === undefined) {
195
+ throw new Error('the sessions service is not mounted');
196
+ }
197
+ childSession = sessions.get(runId) ?? sessions.create(runId, {
198
+ meta: {
199
+ cwd,
200
+ parentSession: request.parent.session.id,
201
+ origin: 'subagent',
202
+ delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1,
203
+ },
204
+ });
205
+ // The durable one-shot descriptor the runtime resolved marks this child
206
+ // as session-backed; without it the 子代理 projection cannot classify
207
+ // the session and the delegation stays invisible. The label carries the
208
+ // harness display name as the source marker so the dropdown shows which
209
+ // local agent produced the conversation.
210
+ const harness = this.ctx.localAgent.get('dsh');
211
+ const label = subagentDelegationLabel(harness?.displayName ?? 'dsh', request.descriptor.label);
212
+ childSession.append('subagent/descriptor', {
213
+ ...request.descriptor,
214
+ label,
215
+ });
216
+ // The official runtime appends the parent-side subagent/catalog
217
+ // discovery row only for in-process children (run.localAgent); this run
218
+ // is remote, so the provider appends it here — once per child, where
219
+ // the descriptor lands. A failure degrades to the warn below; a missing
220
+ // row never blocks the delegation.
221
+ establishSubagentCatalogChild(request.parent.session, childSession.header, label);
222
+ // No persistence create here: the stored session is materialized by the
223
+ // first persistChildSession sync through the CORE's cached write handle.
224
+ // A fire-and-forget create from the provider used to hold (and leak) a
225
+ // second write handle, which blocks the core's claim with
226
+ // SessionAlreadyOwnedError.
227
+ }
228
+ catch (error) {
229
+ this.ctx.logger.warn(`subagent-dsh: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
230
+ }
231
+ // The sub-dsh session id IS the child session id: the parent provider
232
+ // generates one uuid and the sub-dsh creates its session with exactly it,
233
+ // so the delegation record is known before the process spawns and a later
234
+ // resume round continues the same sub-dsh session by the same handle.
235
+ this.ctx.localAgent.recordDelegation({
236
+ childSessionId: runId,
237
+ provider: this.name,
238
+ parentSessionId: request.parent.session.id,
239
+ cliSessionId: runId,
240
+ // The round's resolved working directory anchors the
241
+ // resume-consistency check.
242
+ cwd,
243
+ // …and its scoped home anchors the resume-scope check.
244
+ ...scope === undefined ? {} : { scope },
245
+ // The model this delegation asked for: every resume round reads it back
246
+ // from here instead of the caller restating it.
247
+ ...requestedModel === undefined ? {} : { model: requestedModel },
248
+ });
249
+ // Live driver: the round goes to the resident serve process (one per
250
+ // member). A channel that fails at spawn/handshake marks itself broken and
251
+ // falls through to the exec one-shot below — and stays there
252
+ // (driver.disabled) for later rounds. The resolver may gate this member to
253
+ // exec while a retiring generation still hosts its runtime.
254
+ // A container target always takes the exec one-shot: the live driver runs
255
+ // a resident serve process on the HOST, which is the transport the target
256
+ // exists to replace.
257
+ const live = exec === undefined ? this.liveDriver(runId) : undefined;
258
+ if (live !== undefined && childSession !== undefined && !live.disabled) {
259
+ // The member-bound live driver receives this exact scoped home.
260
+ // A round that names its own model is NOT refused: it becomes the
261
+ // member's start model, bound at the serve spawn (`--model`; a runtime
262
+ // bound to a different model is retired first, so the fresh session
263
+ // spawns onto the asked-for model). The session-level override outranks
264
+ // even this.
265
+ const startModel = configuration === undefined ? this.overrides?.(runId) ?? requestedModel : configuration.resolved.model;
266
+ try {
267
+ return await live.startRound(request, {
268
+ cwd,
269
+ homeDir,
270
+ childSession,
271
+ sessionId: runId,
272
+ parentSessionId: request.parent.session.id,
273
+ ...startModel === undefined ? {} : { startModel },
274
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
275
+ });
276
+ }
277
+ catch (error) {
278
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
279
+ throw error;
280
+ this.ctx.logger.warn(`subagent-dsh: live driver unavailable, using the exec one-shot: ${error.message}`);
281
+ }
282
+ }
283
+ // Member channel: register this run and hand the bridge coordinates to
284
+ // the sub-dsh through the spawn env, so its session starts with
285
+ // member_message available.
286
+ // The member bridge is a host unix socket the container cannot reach, and
287
+ // the bridge entry it names is a host node path — a containerized round
288
+ // therefore runs WITHOUT the member channel rather than with a broken one.
289
+ const member = exec === undefined ? this.memberRun(runId, request.parent.session.id) : undefined;
290
+ try {
291
+ const run = await startDshCliRun(request, {
292
+ cwd,
293
+ homeDir,
294
+ childSession,
295
+ sessionId: runId,
296
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model)),
297
+ resume: undefined,
298
+ config: this.config,
299
+ ctx: this.ctx,
300
+ ...member === undefined ? {} : { memberEnv: member.env },
301
+ ...exec === undefined ? {} : { exec },
302
+ cliVersion: () => dshCliVersion(this.ctx, this.config, homeDir),
303
+ ...scope === undefined ? {} : { scope },
304
+ });
305
+ // The member-channel token dies with the run, whatever its stop reason.
306
+ if (member !== undefined)
307
+ void run.result.then(member.release, member.release);
308
+ return run;
309
+ }
310
+ catch (error) {
311
+ member?.release();
312
+ throw error;
313
+ }
314
+ }
315
+ /** Resume round: continue the recorded sub-dsh session inside the existing child session. */
316
+ async startDshResume(request, intent, cwd, homeDir, exec, scope,
317
+ /** The model the delegation's FIRST round recorded, re-requested here. */
318
+ requestedModel, configuration) {
319
+ // One in-flight resume per child session: a second resume of the same
320
+ // child fails loud instead of racing the first process. The lock releases
321
+ // on every settle path (the run.result handler below) and on the error
322
+ // path, so a deadlock never strands a later resume.
323
+ if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) {
324
+ throw new Error(`subagent-dsh: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
325
+ }
326
+ try {
327
+ const sessions = this.ctx.get('sessions');
328
+ const childSession = sessions?.get(SessionId(intent.childSessionId));
329
+ if (childSession === undefined) {
330
+ throw new Error(`subagent-dsh: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
331
+ }
332
+ // The next turn follows the rounds already recorded in the child session.
333
+ const nextTurn = childSession.snapshotEvents().filter(event => event.type === 'turn/start').length + 1;
334
+ // Live driver: continue the member's resident serve process. Channel
335
+ // spawn/handshake failure falls through to the exec one-shot below.
336
+ // See the fresh path: a container target is exec-only.
337
+ const live = exec === undefined ? this.liveDriver(intent.childSessionId) : undefined;
338
+ if (live !== undefined && !live.disabled) {
339
+ // Resume retains the recorded scope and its native session.
340
+ // The resume re-requests its recorded model as the member's start
341
+ // model (the session-level override outranks it): a runtime bound to
342
+ // a different model is retired so the session respawns onto this one.
343
+ const startModel = configuration === undefined ? this.overrides?.(intent.childSessionId) ?? requestedModel : configuration.resolved.model;
344
+ try {
345
+ const liveRun = await live.startRound(request, {
346
+ cwd,
347
+ homeDir,
348
+ childSession,
349
+ sessionId: intent.cliSessionId,
350
+ parentSessionId: request.parent.session.id,
351
+ resume: { turn: nextTurn },
352
+ ...startModel === undefined ? {} : { startModel },
353
+ ...configuration === undefined ? {} : { configuration: configuration.resolved },
354
+ });
355
+ void liveRun.result.then(() => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); }, () => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); });
356
+ return liveRun;
357
+ }
358
+ catch (error) {
359
+ if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
360
+ throw error;
361
+ this.ctx.logger.warn(`subagent-dsh: live driver unavailable, using the exec one-shot: ${error.message}`);
362
+ }
363
+ }
364
+ // Member channel: register the resume round (same child session, fresh
365
+ // per-run token) before the spawn.
366
+ // See the fresh path: no member channel across the container boundary.
367
+ const member = exec === undefined ? this.memberRun(intent.childSessionId, request.parent.session.id) : undefined;
368
+ try {
369
+ const run = await startDshCliRun(request, {
370
+ cwd,
371
+ homeDir,
372
+ childSession,
373
+ sessionId: intent.cliSessionId,
374
+ ...(configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model)),
375
+ resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
376
+ config: this.config,
377
+ ctx: this.ctx,
378
+ ...member === undefined ? {} : { memberEnv: member.env },
379
+ ...exec === undefined ? {} : { exec },
380
+ cliVersion: () => dshCliVersion(this.ctx, this.config, homeDir),
381
+ ...scope === undefined ? {} : { scope },
382
+ });
383
+ void run.result.then(() => {
384
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
385
+ member?.release();
386
+ }, () => {
387
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
388
+ member?.release();
389
+ });
390
+ return run;
391
+ }
392
+ catch (error) {
393
+ member?.release();
394
+ throw error;
395
+ }
396
+ }
397
+ catch (error) {
398
+ this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
399
+ throw error;
400
+ }
401
+ }
402
+ }
403
+ /** Validate and join the one-shot task before crossing the process boundary. */
404
+ export function dshTextTask(prompt) {
405
+ if (prompt.length === 0) {
406
+ throw new Error('subagent-dsh: the one-shot task must contain only text blocks');
407
+ }
408
+ const texts = [];
409
+ for (const block of prompt) {
410
+ if (block.type !== 'text') {
411
+ throw new Error('subagent-dsh: the one-shot task must contain only text blocks');
412
+ }
413
+ texts.push(block.text);
414
+ }
415
+ if (texts.every(text => text.trim().length === 0)) {
416
+ throw new Error('subagent-dsh: the one-shot task must not be empty');
417
+ }
418
+ return texts.join('\n');
419
+ }
420
+ function thrown(value) {
421
+ /* v8 ignore next -- typed subprocess failures reject with Error. */
422
+ return value instanceof Error ? value : new Error(String(value));
423
+ }
424
+ /**
425
+ * The dsh launch argv the provider replicates: the parent instance's own
426
+ * node + tsx import + entry point, so the sub-dsh runs the same dsh build as
427
+ * its parent. A configured `cliLaunch` replaces this entirely.
428
+ * @param config - plugin config carrying the optional override.
429
+ * @returns the launch prefix before the profile flag.
430
+ */
431
+ export function dshLaunchArgv(config) {
432
+ // An empty array is a degenerate override (an absent schema field resolves
433
+ // to [] without an explicit default); treat it as no override.
434
+ if (config.cliLaunch !== undefined && config.cliLaunch.length > 0)
435
+ return config.cliLaunch;
436
+ return [process.execPath, ...process.execArgv, process.argv[1] ?? 'dsh'];
437
+ }
438
+ /**
439
+ * Mark the harness credential verified after a completed round, degrading
440
+ * silently on a core that predates the mark (the family's companion-pair
441
+ * rule: a provider paired with an older core loses the grade, never the run).
442
+ * @param ctx - host context carrying the family registry.
443
+ */
444
+ function markCredentialVerified(ctx, scope) {
445
+ const registry = ctx.get('localAgent');
446
+ if (registry === undefined || typeof registry.reportAuthSuccess !== 'function')
447
+ return;
448
+ // The grade belongs to the scope the round ran against.
449
+ registry.reportAuthSuccess('dsh', scope);
450
+ }
451
+ /**
452
+ * The dsh build the sub-dsh runs, probed by asking the very launch argv the
453
+ * delegation would spawn for its `--version` (the sub-dsh replicates the
454
+ * parent instance's own node + entry point, so the parent's build IS the
455
+ * child's). Cached by the family probe against that entry script's identity,
456
+ * so a harness upgrade re-probes and an unchanged one never spawns twice.
457
+ * @param ctx - host context carrying the subprocess seam.
458
+ * @param config - plugin config carrying the optional launch override.
459
+ * @param homeDir - the harness's scoped home.
460
+ * @returns the version, or undefined when the launch cannot be asked.
461
+ */
462
+ export function dshCliVersion(ctx, config, homeDir) {
463
+ // Degrade, don't explode: a composition without the subprocess seam simply
464
+ // reports no version, exactly as an unaskable CLI does.
465
+ const subprocess = ctx.get('subprocess');
466
+ if (subprocess === undefined)
467
+ return Promise.resolve(undefined);
468
+ return probeCliVersion({
469
+ argv: [...dshLaunchArgv(config), '--version'],
470
+ cwd: homeDir,
471
+ spawn: spec => subprocess.spawn(spec),
472
+ env: delegationEnv({ DSH_HOME: homeDir }),
473
+ });
474
+ }
475
+ /**
476
+ * Start the real sub-dsh headless child and publish its run. The final answer
477
+ * is printed to stdout; stderr is inherited for diagnostics. The run id is
478
+ * the child session id for a session-backed run, so the tool's
479
+ * `resume="<id>"` self-description names the same handle the registry
480
+ * records.
481
+ * @param request - resolved shared subagent request.
482
+ * @param spec - workspace, scoped home, child session, and resume facts.
483
+ * @returns the published run after the child starts.
484
+ */
485
+ export async function startDshCliRun(request, spec) {
486
+ const { config, ctx } = spec;
487
+ const task = dshTextTask(request.prompt);
488
+ if (request.signal.aborted) {
489
+ throw new Error('subagent-dsh: request was aborted before the CLI started');
490
+ }
491
+ const profileName = config.profileName ?? DEFAULT_SUB_PROFILE_NAME;
492
+ // Provisioning is idempotent and cheap; re-running heals a deleted or
493
+ // drifted sub-profile before every round. A CONTAINER round skips it: the
494
+ // sub-profile's node_modules symlink points at the host installation of the
495
+ // headless bundle, which resolves to nothing inside a unit — and the scoped
496
+ // home is bind-mounted, so writing it would plant a broken profile in the
497
+ // directory the unit actually reads. A containerized caller names the
498
+ // unit's own profile through `profileName` (the image's in-box `headless`)
499
+ // and its own entry through `cliLaunch`.
500
+ if (spec.exec === undefined)
501
+ provisionDshScope(spec.homeDir, config);
502
+ // Resolve the sub-dsh credential BEFORE spawning: the key travels in the
503
+ // explicit env layer, which is the only way past the shared env scrub.
504
+ const apiKey = await resolveApiKey(ctx, config);
505
+ const turn = spec.resume?.turn ?? 1;
506
+ // The turn opens at the real spawn moment so the timing projection
507
+ // measures actual CLI runtime.
508
+ spec.childSession?.append('turn/start', { turn });
509
+ // The default launch replicates THIS process (node + argv[1]), which only
510
+ // exists on the host. A container target therefore expects the caller to
511
+ // have pinned `cliLaunch` to the unit's own entry (`['dsh']` on the T16
512
+ // image) — the existing config knob, not a new field on the exec target.
513
+ const launch = dshLaunchArgv(config);
514
+ // The model rides `--model`, after the session flag on both variants: it is
515
+ // orthogonal to which session runs, and the sub-dsh's parser takes it in
516
+ // either mode. Nothing configured appends nothing — the argv is then
517
+ // byte-for-byte the shape that shipped before the flag existed.
518
+ const modelArgv = [...spec.model === undefined ? [] : ['--model', spec.model], ...spec.effort === undefined ? [] : ['--effort', spec.effort]];
519
+ const argv = spec.resume === undefined
520
+ ? [...launch, '--profile', profileName, '--session-id', spec.sessionId, ...modelArgv, task]
521
+ : [...launch, '--profile', profileName, '--resume', spec.sessionId, ...modelArgv, task];
522
+ // The explicit env layer merges AFTER the shared credential scrub, so
523
+ // both the credential-shaped key and the DSH_* fact survive into the
524
+ // child — without DSH_HOME the sub-dsh would default to ~/.dsh and write
525
+ // sessions into the parent instance's store.
526
+ const env = delegationEnv({
527
+ DSH_HOME: spec.homeDir,
528
+ DEEPSEEK_API_KEY: apiKey,
529
+ // The launch replicates the parent's tsx ESM hook (dshLaunchArgv), so
530
+ // the hook's tsconfig path is a hard launch dependency: without it tsx
531
+ // compiles the harness source with default options and the child dies
532
+ // at import time ('FiberState' export mismatch). Pass it explicitly —
533
+ // the delegation env allowlist tombstones it otherwise. A container
534
+ // round runs the unit's own dsh, so the host hook path is not only
535
+ // useless there, it would put a host directory layout inside the unit.
536
+ ...spec.exec !== undefined || process.env.TSX_TSCONFIG_PATH === undefined
537
+ ? {}
538
+ : { TSX_TSCONFIG_PATH: process.env.TSX_TSCONFIG_PATH },
539
+ // Container round: open undici's env proxy agent, without which the
540
+ // sub-dsh dials the API directly and never reaches the unit's whitelist
541
+ // proxy (see CONTAINER_NODE_OPTIONS). A caller that named NODE_OPTIONS
542
+ // on the target keeps its own value — this is a default, not an override.
543
+ ...spec.exec === undefined || spec.exec.env?.['NODE_OPTIONS'] !== undefined
544
+ ? {}
545
+ : { NODE_OPTIONS: CONTAINER_NODE_OPTIONS },
546
+ // Member channel coordinates ride the same explicit layer (DSH_* names
547
+ // are scrubbed from the ambient env; this layer is the sanctioned
548
+ // override).
549
+ ...spec.memberEnv,
550
+ });
551
+ // Container target: the same argv, wrapped in `docker exec`. The host cwd
552
+ // still applies — it is the docker CLIENT's working directory now, while
553
+ // the sub-dsh's own is the target's in-container workdir.
554
+ const spawnSpec = {
555
+ ...spec.exec === undefined ? { argv, env } : containerExecSpawn(spec.exec, { argv, env }, 'subagent-dsh'),
556
+ cwd: spec.cwd,
557
+ stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
558
+ graceMs: DEFAULT_DISPOSE_GRACE_MS,
559
+ };
560
+ const child = ctx.subprocess.spawn(spawnSpec);
561
+ let output = '';
562
+ child.stdout?.on('data', (chunk) => { output += chunk.toString(); });
563
+ let stderr = '';
564
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
565
+ // Live session mirroring: poll the sub-dsh session log while the run is in
566
+ // flight and mirror new events through the SAME mirrorDshSession path the
567
+ // settle mirror uses (its already-mirrored prefix skip makes the settle
568
+ // pass a no-op when polling kept up). A poll failure never kills the run:
569
+ // mirrorDshSession degrades to a warn internally and the settle mirror
570
+ // remains the fallback. All mirror passes serialize through mirrorQueue.
571
+ let mirrorQueue = Promise.resolve();
572
+ const enqueueMirror = (task) => {
573
+ mirrorQueue = mirrorQueue.then(task);
574
+ };
575
+ const mirrorAndReport = async (childSession, alwaysReport) => {
576
+ const delta = await mirrorDshSession(ctx, childSession, spec.homeDir, spec.sessionId);
577
+ const localAgent = ctx.get('localAgent');
578
+ if (localAgent === undefined)
579
+ return;
580
+ if (delta.texts.length === 0 && !alwaysReport)
581
+ return;
582
+ for (const text of delta.texts) {
583
+ localAgent.reportRunProgress(childSession.id, { kind: 'delta', text });
584
+ }
585
+ localAgent.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
586
+ if (alwaysReport) {
587
+ // The settle pass also reports the round's settled observation — the
588
+ // sub-dsh session's own model attribution, the dsh build the round ran,
589
+ // and the round's token usage — through the registry's observation
590
+ // channel: the delegation record's merge plus the `settled` event. The
591
+ // sub-dsh replicates the parent's own launch, so its build is what that
592
+ // launch answers to `--version`.
593
+ const cliVersion = await spec.cliVersion?.();
594
+ localAgent.recordRoundSettled(childSession.id, {
595
+ ...delta.observedModel === undefined ? {} : { observedModel: delta.observedModel },
596
+ ...cliVersion === undefined ? {} : { cliVersion },
597
+ ...delta.usage === undefined ? {} : { usage: delta.usage },
598
+ ...delta.toolCalls === undefined ? {} : { toolCalls: delta.toolCalls },
599
+ });
600
+ }
601
+ };
602
+ if (spec.childSession !== undefined) {
603
+ const childSession = spec.childSession;
604
+ const liveMirror = setInterval(() => {
605
+ enqueueMirror(() => mirrorAndReport(childSession, false));
606
+ }, spec.liveMirrorIntervalMs ?? DEFAULT_LIVE_MIRROR_INTERVAL_MS);
607
+ liveMirror.unref();
608
+ // Polling stops when the process exits; the settle mirror (enqueued below)
609
+ // runs the final pass afterwards through the same queue.
610
+ void child.done.then(() => { clearInterval(liveMirror); }, () => { clearInterval(liveMirror); });
611
+ }
612
+ const disposeProcess = async () => {
613
+ // Host 0.1.5 hides the child pid (the managed range owns termination):
614
+ // terminate() is an idempotent no-op once the range is gone, so a failed
615
+ // spawn needs no guard; done rejects there, which dispose must swallow.
616
+ child.terminate();
617
+ await child.waitForExit().catch(() => false);
618
+ await child.done.catch(() => { });
619
+ };
620
+ const runAbort = new AbortController();
621
+ const requestCancel = () => {
622
+ if (runAbort.signal.aborted)
623
+ return;
624
+ runAbort.abort(new Error('subagent-dsh: run cancelled locally'));
625
+ };
626
+ const onAbort = () => { requestCancel(); };
627
+ request.signal.addEventListener('abort', onAbort, { once: true });
628
+ // Abort branch: the moment the run is cancelled locally, the attempt settles
629
+ // immediately — settleRunResult observes `cancelled()` and resolves the
630
+ // result with 'aborted' WITHOUT waiting for the child to exit. The actual
631
+ // process kill is dispose's job (SIGTERM → grace → SIGKILL teardown ladder),
632
+ // per the subprocessRunHandle contract: requestCancel settles the result,
633
+ // teardown reaps the process.
634
+ const abortBranch = new Promise((_, reject) => {
635
+ runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-dsh: run cancelled locally')), { once: true });
636
+ });
637
+ const processFailure = child.done.then(outcome => Promise.reject(new Error('subagent-dsh: CLI exited before the run settled '
638
+ + `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
639
+ // A normal post-result dispose also closes the process; keep the expected
640
+ // late rejection observed after the result race has already settled.
641
+ processFailure.catch(() => { });
642
+ const collectOutput = () => {
643
+ const text = output.trim();
644
+ return text === '' ? [] : [{ type: 'text', text }];
645
+ };
646
+ const result = settleRunResult({
647
+ attempt: () => Promise.race([
648
+ child.done.then((outcome) => {
649
+ if (outcome.exitCode !== 0) {
650
+ throw new Error(`subagent-dsh: dsh exited with code ${String(outcome.exitCode)}`);
651
+ }
652
+ // A zero exit with no printed answer is a silent failure, not a
653
+ // success: the sub-dsh produced nothing, so the delegation did not
654
+ // deliver a result.
655
+ const answer = collectOutput();
656
+ if (answer.length === 0) {
657
+ throw new Error('subagent-dsh: dsh exited 0 but produced no answer');
658
+ }
659
+ return { output: answer, stopReason: 'completed' };
660
+ }),
661
+ processFailure,
662
+ abortBranch,
663
+ ]),
664
+ collectOutput,
665
+ cancelled: () => runAbort.signal.aborted,
666
+ onError: (error, stopReason) => {
667
+ const suffix = stderr.trim() === '' ? '' : `; ${stderr.trim()}`;
668
+ ctx.logger.warn(`subagent-dsh: child run failed (${stopReason}): ${error.message}${suffix}`);
669
+ },
670
+ signal: request.signal,
671
+ onAbort,
672
+ }).then((settled) => {
673
+ // A completed round proves the resolved credential is live — the sub-dsh
674
+ // reached its endpoint and produced an answer.
675
+ if (settled.stopReason === 'completed')
676
+ markCredentialVerified(ctx, spec.scope);
677
+ // The turn closes at the real settle moment, so the timing projection's
678
+ // duration equals the actual CLI runtime. Every terminal path closes the
679
+ // window — a failed or cancelled run settles 'error'/'aborted' instead of
680
+ // leaving the turn open with a distorted tiny duration.
681
+ if (spec.childSession !== undefined) {
682
+ if (settled.stopReason === 'completed') {
683
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
684
+ }
685
+ else if (settled.stopReason === 'aborted') {
686
+ spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
687
+ }
688
+ else {
689
+ spec.childSession.append('turn/end', {
690
+ turn,
691
+ reason: { kind: 'error', error: { message: 'dsh exited before the run completed', code: 'UNKNOWN' } },
692
+ });
693
+ }
694
+ }
695
+ return settled;
696
+ });
697
+ // After the child EXITS — however it ended (completed, killed by dispose, or
698
+ // crashed) — mirror whatever the sub-dsh session already produced into the
699
+ // dsh subagent session, so every round preserves its conversation and real
700
+ // token usage instead of showing turn boundaries only. Waits for the settle
701
+ // chain first (so turn/end is already appended) AND for the process to
702
+ // actually exit (the sub-dsh flushed its session before exiting) before
703
+ // reading.
704
+ void result.then(() => child.done).then(() => {
705
+ if (spec.childSession !== undefined) {
706
+ const childSession = spec.childSession;
707
+ // The settle pass reports the final mirror count even when polling
708
+ // already covered the round (empty delta): it is authoritative.
709
+ return enqueueMirror(() => mirrorAndReport(childSession, true));
710
+ }
711
+ return undefined;
712
+ }, () => { });
713
+ return Promise.resolve(subprocessRunHandle({
714
+ // A session-backed run's id is the child session id (the seam's local-run
715
+ // contract), so the tool's resume self-description names the same handle
716
+ // the delegation registry records.
717
+ id: spec.childSession?.id ?? SessionId(randomUUID()),
718
+ result,
719
+ signal: request.signal,
720
+ onAbort,
721
+ requestCancel,
722
+ teardown: disposeProcess,
723
+ }));
724
+ }
725
+ /** Resolve the sub-dsh credential or fail loud before any process spawns. */
726
+ export async function resolveApiKey(ctx, config) {
727
+ const ref = config.apiKeyRef ?? 'DEEPSEEK_API_KEY';
728
+ const resolved = await ctx.credentials.resolve(credentialRef(ref));
729
+ if (resolved === undefined) {
730
+ throw new Error(`subagent-dsh: ${ref} is not configured; set it in the credential settings to delegate to dsh`);
731
+ }
732
+ return resolved.value;
733
+ }