@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.
- package/README.en.md +150 -0
- package/README.i18n.yaml +6 -0
- package/README.md +151 -0
- package/cordis.patch.yml +24 -0
- package/lib/client.js +1115 -0
- package/lib/dsh-cli-provider-UhWvivte.js +1701 -0
- package/lib/dsh-cli-provider.js +3 -0
- package/lib/index.js +624 -0
- package/lib/invariant.js +33 -0
- package/lib/provision.js +602 -0
- package/lib/records.js +109 -0
- package/lib/session-log-DOsSz56-.js +88 -0
- package/lib/types/client/SettingsCard.d.ts +78 -0
- package/lib/types/client/SettingsCard.js +200 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +45 -0
- package/lib/types/client/index.js +88 -0
- package/lib/types/client/locales.d.ts +76 -0
- package/lib/types/client/locales.js +67 -0
- package/lib/types/dsh-cli-provider.d.ts +213 -0
- package/lib/types/dsh-cli-provider.js +733 -0
- package/lib/types/index.d.ts +124 -0
- package/lib/types/index.js +347 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +37 -0
- package/lib/types/live-driver.d.ts +180 -0
- package/lib/types/live-driver.js +887 -0
- package/lib/types/live-switch.d.ts +76 -0
- package/lib/types/live-switch.js +100 -0
- package/lib/types/model-broker.d.ts +86 -0
- package/lib/types/model-broker.js +149 -0
- package/lib/types/model-catalog.d.ts +27 -0
- package/lib/types/model-catalog.js +71 -0
- package/lib/types/provision.d.ts +382 -0
- package/lib/types/provision.js +656 -0
- package/lib/types/records.d.ts +41 -0
- package/lib/types/records.js +125 -0
- package/lib/types/session-log.d.ts +58 -0
- package/lib/types/session-log.js +83 -0
- package/lib/types/session-mirror.d.ts +155 -0
- package/lib/types/session-mirror.js +451 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
+
}
|