@khorsheed/dsh-local-agent 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 (99) hide show
  1. package/README.en.md +181 -0
  2. package/README.i18n.yaml +2 -0
  3. package/README.md +181 -0
  4. package/cordis.patch.yml +21 -0
  5. package/lib/client.js +8832 -0
  6. package/lib/index.js +5281 -0
  7. package/lib/invariant.js +85 -0
  8. package/lib/member-bridge.js +298 -0
  9. package/lib/typert.host.d.ts +3 -0
  10. package/lib/typert.host.js +3137 -0
  11. package/lib/typert.remote-client.d.ts +67 -0
  12. package/lib/typert.remote-client.js +1971 -0
  13. package/lib/types/cli-version.d.ts +82 -0
  14. package/lib/types/cli-version.js +165 -0
  15. package/lib/types/client/AuthStatusDot.d.ts +17 -0
  16. package/lib/types/client/AuthStatusDot.js +9 -0
  17. package/lib/types/client/HarnessModelPicker.d.ts +17 -0
  18. package/lib/types/client/HarnessModelPicker.js +48 -0
  19. package/lib/types/client/LocalAgentRecordsAction.d.ts +41 -0
  20. package/lib/types/client/LocalAgentRecordsAction.js +74 -0
  21. package/lib/types/client/MemberComposer.d.ts +79 -0
  22. package/lib/types/client/MemberComposer.js +308 -0
  23. package/lib/types/client/MemberConfiguration.d.ts +9 -0
  24. package/lib/types/client/MemberConfiguration.js +46 -0
  25. package/lib/types/client/MemberInboxView.d.ts +12 -0
  26. package/lib/types/client/MemberInboxView.js +38 -0
  27. package/lib/types/client/MemberLiveNode.d.ts +14 -0
  28. package/lib/types/client/MemberLiveNode.js +74 -0
  29. package/lib/types/client/MemberPaintDiagnostics.d.ts +7 -0
  30. package/lib/types/client/MemberPaintDiagnostics.js +20 -0
  31. package/lib/types/client/ModelConfigurationFields.d.ts +15 -0
  32. package/lib/types/client/ModelConfigurationFields.js +23 -0
  33. package/lib/types/client/ProviderAuthBlock.d.ts +59 -0
  34. package/lib/types/client/ProviderAuthBlock.js +164 -0
  35. package/lib/types/client/auth-status.d.ts +28 -0
  36. package/lib/types/client/auth-status.js +56 -0
  37. package/lib/types/client/icons.d.ts +22 -0
  38. package/lib/types/client/icons.js +6 -0
  39. package/lib/types/client/index.d.ts +62 -0
  40. package/lib/types/client/index.js +135 -0
  41. package/lib/types/client/live-node.d.ts +14 -0
  42. package/lib/types/client/live-node.js +34 -0
  43. package/lib/types/client/live-output.d.ts +34 -0
  44. package/lib/types/client/live-output.js +94 -0
  45. package/lib/types/client/live-paint.d.ts +40 -0
  46. package/lib/types/client/live-paint.js +74 -0
  47. package/lib/types/client/locales.d.ts +120 -0
  48. package/lib/types/client/locales.js +227 -0
  49. package/lib/types/client/member-configuration.d.ts +41 -0
  50. package/lib/types/client/member-configuration.js +114 -0
  51. package/lib/types/client/member-dock.d.ts +94 -0
  52. package/lib/types/client/member-dock.js +102 -0
  53. package/lib/types/client/member-feed.d.ts +24 -0
  54. package/lib/types/client/member-feed.js +134 -0
  55. package/lib/types/client/request-id.d.ts +3 -0
  56. package/lib/types/client/request-id.js +6 -0
  57. package/lib/types/client/settings-scope.d.ts +74 -0
  58. package/lib/types/client/settings-scope.js +98 -0
  59. package/lib/types/client/use-configuration-menu.d.ts +10 -0
  60. package/lib/types/client/use-configuration-menu.js +61 -0
  61. package/lib/types/container.d.ts +69 -0
  62. package/lib/types/container.js +129 -0
  63. package/lib/types/env.d.ts +17 -0
  64. package/lib/types/env.js +89 -0
  65. package/lib/types/gateway.d.ts +162 -0
  66. package/lib/types/gateway.js +470 -0
  67. package/lib/types/index.d.ts +1463 -0
  68. package/lib/types/index.js +2704 -0
  69. package/lib/types/invariant.d.ts +16 -0
  70. package/lib/types/invariant.js +105 -0
  71. package/lib/types/live-flush.d.ts +21 -0
  72. package/lib/types/live-flush.js +59 -0
  73. package/lib/types/live-stream.d.ts +41 -0
  74. package/lib/types/live-stream.js +178 -0
  75. package/lib/types/member-bridge.d.ts +50 -0
  76. package/lib/types/member-bridge.js +216 -0
  77. package/lib/types/member-channel.d.ts +86 -0
  78. package/lib/types/member-channel.js +232 -0
  79. package/lib/types/member-control-storage.d.ts +10 -0
  80. package/lib/types/member-control-storage.js +100 -0
  81. package/lib/types/member-control.d.ts +62 -0
  82. package/lib/types/member-control.js +359 -0
  83. package/lib/types/member-controls.d.ts +23 -0
  84. package/lib/types/member-controls.js +143 -0
  85. package/lib/types/member-feed.d.ts +4 -0
  86. package/lib/types/member-feed.js +45 -0
  87. package/lib/types/member-inbox.d.ts +30 -0
  88. package/lib/types/member-inbox.js +225 -0
  89. package/lib/types/model-directory.d.ts +43 -0
  90. package/lib/types/model-directory.js +192 -0
  91. package/lib/types/prepared-members.d.ts +15 -0
  92. package/lib/types/prepared-members.js +61 -0
  93. package/lib/types/settings-face.d.ts +61 -0
  94. package/lib/types/settings-face.js +61 -0
  95. package/lib/types/types.d.ts +959 -0
  96. package/lib/types/types.js +24 -0
  97. package/locale/en.json +6 -0
  98. package/locale/zh.json +6 -0
  99. package/package.json +112 -0
@@ -0,0 +1,2704 @@
1
+ /**
2
+ * @khorsheed/dsh-local-agent — the local code-agent harness family core.
3
+ * Each locally-installed coding agent CLI (Kimi Code, Codex, Claude Code, …)
4
+ * registers itself as one harness: a scoped home under the shared homes root,
5
+ * the device-code login command, and a session-records adapter. The glue
6
+ * registers the `/<harness> login|sessions` command family and provisions the
7
+ * scoped home; every harness's state stays under its own directory and never
8
+ * touches the user's native installation.
9
+ *
10
+ * Delegation deliberately stays OUT of this seam: each harness bundle mounts
11
+ * its own subagent-provider row into the existing `subagent` capability
12
+ * (subagent-acp for a harness that speaks ACP over stdio, subagent-codex's
13
+ * provider for Codex's app-server wire, …), reading the scoped home through
14
+ * {@link LocalAgentRegistry.homeDir}. This package owns only harness identity
15
+ * and lifecycle.
16
+ *
17
+ * @module @khorsheed/dsh-local-agent
18
+ */
19
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
20
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
21
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
22
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
23
+ });
24
+ }
25
+ return path;
26
+ };
27
+ import { spawn } from 'node:child_process';
28
+ import { randomUUID } from 'node:crypto';
29
+ import { appendFileSync, chmodSync, mkdirSync, readFileSync, statSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import z from '@deepseek-ai/schemastery';
33
+ import { SessionId } from '@deepseek-ai/dsh-session';
34
+ // The durable reader validates plugin vocabulary before replay. Register in
35
+ // both resolution planes: source-based host toolchains and installed bundles
36
+ // can otherwise hold separate catalog instances (the Room journal uses the
37
+ // same host seam). Keep registration after uninstall until process exit so
38
+ // existing member histories remain readable.
39
+ const streamCatalogSpecifier = ['@deepseek-ai/dsh-session', 'src', 'known-event-types'].join('/');
40
+ const streamCatalogs = await Promise.all([
41
+ import('@deepseek-ai/dsh-session'),
42
+ import(__rewriteRelativeImportExtension(streamCatalogSpecifier)).catch(() => undefined),
43
+ ]);
44
+ for (const catalog of streamCatalogs) {
45
+ catalog?.KNOWN_SESSION_EVENT_TYPES?.add('local-agent/stream');
46
+ }
47
+ import LocalAgentGateway from './gateway.js';
48
+ import { MemberChannel } from './member-channel.js';
49
+ import { MemberInbox } from './member-inbox.js';
50
+ import { PreparedMembers } from './prepared-members.js';
51
+ import { MemberControls } from './member-controls.js';
52
+ import { FileMemberControlStorage } from './member-control-storage.js';
53
+ /** Stable Cordis plugin name. */
54
+ export const name = 'local-agent';
55
+ /** Services required before the registry and commands can mount. */
56
+ export const inject = ['commands'];
57
+ export { delegationEnv } from './env.js';
58
+ export { ModelDirectoryCache, extendModelDirectory, modelDirectoryContextKey } from './model-directory.js';
59
+ export { MemberConfigurationController } from './member-control.js';
60
+ export { FileMemberControlStorage } from './member-control-storage.js';
61
+ export { LiveFlush, LIVE_FLUSH_INTERVAL_MS } from './live-flush.js';
62
+ import { LocalAgentStreams } from './live-stream.js';
63
+ export { LocalAgentStreams, LiveStreamPublisher, LIVE_CHECKPOINT_INTERVAL_MS } from './live-stream.js';
64
+ export { containerExecSpawn, containerScopedHome } from './container.js';
65
+ export { settingsFace, vol } from './settings-face.js';
66
+ export { CLI_VERSION_FAILURE_TTL_MS, CLI_VERSION_PROBE_TIMEOUT_MS, clearCliVersionCache, parseCliVersion, probeCliVersion, } from './cli-version.js';
67
+ /**
68
+ * Compose a session-backed subagent's durable label from the harness's
69
+ * display name and the delegation's own description, so the standard 子代理
70
+ * surface shows which local agent produced the conversation. Providers
71
+ * (kimi, future codex/claude-code) call this when appending the child's
72
+ * `subagent/descriptor` event; the prefix is the durable, model-visible
73
+ * source marker.
74
+ * @param displayName - the harness's human display name (e.g. `Kimi Code`).
75
+ * @param description - the delegation's short description, when the caller
76
+ * supplied one.
77
+ * @returns the composed label, or just the display name without a description.
78
+ */
79
+ export function subagentDelegationLabel(displayName, description) {
80
+ return description === undefined ? displayName : `${displayName}: ${description}`;
81
+ }
82
+ /**
83
+ * Append the parent-side `subagent/catalog` discovery row for a freshly
84
+ * established session-backed child. The official SubagentRuntime writes this
85
+ * row only for IN-PROCESS children (`run.localAgent.session`, harness
86
+ * `SubagentRuntime.start`); a family provider's run is remote, so the
87
+ * provider appends the row itself, right where the child's
88
+ * `subagent/descriptor` lands — exactly once per child session, since resume
89
+ * rounds never create a session.
90
+ *
91
+ * The payload mirrors the upstream `establishCatalogChild` helper
92
+ * (harness packages/subagent catalog.ts) byte-for-byte. It is inlined
93
+ * because that helper is unreachable through the package's exports map on
94
+ * the npm release line: only `./src/*` would resolve it, and published
95
+ * artifacts ship no `src/`. The `subagent/catalog` event type itself rides
96
+ * the official package's SessionEventMap augmentation, so this append stays
97
+ * type-checked against the host's own schema. A throw is the caller's to
98
+ * degrade (warn and continue) — a missing catalog row never blocks the
99
+ * delegation itself.
100
+ * @param parent - the durable direct parent session receiving the row.
101
+ * @param child - the established child's immutable session header.
102
+ * @param label - the same composed label the child's descriptor carries.
103
+ */
104
+ export function establishSubagentCatalogChild(parent, child, label) {
105
+ parent.append('subagent/catalog', {
106
+ version: 0,
107
+ childId: child.id,
108
+ childCreatedAt: child.createdAt,
109
+ mode: 'one-shot',
110
+ ...label === undefined ? {} : { label },
111
+ });
112
+ }
113
+ /**
114
+ * Persist a delegated child session's events, the family providers' one entry
115
+ * point after every mirror pass. Two paths:
116
+ *
117
+ * - **live session + a core with {@link LocalAgentRegistry.syncChildSession}**
118
+ * (the production wiring): delegate to the registry, which syncs through
119
+ * its cached per-child write handle — the same handle the reattach recipe
120
+ * holds, so live write routing and the explicit suffix sync share one
121
+ * owner and never collide on SessionAlreadyOwnedError. The registry
122
+ * downgrades a sync failure to a warn, so this path never throws.
123
+ * - **anything else** (standalone tests, ad-hoc mirrors, a core predating
124
+ * syncChildSession): a one-shot handle flow — claim the write handle
125
+ * (creating the stored session on first persist), append only the unstored
126
+ * suffix (re-appending the full snapshot violates the contiguous-seq
127
+ * contract), flush, close. There is deliberately NO live-session skip:
128
+ * reading the stored prefix first makes the suffix append idempotent, so
129
+ * on a host line whose write-behind does store live appends the suffix is
130
+ * simply empty — while on host 0.1.5, where a mirror session without an
131
+ * agent loop never checkpoints, this flow is what lands the events at all.
132
+ *
133
+ * @param ctx - host context carrying the sessions/localAgent/sessionPersistence services.
134
+ * @param childSession - the dsh child session the provider mirrored into.
135
+ */
136
+ export async function persistChildSession(ctx, childSession) {
137
+ const sessions = ctx.get('sessions');
138
+ if (sessions !== undefined && sessions.get(childSession.id) !== undefined) {
139
+ const registry = ctx.get('localAgent');
140
+ if (registry?.syncChildSession !== undefined) {
141
+ await registry.syncChildSession(childSession);
142
+ return;
143
+ }
144
+ }
145
+ const persistence = ctx.get('sessionPersistence');
146
+ if (persistence === undefined)
147
+ return;
148
+ const existing = await persistence.stat(childSession.id);
149
+ const handle = existing === undefined
150
+ ? await persistence.create(childSession.header)
151
+ : await persistence.open(childSession.id, 'write');
152
+ try {
153
+ const stored = await handle.read(0);
154
+ const suffix = childSession.snapshotEvents().slice(stored.events.length);
155
+ if (suffix.length > 0)
156
+ await handle.append(suffix);
157
+ await handle.flush();
158
+ }
159
+ finally {
160
+ await handle.close();
161
+ }
162
+ }
163
+ /** Grace between SIGTERM and SIGKILL when replacing an abandoned login child. */
164
+ export const REPLACE_LOGIN_GRACE_MS = 5_000;
165
+ /**
166
+ * Strip ANSI escape sequences (colors, OSC-8 hyperlinks) from captured CLI
167
+ * output. Login prompts are scraped from terminal-targeted output, and a
168
+ * trailing `ESC[0m` glued onto an OAuth URL corrupts the link the user opens
169
+ * (a real 3080 incident: codex's device URL ended in `%1B[0m` and the page
170
+ * reported the session ended).
171
+ */
172
+ const ANSI_ESCAPE = /\[[0-9;?]*[A-Za-z]|\]8;;[^\ ]*\\/g;
173
+ /** Remove terminal escape sequences from scraped CLI text. */
174
+ export function stripAnsi(text) {
175
+ return text.replace(ANSI_ESCAPE, '');
176
+ }
177
+ /**
178
+ * The characters a named scope may use. A scope is a NAME, never a path: the
179
+ * scoped home it selects always lands directly under the shared homes root,
180
+ * so `..`, a slash, or a leading dash can never walk out of it.
181
+ */
182
+ export const SCOPE_NAME_RE = /^[a-z0-9-]+$/;
183
+ /**
184
+ * The directory NAME of one harness's scoped home under the homes root: the
185
+ * harness name for the default scope, `<name>@<scope>` for a named one. The
186
+ * named directory is a SIBLING of the default one, never a child: nesting it
187
+ * inside would put a second CLI state tree under a directory each harness's
188
+ * own CLI owns and prunes.
189
+ * @param name - the harness name.
190
+ * @param scope - the named scope, or undefined for the default one.
191
+ * @returns the directory name under the homes root.
192
+ * @throws when the scope is present but not a `[a-z0-9-]` name.
193
+ */
194
+ export function scopedHomeName(name, scope) {
195
+ if (scope === undefined)
196
+ return name;
197
+ if (!SCOPE_NAME_RE.test(scope)) {
198
+ throw new Error(`localAgent: ${JSON.stringify(scope)} is not a usable scope name — a scope is a name matching [a-z0-9-], never a path`);
199
+ }
200
+ return `${name}@${scope}`;
201
+ }
202
+ /**
203
+ * Enforce the resume-scope consistency rule, the scoped-home twin of
204
+ * {@link assertResumeCwdUnchanged}: a CLI session continues in the scoped home
205
+ * its earlier rounds ran in, so a round naming a different scope — including
206
+ * naming none when the first round named one, and the reverse — is rejected
207
+ * instead of resuming the conversation against another account's credentials
208
+ * and another directory's session records. Records written before the `scope`
209
+ * field existed carry no scope and are default-scope records; a resume of one
210
+ * with a scope is exactly the mismatch this refuses.
211
+ * @param record - the delegation's persisted record, when one exists.
212
+ * @param scope - the round's scope, or undefined for the default one.
213
+ * @param provider - the provider name, for the error message.
214
+ * @throws when the record's scope differs from the round's.
215
+ */
216
+ export function assertResumeScopeUnchanged(record, scope, provider) {
217
+ if (record === undefined)
218
+ return;
219
+ if (record.scope === scope)
220
+ return;
221
+ throw new Error(`${provider}: resume scope ${scope === undefined ? '(default)' : scope} differs from the first round's `
222
+ + `${record.scope === undefined ? '(default)' : record.scope}; a CLI session continues in the scoped home its `
223
+ + 'earlier rounds ran in — repeat the first round\'s scope');
224
+ }
225
+ /**
226
+ * Refuse a round that names its own model and would be served by a live
227
+ * driver — the model twin of {@link assertScopeExecOnly}, and refused for the
228
+ * same reason. A resident runtime (codex's `app-server`, claude's and kimi's
229
+ * ACP servers, the sub-dsh `serve`) binds its model when the PROCESS starts
230
+ * and then serves many rounds of one member, so a per-delegation model would
231
+ * either be ignored or would silently change what every other round of that
232
+ * runtime runs. Refused rather than quietly downgraded to exec: the caller
233
+ * named a model, and a fallback would answer a different question than the
234
+ * one asked. The evaluation pins `drive: exec` anyway (web-eval frozen
235
+ * decision 2).
236
+ * @param model - the round's requested model, or undefined for none.
237
+ * @param provider - the provider name, for the error message.
238
+ * @throws when a named model meets an active live driver.
239
+ */
240
+ export function assertModelExecOnly(model, provider) {
241
+ if (model === undefined)
242
+ return;
243
+ throw new Error(`${provider}: a delegation that names model ${model} is exec-only — a resident runtime binds its model at spawn `
244
+ + 'and serves many rounds; turn the live driver off for this delegation, or drop the model');
245
+ }
246
+ /**
247
+ * The model one round starts its CLI with, resolved in the family's fixed
248
+ * order: the DELEGATION's own model (the `model` call option on the fresh
249
+ * round, re-requested by every resume round from the record) first, then the
250
+ * harness's `model` plugin-config key, then nothing — which leaves the scoped
251
+ * configuration file and, last, the CLI's own default to decide exactly as
252
+ * they did before either key existed.
253
+ *
254
+ * Blank is not a model: a whitespace-only value at either layer reads as
255
+ * unset, so an empty settings field cannot produce an empty flag value.
256
+ * @param requested - the delegation's own model, when it named one.
257
+ * @param configured - the plugin config's per-round resolver, when the plugin
258
+ * passed one.
259
+ * @returns `{ model }` when either layer names one, `{}` otherwise — the
260
+ * fragment a provider spreads into its run spec, so an absent model leaves
261
+ * the spawn argv byte-identical to the shape that shipped before.
262
+ */
263
+ export function resolveRoundModel(requested, configured) {
264
+ const own = requested?.trim();
265
+ if (own !== undefined && own !== '')
266
+ return { model: own };
267
+ const fallback = configured?.()?.trim();
268
+ return fallback === undefined || fallback === '' ? {} : { model: fallback };
269
+ }
270
+ /**
271
+ * Refuse a scoped round that would be served by a live driver. The resident
272
+ * runtimes (codex's `app-server`, claude's and kimi's ACP servers, the
273
+ * sub-dsh `serve`) are started per member against the DEFAULT scoped home and
274
+ * outlive a single round, so a scoped round taken by one would run under the
275
+ * wrong credentials and write its records into the wrong directory. Refused
276
+ * rather than silently downgraded to exec: the caller named a scope, and a
277
+ * quiet fallback would answer a different question than the one asked. The
278
+ * evaluation pins `drive: exec` anyway (web-eval frozen decision 2).
279
+ * @param scope - the round's scope, or undefined for the default one.
280
+ * @param provider - the provider name, for the error message.
281
+ * @throws when a named scope meets an active live driver.
282
+ */
283
+ export function assertScopeExecOnly(scope, provider) {
284
+ if (scope === undefined)
285
+ return;
286
+ throw new Error(`${provider}: a delegation in scope ${scope} is exec-only — the live driver binds the harness's default scoped home; `
287
+ + 'turn the live driver off for this round, or drop the scope');
288
+ }
289
+ /** Poll interval for a manual-handoff login watch. */
290
+ export const MANUAL_LOGIN_POLL_MS = 2_000;
291
+ /** How long a manual-handoff login watches before reporting the timeout. */
292
+ export const MANUAL_LOGIN_LIMIT_MS = 5 * 60_000;
293
+ /** Heartbeat interval for facade-tracked in-flight runs. */
294
+ export const RUN_PROGRESS_HEARTBEAT_MS = 5_000;
295
+ /**
296
+ * How long after a run's result settles the facade still routes progress to
297
+ * that call's `onProgress`.
298
+ *
299
+ * A round's settle OBSERVATION (its observed model, CLI version, and token
300
+ * usage) is only knowable once the CLI process has exited and its whole
301
+ * output stream has been parsed — which, on every exec-drive provider, is
302
+ * strictly after `run.result` resolves (a cancelled run settles at the cancel
303
+ * moment, before the process is even reaped). Clearing the tracked run at
304
+ * result-settle therefore dropped exactly the one report the caller asked for:
305
+ * the `settled` payload reached the cordis event and nothing else. The entry's
306
+ * cancel lever and heartbeat still stop at result-settle — only the progress
307
+ * route is held open, and it closes the moment the `settled` report arrives.
308
+ */
309
+ export const RUN_PROGRESS_SETTLE_GRACE_MS = 60_000;
310
+ /** Socket filename of the member-bridge listener, under the shared homes root. */
311
+ export const MEMBER_BRIDGE_SOCKET_FILENAME = 'member-bridge.sock';
312
+ /** One delegation-intent queue per (parent session, provider). */
313
+ function delegationIntentKey(parentSessionId, provider) {
314
+ return `${parentSessionId}\u0000${provider}`;
315
+ }
316
+ /**
317
+ * Fuse the facade-owned cancel controller with an optional caller signal, so
318
+ * `cancel()` and the caller's own abort channel both reach the run.
319
+ * `AbortSignal.any` is the harness's own fusion primitive (agent-loop's resume
320
+ * uses it); either source aborts the run.
321
+ */
322
+ function fusedSignal(controller, caller) {
323
+ if (caller === undefined)
324
+ return controller.signal;
325
+ return AbortSignal.any([controller.signal, caller]);
326
+ }
327
+ /**
328
+ * The per-harness append-only delegation-mapping log inside the harness's
329
+ * scoped home (`<homeDir>/<harness>/delegations.jsonl`): one JSON record per
330
+ * line, the last line per childSessionId wins — the same replace semantics as
331
+ * {@link LocalAgentRegistry.recordDelegation}. The file grows without
332
+ * rotation (accepted; same growth class as session_index/rollout files).
333
+ */
334
+ export const DELEGATIONS_FILENAME = 'delegations.jsonl';
335
+ /**
336
+ * Parse one `delegations.jsonl` line into a delegation record, or `undefined`
337
+ * when the line is malformed or names a different provider than the harness
338
+ * being loaded (a foreign or stale line must never fail registration).
339
+ * @param line - one raw line of the file.
340
+ * @param provider - the loading harness's delegation provider.
341
+ * @returns the record, or undefined to skip the line.
342
+ */
343
+ function parseDelegationLine(line, provider) {
344
+ let value;
345
+ try {
346
+ value = JSON.parse(line);
347
+ }
348
+ catch {
349
+ return undefined;
350
+ }
351
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
352
+ return undefined;
353
+ const record = value;
354
+ if (provider === undefined || record['provider'] !== provider)
355
+ return undefined;
356
+ const childSessionId = record['childSessionId'];
357
+ const parentSessionId = record['parentSessionId'];
358
+ const cliSessionId = record['cliSessionId'];
359
+ if (typeof childSessionId !== 'string' || childSessionId === '')
360
+ return undefined;
361
+ if (typeof parentSessionId !== 'string' || parentSessionId === '')
362
+ return undefined;
363
+ if (typeof cliSessionId !== 'string' || cliSessionId === '')
364
+ return undefined;
365
+ const kimiMirroredLines = record['kimiMirroredLines'];
366
+ const observedModel = record['observedModel'];
367
+ const cliVersion = record['cliVersion'];
368
+ const cwd = record['cwd'];
369
+ const scope = record['scope'];
370
+ const model = record['model'];
371
+ return {
372
+ childSessionId,
373
+ provider,
374
+ parentSessionId,
375
+ cliSessionId,
376
+ ...typeof kimiMirroredLines === 'number' && Number.isFinite(kimiMirroredLines)
377
+ ? { kimiMirroredLines }
378
+ : {},
379
+ // Optional observations: records written before the fields existed (and
380
+ // lines that never observed them) load unchanged — absence stays absence.
381
+ ...typeof observedModel === 'string' && observedModel !== '' ? { observedModel } : {},
382
+ ...typeof cliVersion === 'string' && cliVersion !== '' ? { cliVersion } : {},
383
+ ...typeof cwd === 'string' && cwd !== '' ? { cwd } : {},
384
+ // The scoped home the delegation belongs to. A line without one is a
385
+ // DEFAULT-scope record — which is what every line written before the
386
+ // field existed is, and what the fallback below preserves when a named
387
+ // scope's own file carries an older line.
388
+ ...typeof scope === 'string' && scope !== '' ? { scope } : {},
389
+ // The model the delegation requested. A line without one is a delegation
390
+ // that named none — which every line written before the field existed is.
391
+ ...typeof model === 'string' && model !== '' ? { model } : {},
392
+ ...typeof record['effort'] === 'string' && record['effort'] !== '' ? { effort: record['effort'] } : {},
393
+ ...typeof record['configurationLock'] === 'string' && record['configurationLock'] !== '' ? { configurationLock: record['configurationLock'] } : {},
394
+ };
395
+ }
396
+ /**
397
+ * Resolve the working directory one CLI delegation round runs in: the caller's
398
+ * override when supplied (the {@link DelegationCallOptions.cwd} position —
399
+ * an orchestrator giving each evaluation cell its own directory), otherwise
400
+ * the parent session's cwd. Returns `undefined` when neither is present — the
401
+ * caller fails loud on that, exactly as it did before overrides existed.
402
+ * Named after the seam the official ACP provider uses for the same override;
403
+ * the future container-workdir layer (web-eval I3) lands here too.
404
+ * @param parentCwd - the parent session's cwd, when the session has one.
405
+ * @param override - the caller-supplied cwd, when one was passed.
406
+ * @returns the effective cwd, or undefined when neither source has one.
407
+ */
408
+ export function resolveChildCwd(parentCwd, override) {
409
+ if (override !== undefined && override !== '')
410
+ return override;
411
+ return parentCwd;
412
+ }
413
+ /**
414
+ * Enforce the resume-cwd consistency rule: a CLI session continues in the
415
+ * directory its earlier rounds ran in, so a round whose effective cwd differs
416
+ * from the recorded first-round cwd is rejected instead of resuming the
417
+ * conversation somewhere else. Records written before the `cwd` field existed
418
+ * carry no anchor and opt out — absence is the honest state, never a guess.
419
+ * @param record - the delegation's persisted record, when one exists.
420
+ * @param effectiveCwd - the round's resolved working directory.
421
+ * @param provider - the provider name, for the error message.
422
+ * @throws when the record anchors a different first-round cwd.
423
+ */
424
+ export function assertResumeCwdUnchanged(record, effectiveCwd, provider) {
425
+ if (record?.cwd === undefined)
426
+ return;
427
+ if (record.cwd !== effectiveCwd) {
428
+ throw new Error(`${provider}: resume cwd ${effectiveCwd} differs from the first round's ${record.cwd}; `
429
+ + `a CLI session continues in the directory its earlier rounds ran in — repeat the first round's cwd`);
430
+ }
431
+ }
432
+ // Bare `z` annotation: `z<Config>`/`z<any, Config>` both fail schemastery
433
+ // 3.18.4's variance under exactOptionalPropertyTypes (TS2375), and dropping
434
+ // the annotation trips TS2742 on the inferred type's private names. The
435
+ // interface stays the apply signature's contract.
436
+ export const Config = z.object({
437
+ homesRoot: z.string().required(),
438
+ loginPromptTimeoutMs: z.number().default(10_000),
439
+ });
440
+ /** Service name provided by this plugin and injected by patch rows. */
441
+ export const LOCAL_AGENT_SERVICE = 'localAgent';
442
+ /** Render the session list for a command reply. */
443
+ function renderSessions(harness, records) {
444
+ if (records.length === 0)
445
+ return `No ${harness.displayName} sessions in the scoped home yet.`;
446
+ const lines = records.map(record => `- ${record.id} (workDir: ${record.workDir})`);
447
+ return `${harness.displayName} sessions (${records.length}):\n${lines.join('\n')}`;
448
+ }
449
+ /** Render one effective-settings snapshot as `key: value` status lines. */
450
+ function renderEffectiveSettings(settings) {
451
+ return [
452
+ `drive: ${settings.drive}`,
453
+ ...settings.sandbox !== undefined ? [`sandbox: ${settings.sandbox}`] : [],
454
+ ...settings.permissionMode !== undefined ? [`permissionMode: ${settings.permissionMode}`] : [],
455
+ ...settings.autoApprove !== undefined ? [`autoApprove: ${settings.autoApprove ? 'yes' : 'no'}`] : [],
456
+ ...settings.reasoningEffort !== undefined ? [`reasoningEffort: ${settings.reasoningEffort}`] : [],
457
+ settings.baseUrlSet
458
+ ? `baseUrl: set${settings.baseUrlHost !== undefined ? ` (${settings.baseUrlHost})` : ''}`
459
+ : 'baseUrl: default',
460
+ ...settings.cliVersion !== undefined ? [`cliVersion: ${settings.cliVersion}`] : [],
461
+ ];
462
+ }
463
+ /** Render the status reply: one `key: value` line per fact. */
464
+ function renderStatus(status) {
465
+ return [
466
+ `harness: ${status.name}`,
467
+ `authenticated: ${status.authenticated ? 'yes' : 'no'}`,
468
+ `credentialState: ${status.credentialState}`,
469
+ ...status.scope !== undefined ? [`scope: ${status.scope}`] : [],
470
+ `homeDir: ${status.homeDir}`,
471
+ ...status.delegationProvider !== undefined ? [`provider: ${status.delegationProvider}`] : [],
472
+ ...status.effectiveSettings !== undefined ? renderEffectiveSettings(status.effectiveSettings) : [],
473
+ ].join('\n');
474
+ }
475
+ /**
476
+ * Split one `/<harness>` input into its optional `--scope <name>` flag and the
477
+ * remaining subcommand. Both spellings a person reaches for are accepted
478
+ * (`--scope eval-b` and `--scope=eval-b`); everything else is left in place,
479
+ * so an input without the flag comes back exactly as it went in.
480
+ * @param input - the trimmed subcommand input.
481
+ * @returns the remaining input and the scope, or the error naming the rule.
482
+ */
483
+ export function parseScopeFlag(input) {
484
+ if (!input.includes('--scope'))
485
+ return { input };
486
+ const tokens = input.split(/\s+/);
487
+ const rest = [];
488
+ let scope;
489
+ for (let index = 0; index < tokens.length; index += 1) {
490
+ const token = tokens[index];
491
+ if (token === '--scope') {
492
+ const value = tokens[index + 1];
493
+ if (value === undefined || value.startsWith('--')) {
494
+ return { input, error: 'usage: --scope <name>, where the name matches [a-z0-9-] (a scope is a name, never a path)' };
495
+ }
496
+ scope = value;
497
+ index += 1;
498
+ continue;
499
+ }
500
+ if (token.startsWith('--scope=')) {
501
+ scope = token.slice('--scope='.length);
502
+ continue;
503
+ }
504
+ rest.push(token);
505
+ }
506
+ if (scope !== undefined && !SCOPE_NAME_RE.test(scope)) {
507
+ return { input, error: `${JSON.stringify(scope)} is not a usable scope name — a scope is a name matching [a-z0-9-], never a path` };
508
+ }
509
+ return { input: rest.join(' ').trim(), ...scope === undefined ? {} : { scope } };
510
+ }
511
+ /** Map a login failure to a command error, naming the swallowed signal. */
512
+ function loginFailure(harness, exitCode, signal) {
513
+ return `${harness.name} login exited with ${signal ?? `code ${exitCode ?? 'unknown'}`}; try /${harness.name} login again.`;
514
+ }
515
+ /**
516
+ * Registry of local code-agent harnesses and the per-harness command family.
517
+ * @module @khorsheed/dsh-local-agent
518
+ */
519
+ export class LocalAgentRegistry {
520
+ ctx;
521
+ homesRoot;
522
+ loginPromptTimeoutMs;
523
+ preparedMembers;
524
+ preparations = new Map();
525
+ memberInbox;
526
+ memberControls;
527
+ /** All provider starts, including direct tool starts, acquire the same whole-turn lease. */
528
+ withMemberConfigurationRound(binding, start, signal, onAdmitted) {
529
+ this.memberInbox.assertAdmission(binding.childSessionId);
530
+ return this.memberControls.run(binding, start, signal, () => { this.memberInbox.assertAdmission(binding.childSessionId); return onAdmitted?.(); });
531
+ }
532
+ requireInboxMember(childSessionId) {
533
+ const record = this.getDelegation(childSessionId);
534
+ if (record === undefined)
535
+ throw new Error('localAgent: no delegation recorded for this member');
536
+ if (!this.supportsMemberConfiguration(record.provider))
537
+ throw new Error('The member provider does not support queued input admission');
538
+ if (record.configurationLock !== undefined)
539
+ throw new Error('Frozen evaluation members do not accept extra human input');
540
+ return record;
541
+ }
542
+ enqueueMemberInput(childSessionId, text, requestId) {
543
+ const record = this.requireInboxMember(childSessionId);
544
+ this.requireLiveParent(record.parentSessionId);
545
+ return this.memberInbox.enqueue(childSessionId, text, requestId).id;
546
+ }
547
+ resumeMemberInbox(childSessionId) {
548
+ const record = this.requireInboxMember(childSessionId);
549
+ this.requireLiveParent(record.parentSessionId);
550
+ this.memberInbox.resume(childSessionId);
551
+ }
552
+ readMemberInbox(childSessionId) {
553
+ // Preparation establishes the same stable child identity before a delegation
554
+ // transcript exists. Its empty inbox must be readable by the composer.
555
+ if (this.isPreparedMember(childSessionId))
556
+ return this.memberInbox.read(childSessionId);
557
+ this.requireInboxMember(childSessionId);
558
+ return this.memberInbox.read(childSessionId);
559
+ }
560
+ queuedMemberRounds(childSessionId) { return this.memberControls.queuedCount(childSessionId); }
561
+ /** The admitted snapshot belongs to this exact run object, including after settle. */
562
+ runConfiguration(run) { return this.memberControls.configurationOf(run); }
563
+ supportsMemberConfiguration(provider) { return this.harnessForProvider(provider)?.modelBroker?.configurationAdapter !== undefined; }
564
+ memberControl(childSessionId) {
565
+ let binding = this.memberControls.binding(childSessionId);
566
+ if (binding === undefined) {
567
+ const record = this.getDelegation(childSessionId);
568
+ if (record === undefined || record.cwd === undefined)
569
+ binding = this.preparedMembers.read(childSessionId)?.binding;
570
+ else
571
+ binding = { ...record, cwd: record.cwd };
572
+ if (binding === undefined)
573
+ throw new Error('localAgent: member execution identity is unavailable');
574
+ }
575
+ return this.memberControls.get(binding);
576
+ }
577
+ /** Execution identity is available before the first native transcript exists. */
578
+ memberBinding(childSessionId) {
579
+ const known = this.memberControls.binding(childSessionId);
580
+ if (known !== undefined)
581
+ return known;
582
+ const record = this.getDelegation(childSessionId);
583
+ if (record?.cwd !== undefined)
584
+ return { ...record, cwd: record.cwd };
585
+ return this.preparedMembers.read(childSessionId)?.binding;
586
+ }
587
+ memberConfiguration(childSessionId) {
588
+ return this.memberControl(childSessionId).read();
589
+ }
590
+ selectMemberConfiguration(childSessionId, requestId, expectedRevision, selection) {
591
+ return this.memberControl(childSessionId).select(requestId, expectedRevision, selection);
592
+ }
593
+ cancelMemberConfiguration(childSessionId, requestId, expectedRevision) {
594
+ return this.memberControl(childSessionId).cancel(requestId, expectedRevision);
595
+ }
596
+ retryMemberConfiguration(childSessionId, expectedRevision) {
597
+ return this.memberControl(childSessionId).retry(expectedRevision);
598
+ }
599
+ followMemberConfiguration(childSessionId, signal) {
600
+ return this.memberControl(childSessionId).follow(signal);
601
+ }
602
+ /** Compatibility write routes through the durable slot, including while busy. */
603
+ async setMemberModel(childSessionId, model) {
604
+ const state = this.memberConfiguration(childSessionId);
605
+ const selection = state.pending?.selection ?? state.current.selection;
606
+ const receipt = await this.selectMemberConfiguration(childSessionId, randomUUID(), state.revision, {
607
+ ...selection, model: model?.trim() ? { mode: 'value', value: model } : { mode: 'inherit' },
608
+ });
609
+ if (!['pending', 'applying', 'applied'].includes(receipt.status))
610
+ throw new Error(receipt.error ?? receipt.status);
611
+ }
612
+ /** Independent live output transport for mirrored sessions without native Agents. */
613
+ liveStreams = new LocalAgentStreams();
614
+ harnesses = new Map();
615
+ /**
616
+ * Named scopes materialized in this host process, keyed exactly like their
617
+ * directory (`<name>@<scope>`) — the guard that keeps the mkdir, the
618
+ * harness provisioning and the per-directory delegation load to once each.
619
+ */
620
+ scopedHomes = new Set();
621
+ /**
622
+ * The provisioning currently running for each materialized scope.
623
+ *
624
+ * Materialization fires the harness's hook and does not wait for it — the
625
+ * directory is what the caller asked for, and the hook is best-effort. A
626
+ * caller that then provisions the SAME scope deliberately would otherwise
627
+ * race it, and both writers regenerate the same files. Recording the
628
+ * promise lets {@link LocalAgentRegistry.provisionScope} let the first one
629
+ * finish before it starts.
630
+ */
631
+ scopeProvisioning = new Map();
632
+ commandDisposers = new Map();
633
+ logins = new Map();
634
+ /**
635
+ * One delegation per dsh child session id: the CLI session id a resumed
636
+ * round continues, plus the ownership proof (parent session) and the
637
+ * provider that owns the CLI session.
638
+ */
639
+ delegations = new Map();
640
+ /**
641
+ * Observation recency for {@link latestObservedModel}: a monotonically
642
+ * increasing stamp per child session, bumped every time a record carrying an
643
+ * `observedModel` lands in {@link delegations} (recordDelegation,
644
+ * recordRoundSettled's merge, and the boot-time `delegations.jsonl` load —
645
+ * file order stamps the load, so the log's own last-line-wins semantics carry
646
+ * over). In-memory only: the records themselves deliberately carry no
647
+ * timestamp, and the Map's insertion order cannot serve (a `set` on an
648
+ * existing key keeps its first position, so an old record re-observed by a
649
+ * fresh round would sort as old).
650
+ */
651
+ observedModelStamps = new Map();
652
+ /** The stamp counter for {@link observedModelStamps}. */
653
+ observedModelClock = 0;
654
+ /**
655
+ * A settled round's observation that arrived BEFORE the delegation record
656
+ * existed ({@link recordRoundSettled} has nothing to merge into yet), held
657
+ * until {@link recordDelegation} lands and consumed there. A settle and the
658
+ * first-round record are ordered on every provider path today, but the
659
+ * record point is the provider's (a live round records at session/new, an
660
+ * exec round at the settle-time output parse), so the channel tolerates the
661
+ * inversion instead of dropping the observation. In-memory only and never
662
+ * persisted on its own: an observation whose record never arrives stays
663
+ * unrecorded, exactly as before.
664
+ */
665
+ pendingRoundObservations = new Map();
666
+ /**
667
+ * Per-(parent session, provider) FIFO of delegation intents. The family
668
+ * resume tool stages exactly one intent per call before awaiting
669
+ * `ctx.subagents.start()`, and the owning provider consumes exactly one per
670
+ * start, so fresh and resume rounds stay paired even under parallel
671
+ * delegations from one parent.
672
+ */
673
+ delegationIntents = new Map();
674
+ /**
675
+ * Per-child-session resume locks: a dsh child session may have only one
676
+ * in-flight resume at a time. The provider takes the lock before spawning
677
+ * the resume CLI and releases it when the run settles, so a second resume
678
+ * of the same child fails loud instead of racing the first process.
679
+ */
680
+ resumeLocks = new Set();
681
+ /**
682
+ * Kimi transcript-line mirror offset per child session, tracked
683
+ * INDEPENDENTLY of the delegation record: the offset must survive even when
684
+ * the CLI session hint is not parsed from stderr (the record — and therefore
685
+ * a resume — needs the hint, but the mirror bookkeeping does not). Without
686
+ * this separation a resume round would fall back to offset 0 and duplicate
687
+ * the first round's messages.
688
+ */
689
+ kimiMirrorOffsets = new Map();
690
+ /**
691
+ * Delegation-reported auth failures per harness (epoch ms of the report).
692
+ * Presence probes cannot see a server-side revocation, so a provider
693
+ * observing a 401/403 reports it here and `statusOf` downgrades the harness
694
+ * until its credential marker is rewritten by a real re-login (stamp newer
695
+ * than the mark).
696
+ */
697
+ authFailures = new Map();
698
+ /**
699
+ * Delegation-reported auth SUCCESSES per harness (epoch ms of the report):
700
+ * a round that reached the CLI's endpoint and completed, which is the only
701
+ * evidence a host has that a present credential is actually live. Cleared
702
+ * when a login starts or a logout lands, because both change which account
703
+ * the scoped home holds — a verification of the previous credential says
704
+ * nothing about the next one. Per host process on purpose: after a restart
705
+ * a present credential is `present-unverified` until a round exercises it,
706
+ * which is exactly what is known.
707
+ */
708
+ authSuccesses = new Map();
709
+ /**
710
+ * In-flight runs registered with the member channel, by per-run token. The
711
+ * bridge MCP server presents its token (and parent pid) on every callback;
712
+ * entries are invalidated on the run's settle path.
713
+ */
714
+ memberRuns = new Map();
715
+ /**
716
+ * The ACTIVE DELEGATION REGISTRY — in-flight local-agent runs by dsh child
717
+ * session id, the key the `/local-agent stop <childSessionId>` command and
718
+ * {@link LocalAgentRegistry.cancel} look up. Two registration kinds share
719
+ * the map:
720
+ *
721
+ * - **facade-tracked** ({@link trackRun}): runs started through
722
+ * {@link LocalAgentRegistry.start} / {@link LocalAgentRegistry.resume},
723
+ * carrying the fused AbortController whose signal reached
724
+ * `ctx.subagents.start`; cancel aborts it.
725
+ * - **tool-registered** ({@link trackDelegationRun}): runs the family tool
726
+ * starts directly through `ctx.subagents.start`, carrying an explicit
727
+ * cancel lever (its fused controller); cancel calls it.
728
+ *
729
+ * Every entry clears itself when the run's result settles (any stop
730
+ * reason), and facade entries also stop their heartbeat timer then.
731
+ */
732
+ runs = new Map();
733
+ /**
734
+ * Progress routes held open past their run's result-settle, by child session
735
+ * id — see {@link RUN_PROGRESS_SETTLE_GRACE_MS}. An entry carries only the
736
+ * call's `onProgress` (never a cancel lever: a settled run is not
737
+ * cancellable) and self-destructs on the `settled` report or the grace
738
+ * timer, whichever comes first.
739
+ */
740
+ settledRuns = new Map();
741
+ /**
742
+ * Detach disposers for child sessions the facade reattached into the live
743
+ * store (see {@link LocalAgentRegistry.resume} step 5). Held for the plugin
744
+ * lifetime — the same lifecycle a provider-created child session has — and
745
+ * released together on plugin dispose. The reattach's WRITE handle is NOT
746
+ * here: it lives in {@link childWriteHandles}, shared with
747
+ * {@link syncChildSession}, and closes with the cache on dispose.
748
+ */
749
+ reattachDisposers = new Map();
750
+ /**
751
+ * The registry-owned WRITE handle per child session id (a resolved-value
752
+ * cache: the PROMISE is stored so concurrent acquirers share one open).
753
+ * Host 0.1.5 routes a live session's `session/event` appends into the
754
+ * per-id writer only while a write handle is open, so exactly one handle
755
+ * must exist per child — a second open fails SessionAlreadyOwnedError.
756
+ * Both owners of a child session's durability share this cache: the
757
+ * reattach recipe ({@link reattachChildSession}) and the explicit suffix
758
+ * sync ({@link syncChildSession}). Every handle closes on plugin dispose.
759
+ */
760
+ childWriteHandles = new Map();
761
+ /**
762
+ * @param ctx - context carrying the command registry.
763
+ * @param homesRoot - shared scoped-homes root.
764
+ * @param loginPromptTimeoutMs - device-code prompt wait.
765
+ */
766
+ constructor(ctx, homesRoot, loginPromptTimeoutMs) {
767
+ this.ctx = ctx;
768
+ this.homesRoot = homesRoot;
769
+ this.loginPromptTimeoutMs = loginPromptTimeoutMs;
770
+ this.preparedMembers = new PreparedMembers(join(homesRoot, '.prepared-members'));
771
+ this.memberInbox = new MemberInbox(join(homesRoot, '.member-inbox'), async (childSessionId, text, signal, onAdmitted) => {
772
+ const record = this.requireInboxMember(childSessionId);
773
+ return this.resume(record.parentSessionId, record.provider, childSessionId, [{ type: 'text', text }], {
774
+ signal, onAdmitted, ...record.scope === undefined ? {} : { scope: record.scope }, ...record.cwd === undefined ? {} : { cwd: record.cwd },
775
+ });
776
+ }, error => this.ctx.logger.warn(`localAgent: member inbox failed: ${String(error)}`));
777
+ this.memberControls = new MemberControls(new FileMemberControlStorage(join(homesRoot, '.member-controls')), binding => {
778
+ const adapter = this.harnessForProvider(binding.provider)?.modelBroker?.configurationAdapter?.(binding);
779
+ if (adapter === undefined)
780
+ throw new Error(`localAgent: ${binding.provider} exposes no configuration admission adapter`);
781
+ return adapter;
782
+ }, error => { this.ctx.logger.warn(`localAgent: member control persistence failed: ${String(error)}`); });
783
+ // Reattached child sessions leave the live store, in-flight run
784
+ // heartbeats stop, and every cached child-session write handle closes,
785
+ // when the plugin unloads.
786
+ ctx.effect(() => () => {
787
+ this.memberInbox.dispose();
788
+ this.liveStreams.dispose();
789
+ for (const entry of this.runs.values()) {
790
+ if (entry.heartbeat !== undefined)
791
+ clearInterval(entry.heartbeat);
792
+ }
793
+ for (const entry of this.settledRuns.values())
794
+ clearTimeout(entry.timer);
795
+ this.settledRuns.clear();
796
+ for (const detach of this.reattachDisposers.values())
797
+ detach();
798
+ this.reattachDisposers.clear();
799
+ for (const [childSessionId, cached] of this.childWriteHandles) {
800
+ this.childWriteHandles.delete(childSessionId);
801
+ void cached.then(handle => handle.close()).catch((error) => {
802
+ this.ctx.logger.warn(`localAgent: closing the child session ${childSessionId} write handle failed: ${String(error)}`);
803
+ });
804
+ }
805
+ });
806
+ }
807
+ /**
808
+ * Absolute scoped home for one harness, in the default scope or in a NAMED
809
+ * one.
810
+ *
811
+ * The default scope (`scope` absent) is `<homesRoot>/<name>` and is a pure
812
+ * path computation, exactly as it always was. A named scope is the sibling
813
+ * directory `<homesRoot>/<name>@<scope>` — a name, never a path, so the
814
+ * directory can only ever be under the homes root — and is MATERIALIZED the
815
+ * first time it is named here: `mkdir` 0700 plus this harness's own
816
+ * provisioning, plus the load of that directory's own `delegations.jsonl`,
817
+ * once per (harness, scope) in this host process. Materializing on the read
818
+ * is what makes "a scope exists once something names it" true for every
819
+ * caller at once: `/<name> login --scope`, the status surfaces, a scoped
820
+ * delegation, and the evaluation's mount source.
821
+ *
822
+ * Credentials are never copied between scopes: a new named scope starts
823
+ * empty, reports `credentialState: absent`, and needs its own login.
824
+ * @param name - the harness name.
825
+ * @param scope - the named scope, or undefined for the default one.
826
+ * @returns `<homesRoot>/<name>`, or `<homesRoot>/<name>@<scope>`.
827
+ * @throws when the scope is present but not a `[a-z0-9-]` name.
828
+ */
829
+ homeDir(name, scope) {
830
+ const dir = join(this.homesRoot, scopedHomeName(name, scope));
831
+ if (scope !== undefined)
832
+ this.materializeScopedHome(name, scope, dir);
833
+ return dir;
834
+ }
835
+ /**
836
+ * Create one named scope's directory the first time it is named, with the
837
+ * same 0700 the default scope's gets, then run the harness's own
838
+ * provisioning and load that directory's persisted delegation mappings.
839
+ * Runs at most once per (harness, scope) per host process; the mkdir itself
840
+ * is idempotent, so a directory a person created by hand is adopted rather
841
+ * than refused. An unregistered harness gets the directory and nothing
842
+ * else — there is no harness to ask for provisioning yet.
843
+ */
844
+ materializeScopedHome(name, scope, dir) {
845
+ const key = scopedHomeName(name, scope);
846
+ if (this.scopedHomes.has(key))
847
+ return;
848
+ this.scopedHomes.add(key);
849
+ try {
850
+ mkdirSync(dir, { recursive: true });
851
+ chmodSync(dir, 0o700);
852
+ }
853
+ catch (error) {
854
+ this.scopedHomes.delete(key);
855
+ throw error;
856
+ }
857
+ const harness = this.harnesses.get(name);
858
+ if (harness === undefined)
859
+ return;
860
+ this.loadDelegations(harness, scope);
861
+ // Provisioning is async and best-effort, exactly as it is in every
862
+ // harness bundle's own apply: the directory exists either way, and a
863
+ // failure is a log line rather than a broken command reply.
864
+ const pending = (async () => { await harness.provision?.(dir); })().catch((error) => {
865
+ this.ctx.logger.warn(`localAgent: ${name} provisioning of scope ${scope} failed: ${error instanceof Error ? error.message : String(error)}`);
866
+ });
867
+ this.scopeProvisioning.set(key, pending);
868
+ void pending;
869
+ }
870
+ /**
871
+ * Provision ONE scope deliberately, with per-scope inputs, and report what
872
+ * the scope now holds.
873
+ *
874
+ * The difference from the materialization above is the whole reason this
875
+ * exists. Materialization runs once per (harness, scope) per host process,
876
+ * passes no options, and swallows failures — right for "somebody named a
877
+ * scope". A caller that provisions a scope on purpose is asking a question
878
+ * (`compose this preset`), needs the answer (`this is what it composes
879
+ * now`), and needs the failure (a preset that could not be composed must
880
+ * not read as one that was). So this one awaits, propagates, and returns
881
+ * the harness's read-back.
882
+ *
883
+ * A harness with no `provision` hook is not an error: there is simply
884
+ * nothing to do, and the caller's own read-back — the file it was going to
885
+ * check anyway — reports what is actually there.
886
+ * @param name - the harness name.
887
+ * @param scope - the named scope, or undefined for the default scoped home.
888
+ * @param options - per-scope inputs for the harness's provisioning.
889
+ * @returns the scoped home plus whatever the harness read back.
890
+ * @throws whatever the harness's provisioning throws.
891
+ */
892
+ async provisionScope(name, scope, options) {
893
+ const dir = this.homeDir(name, scope);
894
+ if (scope !== undefined) {
895
+ // The materialization this call may just have started writes the same
896
+ // files. Let it finish rather than interleave with it.
897
+ await this.scopeProvisioning.get(scopedHomeName(name, scope));
898
+ }
899
+ const harness = this.harnesses.get(name);
900
+ if (harness?.provision === undefined)
901
+ return { homeDir: dir };
902
+ const run = (async () => harness.provision?.(dir, options))();
903
+ if (scope !== undefined)
904
+ this.scopeProvisioning.set(scopedHomeName(name, scope), run.then(() => { }, () => { }));
905
+ const result = await run;
906
+ return { homeDir: dir, ...(result ?? {}) };
907
+ }
908
+ /**
909
+ * Register one harness: provision its scoped home, register the
910
+ * `/<name> login|sessions` command family, and publish the added event.
911
+ * @param harness - the harness definition.
912
+ * @returns the disposer unregistering the command and the harness.
913
+ */
914
+ register(harness) {
915
+ if (this.harnesses.has(harness.name)) {
916
+ throw new Error(`localAgent: harness ${harness.name} is already registered`);
917
+ }
918
+ const homeDir = this.homeDir(harness.name);
919
+ // The scoped home holds credentials and sessions; 0700 keeps them
920
+ // readable only by the owning user. mkdir's mode is umask-masked, so the
921
+ // chmod enforces the exact bits.
922
+ mkdirSync(homeDir, { recursive: true });
923
+ chmodSync(homeDir, 0o700);
924
+ // Restore this harness's persisted delegation mappings before the harness
925
+ // becomes visible: a resume after a host restart resolves them.
926
+ this.loadDelegations(harness);
927
+ this.harnesses.set(harness.name, harness);
928
+ const commandDisposer = this.ctx.commands.register({
929
+ name: harness.name,
930
+ description: `manage the scoped ${harness.displayName} CLI: login, sessions, status, logout`,
931
+ input: { hint: 'login|sessions|status|logout' },
932
+ handler: invocation => this.handle(invocation, harness),
933
+ });
934
+ this.commandDisposers.set(harness.name, commandDisposer);
935
+ this.ctx.emit('localAgent/harness-added', harness);
936
+ return () => {
937
+ commandDisposer();
938
+ this.commandDisposers.delete(harness.name);
939
+ this.harnesses.delete(harness.name);
940
+ this.ctx.emit('localAgent/harness-removed', harness.name);
941
+ };
942
+ }
943
+ /**
944
+ * Resolve one harness by name.
945
+ * @param name - the harness name.
946
+ * @returns the harness, or undefined when it is not registered.
947
+ */
948
+ get(name) {
949
+ return this.harnesses.get(name);
950
+ }
951
+ /**
952
+ * Resolve the harness that claims one delegation provider — the inverse of
953
+ * {@link LocalAgentHarness.delegationProvider}, for surfaces handed a
954
+ * provider name (a delegation record's) that need the owning harness's
955
+ * knobs (its model broker).
956
+ * @param provider - the `ctx.subagents` provider name.
957
+ * @returns the claiming harness, or undefined when none claims it.
958
+ */
959
+ harnessForProvider(provider) {
960
+ return [...this.harnesses.values()]
961
+ .find(candidate => candidate.delegationProvider === provider);
962
+ }
963
+ /**
964
+ * Every registered harness name, in registration order.
965
+ * @returns the harness names.
966
+ */
967
+ list() {
968
+ return [...this.harnesses.keys()];
969
+ }
970
+ /**
971
+ * Every registered harness as a roster row, in registration order.
972
+ * @returns the roster rows.
973
+ */
974
+ roster() {
975
+ return [...this.harnesses.values()].map(harness => ({ name: harness.name, displayName: harness.displayName }));
976
+ }
977
+ /**
978
+ * Record a delegation-observed authentication failure (a 401/403 from the
979
+ * CLI's endpoint). Presence probes cannot see a server-side revocation, so
980
+ * this mark downgrades the harness's reported status until a real re-login
981
+ * rewrites the credential marker (see {@link LocalAgentHarness.credentialStamp}).
982
+ * @param name - the harness whose credential failed.
983
+ * @param detail - the endpoint's answer, for the log.
984
+ * @param scope - the scope whose credential failed; absent means the
985
+ * default scope. Each scope holds its own account, so the mark is per
986
+ * scope: one scope's revocation says nothing about another's.
987
+ */
988
+ reportAuthFailure(name, detail, scope) {
989
+ this.authFailures.set(scopedHomeName(name, scope), Date.now());
990
+ this.ctx.logger.warn(`localAgent: ${name} credential rejected by its endpoint (${detail}); the harness reports unauthenticated until a fresh login rewrites the credential marker`);
991
+ }
992
+ /**
993
+ * Record a delegation-observed authentication SUCCESS: a round reached the
994
+ * harness CLI's endpoint and completed, so the scoped credential is known
995
+ * live and the status grades it `verified` instead of `present-unverified`.
996
+ * The counterpart of {@link reportAuthFailure}; providers wire both from the
997
+ * same settle path. A success newer than a failure mark also clears the
998
+ * `rejected` grade — a completed round outranks a stale rejection.
999
+ * @param name - the harness whose credential just worked.
1000
+ * @param scope - the scope whose credential just worked; absent means the
1001
+ * default scope.
1002
+ */
1003
+ reportAuthSuccess(name, scope) {
1004
+ this.authSuccesses.set(scopedHomeName(name, scope), Date.now());
1005
+ }
1006
+ /**
1007
+ * Forget what delegation rounds observed about one harness's credential.
1008
+ * Called where the account itself can change — a login starting, a logout
1009
+ * landing — because a verdict about the previous credential describes
1010
+ * nothing about the next one.
1011
+ */
1012
+ forgetCredentialObservations(name, scope) {
1013
+ const key = scopedHomeName(name, scope);
1014
+ this.authSuccesses.delete(key);
1015
+ this.authFailures.delete(key);
1016
+ }
1017
+ /**
1018
+ * Query one harness's auth status, in the default scope or in a named one.
1019
+ * @param name - the harness name.
1020
+ * @param scope - the named scope to report on; absent means the default
1021
+ * scope and the reply is exactly what it always was. A named scope is
1022
+ * materialized by the read (see {@link homeDir}), so a scope nobody has
1023
+ * logged into reports `credentialState: absent` against a real, empty
1024
+ * directory.
1025
+ * @returns the status snapshot.
1026
+ */
1027
+ async statusOf(name, scope) {
1028
+ const harness = this.requireHarness(name);
1029
+ const homeDir = this.homeDir(name, scope);
1030
+ const credentialState = await this.credentialStateOf(harness, homeDir, scope);
1031
+ // The boolean is exactly the two grades that mean "there is a credential
1032
+ // worth trying", so every surface written before the grade existed keeps
1033
+ // reading what it always read.
1034
+ const authenticated = credentialState === 'verified' || credentialState === 'present-unverified';
1035
+ // Degrade, don't explode: the snapshot reads scoped-config files, and a
1036
+ // read failure must not break the status surface — the field drops out
1037
+ // and the failure is logged instead.
1038
+ let effectiveSettings;
1039
+ try {
1040
+ effectiveSettings = await harness.effectiveSettings?.(homeDir);
1041
+ }
1042
+ catch (error) {
1043
+ this.ctx.logger.warn(`localAgent: ${name} effective-settings snapshot failed: ${error instanceof Error ? error.message : String(error)}`);
1044
+ }
1045
+ return {
1046
+ name: harness.name,
1047
+ displayName: harness.displayName,
1048
+ authenticated,
1049
+ credentialState,
1050
+ homeDir,
1051
+ ...scope === undefined ? {} : { scope },
1052
+ // Explicit capability flags so surfaces never offer a login/logout
1053
+ // action the harness would answer with an error (the dsh harness has
1054
+ // neither).
1055
+ loginable: harness.login !== undefined,
1056
+ logoutable: harness.logout !== undefined,
1057
+ ...this.logins.get(scopedHomeName(name, scope))?.awaitingCode === true ? { loginAwaitingCode: true } : {},
1058
+ ...harness.delegationProvider !== undefined ? { delegationProvider: harness.delegationProvider } : {},
1059
+ ...effectiveSettings !== undefined ? { effectiveSettings } : {},
1060
+ };
1061
+ }
1062
+ /**
1063
+ * Grade one harness's credential from the two channels a status read has:
1064
+ * the scoped home's own shape ({@link LocalAgentHarness.isAuthenticated})
1065
+ * and what delegation rounds reported. Presence alone can only ever say
1066
+ * `present-unverified` — an expired, unrefreshable record has exactly the
1067
+ * shape of a working one, and saying so is this grade's whole point.
1068
+ * @param harness - the harness to grade.
1069
+ * @param homeDir - its scoped home.
1070
+ * @param scope - the scope that directory belongs to; the delegation-
1071
+ * reported observations are per scope, because each scope holds its own
1072
+ * account.
1073
+ * @returns the credential grade.
1074
+ */
1075
+ async credentialStateOf(harness, homeDir, scope) {
1076
+ const present = await (harness.isAuthenticated?.(homeDir) ?? Promise.resolve(false));
1077
+ if (!present)
1078
+ return 'absent';
1079
+ const key = scopedHomeName(harness.name, scope);
1080
+ const failedAt = this.authFailures.get(key);
1081
+ const verifiedAt = this.authSuccesses.get(key);
1082
+ // A completed round outranks a stale rejection: the endpoint answered.
1083
+ if (verifiedAt !== undefined && (failedAt === undefined || verifiedAt > failedAt))
1084
+ return 'verified';
1085
+ if (failedAt !== undefined && harness.credentialStamp !== undefined) {
1086
+ // A delegation reported an auth failure (e.g. a server-side revocation
1087
+ // the presence probe cannot see). Stay rejected until the credential
1088
+ // marker is rewritten by a real re-login. A harness without a stamp has
1089
+ // no recovery signal at all, so its mark cannot pin the grade down.
1090
+ const stamp = await harness.credentialStamp(homeDir).catch(() => undefined);
1091
+ return stamp !== undefined && stamp > failedAt ? 'present-unverified' : 'rejected';
1092
+ }
1093
+ return 'present-unverified';
1094
+ }
1095
+ /**
1096
+ * One harness's fairness-relevant effective settings — the evaluation
1097
+ * condition hash's read side (web-eval frozen decisions 2 to 4: drive,
1098
+ * approval boundary, reasoning effort, endpoint route). Read-only: the
1099
+ * snapshot reflects the settings in force right now, whatever layer set
1100
+ * them (plugin config, scoped-home config a person edited, process env).
1101
+ * Pure JSON and credential-free by contract; see
1102
+ * {@link LocalAgentEffectiveSettings}.
1103
+ * @param name - the harness name.
1104
+ * @param scope - the scope to snapshot; absent means the default one.
1105
+ * @returns the snapshot, or undefined when the harness declares none.
1106
+ */
1107
+ async effectiveSettings(name, scope) {
1108
+ const harness = this.requireHarness(name);
1109
+ return harness.effectiveSettings?.(this.homeDir(name, scope));
1110
+ }
1111
+ /**
1112
+ * Query one harness's scoped session records.
1113
+ * @param name - the harness name.
1114
+ * @param scope - the scope to list; absent means the default one. Session
1115
+ * records belong to the directory the rounds ran in, so a named scope
1116
+ * lists its own and nothing of the default scope's.
1117
+ * @returns the session records in the harness's own order.
1118
+ */
1119
+ sessionsOf(name, scope) {
1120
+ return this.requireHarness(name).records.listSessions(this.homeDir(name, scope));
1121
+ }
1122
+ /**
1123
+ * Record one delegation's CLI-session mapping after its first round
1124
+ * settles, so a later resume round can continue the same CLI session. The
1125
+ * provider learns the CLI session id only from the settled round's output,
1126
+ * so recording happens post-settle. A duplicate child session id replaces
1127
+ * the earlier record: a resumed child keeps one mapping. The record is also
1128
+ * appended to the owning harness's `delegations.jsonl` (last line per child
1129
+ * session wins), so a host restart keeps the mapping resolvable. A settled
1130
+ * round's observation that arrived before this record (the provider's
1131
+ * record point is its own) is merged in from the pending stash — see
1132
+ * {@link recordRoundSettled}.
1133
+ * @param record - the delegation's dsh child session id, provider, owning
1134
+ * parent session id, and CLI session id.
1135
+ */
1136
+ recordDelegation(record) {
1137
+ const binding = this.memberControls.binding(record.childSessionId);
1138
+ if (binding !== undefined)
1139
+ record = {
1140
+ ...record,
1141
+ ...binding.effort === undefined ? {} : { effort: binding.effort },
1142
+ ...binding.configurationLock === undefined ? {} : { configurationLock: binding.configurationLock },
1143
+ };
1144
+ // A settle that beat the record (see pendingRoundObservations) fills only
1145
+ // the fields the record itself does not carry — the record's own values
1146
+ // win.
1147
+ const pending = this.pendingRoundObservations.get(record.childSessionId);
1148
+ this.pendingRoundObservations.delete(record.childSessionId);
1149
+ const merged = pending === undefined ? record : {
1150
+ ...record,
1151
+ ...record.observedModel === undefined && pending.observedModel !== undefined
1152
+ ? { observedModel: pending.observedModel }
1153
+ : {},
1154
+ ...record.cliVersion === undefined && pending.cliVersion !== undefined
1155
+ ? { cliVersion: pending.cliVersion }
1156
+ : {},
1157
+ };
1158
+ this.delegations.set(record.childSessionId, merged);
1159
+ this.noteObservedModel(merged);
1160
+ this.persistDelegation(merged);
1161
+ }
1162
+ /**
1163
+ * Resolve the CLI-session mapping a resume tool call needs to continue a
1164
+ * previous delegation. Ownership is proven by the recorded parent session
1165
+ * id: a caller naming a child session it did not delegate is rejected
1166
+ * instead of resuming someone else's conversation context.
1167
+ * @param childSessionId - the dsh child session id from the first round.
1168
+ * @param claims - the caller's provider and parent session id.
1169
+ * @returns the recorded CLI session id to continue.
1170
+ * @throws when the child session is unknown, owned by another parent, or
1171
+ * served by a different provider than claimed.
1172
+ */
1173
+ resolveDelegation(childSessionId, claims) {
1174
+ const record = this.delegations.get(childSessionId);
1175
+ if (record === undefined) {
1176
+ throw new Error(`localAgent: no delegation recorded for child session ${childSessionId}`);
1177
+ }
1178
+ if (record.parentSessionId !== claims.parentSessionId) {
1179
+ throw new Error(`localAgent: child session ${childSessionId} belongs to another parent session and cannot be resumed`);
1180
+ }
1181
+ if (record.provider !== claims.provider) {
1182
+ throw new Error(`localAgent: child session ${childSessionId} was delegated through ${record.provider}, not ${claims.provider}`);
1183
+ }
1184
+ return { cliSessionId: record.cliSessionId };
1185
+ }
1186
+ /**
1187
+ * Stage one delegation intent for a provider's next `start()`. The family
1188
+ * resume tool stages exactly one intent per call before awaiting
1189
+ * `ctx.subagents.start()`; the provider consumes exactly one per start via
1190
+ * {@link takeDelegationIntent}, so the per-(parent, provider) FIFO pairs
1191
+ * fresh and resume rounds even under parallel delegations from one parent.
1192
+ * @param parentSessionId - the delegating parent session id.
1193
+ * @param provider - the `ctx.subagents` provider name.
1194
+ * @param intent - the fresh or resume intent for the next start.
1195
+ */
1196
+ stageDelegationIntent(parentSessionId, provider, intent) {
1197
+ const key = delegationIntentKey(parentSessionId, provider);
1198
+ const queue = this.delegationIntents.get(key);
1199
+ if (queue === undefined) {
1200
+ this.delegationIntents.set(key, [intent]);
1201
+ return;
1202
+ }
1203
+ queue.push(intent);
1204
+ }
1205
+ /**
1206
+ * Consume the oldest staged intent for one provider start, or `undefined`
1207
+ * when the queue is empty (a provider invoked directly without the family
1208
+ * tool stages nothing and starts a fresh round).
1209
+ * @param parentSessionId - the delegating parent session id.
1210
+ * @param provider - the `ctx.subagents` provider name.
1211
+ * @returns the oldest staged intent, or undefined when none is staged.
1212
+ */
1213
+ takeDelegationIntent(parentSessionId, provider) {
1214
+ const key = delegationIntentKey(parentSessionId, provider);
1215
+ const queue = this.delegationIntents.get(key);
1216
+ if (queue === undefined || queue.length === 0)
1217
+ return undefined;
1218
+ const intent = queue.shift();
1219
+ if (queue.length === 0)
1220
+ this.delegationIntents.delete(key);
1221
+ return intent;
1222
+ }
1223
+ /**
1224
+ * Remove one previously staged intent by reference, returning whether it was
1225
+ * still queued. The delegation facade calls this when `ctx.subagents.start()`
1226
+ * throws: an intent the provider never consumed must not linger in the FIFO,
1227
+ * where the NEXT same-(parent, provider) start would misconsume it as its own
1228
+ * (orphan-intent crosstalk). When the provider already consumed the intent
1229
+ * before throwing, the removal is a no-op and the pairing stays intact.
1230
+ * @param parentSessionId - the delegating parent session id.
1231
+ * @param provider - the `ctx.subagents` provider name.
1232
+ * @param intent - the exact intent object passed to
1233
+ * {@link stageDelegationIntent}.
1234
+ * @returns whether the intent was removed (false when already consumed or
1235
+ * never staged).
1236
+ */
1237
+ unstageDelegationIntent(parentSessionId, provider, intent) {
1238
+ const key = delegationIntentKey(parentSessionId, provider);
1239
+ const queue = this.delegationIntents.get(key);
1240
+ if (queue === undefined)
1241
+ return false;
1242
+ const index = queue.indexOf(intent);
1243
+ if (index === -1)
1244
+ return false;
1245
+ queue.splice(index, 1);
1246
+ if (queue.length === 0)
1247
+ this.delegationIntents.delete(key);
1248
+ return true;
1249
+ }
1250
+ /**
1251
+ * Every recorded delegation, in recording order. The invariant companion
1252
+ * cross-checks each record's provider against the mounted subagent
1253
+ * providers so a provider rename cannot leave a stale resume mapping.
1254
+ * @returns the recorded delegations.
1255
+ */
1256
+ listDelegations() {
1257
+ return [...this.delegations.values()];
1258
+ }
1259
+ /**
1260
+ * Read-only lookup of one delegation by its dsh child session id — the
1261
+ * member channel's membership check. Unlike {@link resolveDelegation} this
1262
+ * never throws and performs no ownership assertion: the gateway composes it
1263
+ * with {@link resume} (whose ownership checks are unchanged) for the
1264
+ * human-opened child session, where the record itself is the authorization
1265
+ * source.
1266
+ * @param childSessionId - the dsh child session id.
1267
+ * @returns the record, or undefined when this child was never delegated
1268
+ * through the family.
1269
+ */
1270
+ getDelegation(childSessionId) {
1271
+ return this.delegations.get(childSessionId);
1272
+ }
1273
+ /**
1274
+ * Read-only lookup of one delegation's facts by its dsh child session id,
1275
+ * WITHOUT the CLI-session resume handle — the orchestrator-facing read side
1276
+ * (the evaluation reads a cell's recorded `observedModel` and first-round
1277
+ * `cwd` through it). Same never-throws contract as
1278
+ * {@link getDelegation}; the record's optional fields stay absent when the
1279
+ * stream never yielded them.
1280
+ * @param childSessionId - the dsh child session id.
1281
+ * @returns the record without `cliSessionId`, or undefined when this child
1282
+ * was never delegated through the family.
1283
+ */
1284
+ delegationOf(childSessionId) {
1285
+ const record = this.delegations.get(childSessionId);
1286
+ if (record === undefined)
1287
+ return undefined;
1288
+ const { cliSessionId: _resumeHandle, ...info } = record;
1289
+ return info;
1290
+ }
1291
+ /**
1292
+ * Stamp a record's `observedModel` with the next recency tick, when the
1293
+ * record carries a usable one. Called everywhere a record with an observed
1294
+ * model lands in {@link delegations}; a blank value is no observation.
1295
+ */
1296
+ noteObservedModel(record) {
1297
+ if (record.observedModel === undefined || record.observedModel.trim() === '')
1298
+ return;
1299
+ this.observedModelClock += 1;
1300
+ this.observedModelStamps.set(record.childSessionId, this.observedModelClock);
1301
+ }
1302
+ /**
1303
+ * The most recent model one provider was OBSERVED running across all its
1304
+ * recorded delegations — the display hint behind the cli-builtin layer
1305
+ * (nothing names the CLI's compiled default, but "what ran last" is what a
1306
+ * round with no model layer would run again). Recency is the observation
1307
+ * stamp ({@link observedModelStamps}), not record insertion order: a round
1308
+ * that re-observes an old delegation counts as the freshest. Records whose
1309
+ * stream never yielded a model (absent or blank) are not candidates.
1310
+ * @param provider - the `ctx.subagents` provider name to scan.
1311
+ * @returns the newest observed model identifier, or undefined when this
1312
+ * provider was never observed running one.
1313
+ */
1314
+ latestObservedModel(provider) {
1315
+ let newest;
1316
+ for (const record of this.delegations.values()) {
1317
+ if (record.provider !== provider)
1318
+ continue;
1319
+ const model = record.observedModel?.trim();
1320
+ if (model === undefined || model === '')
1321
+ continue;
1322
+ const stamp = this.observedModelStamps.get(record.childSessionId) ?? 0;
1323
+ if (newest === undefined || stamp >= newest.stamp)
1324
+ newest = { stamp, model };
1325
+ }
1326
+ return newest?.model;
1327
+ }
1328
+ /**
1329
+ * Record one delegation round's settled observation: merge `observedModel`
1330
+ * into the delegation record when present (in memory and `delegations.jsonl`,
1331
+ * same replace semantics as {@link recordDelegation}) and report the
1332
+ * `settled` run-progress event with the round's observed model and usage.
1333
+ * Providers call it once per settled round, at the point their output
1334
+ * stream has been fully parsed; fields the stream did not yield stay absent
1335
+ * — absence is recorded, never guessed. A record that does not exist yet
1336
+ * (the round's settle beat the provider's record point — e.g. a round whose
1337
+ * CLI session id only parses post-settle) does not lose the observation: it
1338
+ * is stashed and merged by the later {@link recordDelegation}; the event
1339
+ * still reports either way.
1340
+ * @param childSessionId - the dsh child session id of the settled round.
1341
+ * @param round - the round's observed model, version, usage and tool-call
1342
+ * accounting, each optional.
1343
+ */
1344
+ recordRoundSettled(childSessionId, round) {
1345
+ if (round.observedModel !== undefined || round.cliVersion !== undefined) {
1346
+ const record = this.delegations.get(childSessionId);
1347
+ if (record !== undefined) {
1348
+ const updated = {
1349
+ ...record,
1350
+ ...round.observedModel === undefined ? {} : { observedModel: round.observedModel },
1351
+ ...round.cliVersion === undefined ? {} : { cliVersion: round.cliVersion },
1352
+ };
1353
+ this.delegations.set(childSessionId, updated);
1354
+ this.noteObservedModel(updated);
1355
+ this.persistDelegation(updated);
1356
+ }
1357
+ else {
1358
+ const pending = this.pendingRoundObservations.get(childSessionId) ?? {};
1359
+ this.pendingRoundObservations.set(childSessionId, {
1360
+ ...(round.observedModel ?? pending.observedModel) === undefined
1361
+ ? {}
1362
+ : { observedModel: (round.observedModel ?? pending.observedModel) },
1363
+ ...(round.cliVersion ?? pending.cliVersion) === undefined
1364
+ ? {}
1365
+ : { cliVersion: (round.cliVersion ?? pending.cliVersion) },
1366
+ });
1367
+ }
1368
+ }
1369
+ // `toolCalls` rides the EVENT only, never the record: the record carries a
1370
+ // delegation's latest state (its model, its build), while a tool-call
1371
+ // count belongs to one round and merging it would silently overwrite the
1372
+ // previous round's count with the newest one.
1373
+ this.reportRunProgress(childSessionId, {
1374
+ kind: 'settled',
1375
+ ...round.observedEffort === undefined ? {} : { observedEffort: round.observedEffort },
1376
+ ...round.observedModel === undefined ? {} : { observedModel: round.observedModel },
1377
+ ...round.cliVersion === undefined ? {} : { cliVersion: round.cliVersion },
1378
+ ...round.usage === undefined ? {} : { usage: round.usage },
1379
+ ...round.toolCalls === undefined ? {} : { toolCalls: round.toolCalls },
1380
+ });
1381
+ }
1382
+ /**
1383
+ * Acquire the resume lock for one child session. A dsh child session may
1384
+ * have only one in-flight resume: the provider takes the lock before
1385
+ * spawning the resume CLI and releases it when the run settles, so a second
1386
+ * resume of the same child fails loud instead of racing the first process.
1387
+ * Fresh delegations never take the lock (they mint a new child session).
1388
+ * @param childSessionId - the dsh child session id to resume.
1389
+ * @returns whether the lock was acquired (false when already held).
1390
+ */
1391
+ acquireResumeLock(childSessionId) {
1392
+ if (this.resumeLocks.has(childSessionId))
1393
+ return false;
1394
+ this.resumeLocks.add(childSessionId);
1395
+ return true;
1396
+ }
1397
+ /**
1398
+ * Release the resume lock for one child session. The provider releases it
1399
+ * on every settle path (completed, failed, cancelled) so a deadlock never
1400
+ * strands a later resume. Releasing an unheld lock is a no-op.
1401
+ * @param childSessionId - the dsh child session id.
1402
+ */
1403
+ releaseResumeLock(childSessionId) {
1404
+ this.resumeLocks.delete(childSessionId);
1405
+ }
1406
+ /**
1407
+ * Read-only probe of the per-child resume lock. The delegation facade uses
1408
+ * it to fail fast BEFORE staging an intent; the authoritative mutual
1409
+ * exclusion stays with the provider's `acquireResumeLock` inside `start()`.
1410
+ * @param childSessionId - the dsh child session id.
1411
+ * @returns whether a resume of this child session is currently in flight.
1412
+ */
1413
+ isResumeLocked(childSessionId) {
1414
+ return this.resumeLocks.has(childSessionId);
1415
+ }
1416
+ memberBridgeAvailable = false;
1417
+ /** Listener lifecycle owns this flag; it is never persisted across host restarts. */
1418
+ setMemberBridgeAvailable(available) { this.memberBridgeAvailable = available; }
1419
+ /** Transport/readiness capability only; authenticated generation needs separate acceptance. */
1420
+ canCoordinateRoom(childSessionId) {
1421
+ const binding = this.memberBinding(childSessionId);
1422
+ return this.memberBridgeAvailable && binding !== undefined && this.harnessForProvider(binding.provider)?.prepareMember !== undefined;
1423
+ }
1424
+ /**
1425
+ * Register one CLI run with the member channel and mint its per-run token.
1426
+ * The provider calls this before spawning the CLI (fresh and resume rounds
1427
+ * alike) and injects the token into the bridge MCP server's environment; the
1428
+ * token is how the host resolves which run — and therefore which member — a
1429
+ * bridge callback belongs to, so a member's identity is never self-reported.
1430
+ * @param run - the run's member identity (child session, parent, provider).
1431
+ * @returns the minted token, invalidated by {@link unregisterMemberRun}.
1432
+ */
1433
+ registerMemberRun(run) {
1434
+ const token = randomUUID();
1435
+ this.memberRuns.set(token, { ...run });
1436
+ return token;
1437
+ }
1438
+ /**
1439
+ * Resolve a member-channel token to its run, or undefined when the token is
1440
+ * unknown or already invalidated (the run settled).
1441
+ * @param token - the token the bridge presented.
1442
+ * @returns the registered run, or undefined.
1443
+ */
1444
+ resolveMemberRun(token) {
1445
+ return this.memberRuns.get(token);
1446
+ }
1447
+ /**
1448
+ * Invalidate a run's member-channel token on its settle path (any stop
1449
+ * reason). A bridge callback arriving after settle is an unknown token.
1450
+ * @param token - the token to invalidate.
1451
+ */
1452
+ unregisterMemberRun(token) {
1453
+ this.memberRuns.delete(token);
1454
+ }
1455
+ /**
1456
+ * The loopback socket path the member-bridge listener owns. Providers inject
1457
+ * it into the bridge's environment so the stdio MCP server knows where to
1458
+ * call back.
1459
+ * @returns the unix socket path under the shared homes root.
1460
+ */
1461
+ memberBridgeSocketPath() {
1462
+ return join(this.homesRoot, MEMBER_BRIDGE_SOCKET_FILENAME);
1463
+ }
1464
+ /**
1465
+ * The stdio MCP server entry for the member bridge, for providers to write
1466
+ * into their CLI's scoped MCP config. The bridge script ships inside this
1467
+ * package and runs under the same Node that runs the host.
1468
+ * @returns the command + args declaring the bridge server.
1469
+ */
1470
+ memberBridgeCommand() {
1471
+ return { command: process.execPath, args: [fileURLToPath(new URL('./member-bridge.js', import.meta.url))] };
1472
+ }
1473
+ /** Prepare a roster-owned identity without a paid prompt; repeated calls coalesce. */
1474
+ prepareMember(parentSessionId, provider, childSessionId, options = {}) {
1475
+ const signature = JSON.stringify([parentSessionId, provider, options.cwd, options.scope, options.model, options.effort, options.configurationLock, options.exec, options.preparedMemberId]);
1476
+ const existing = this.preparations.get(childSessionId);
1477
+ if (existing !== undefined)
1478
+ return existing.signature === signature ? existing.promise : Promise.reject(new Error('Prepared member identity cannot change'));
1479
+ const preparation = this.prepareMemberNow(parentSessionId, provider, childSessionId, options)
1480
+ .finally(() => this.preparations.delete(childSessionId));
1481
+ this.preparations.set(childSessionId, { signature, promise: preparation });
1482
+ return preparation;
1483
+ }
1484
+ async prepareMemberNow(parentSessionId, provider, childSessionId, options) {
1485
+ options.signal?.throwIfAborted();
1486
+ if (options.exec !== undefined || options.preparedMemberId !== undefined)
1487
+ throw new Error('Member preparation requires a live local harness');
1488
+ const parent = this.requireLiveParent(parentSessionId);
1489
+ this.requireProvider(this.requireSubagents(), provider);
1490
+ const harness = this.harnessForProvider(provider);
1491
+ if (harness?.prepareMember === undefined)
1492
+ throw new Error('Harness has no native member preparation capability');
1493
+ const cwd = resolveChildCwd(parent.session.header.cwd, options.cwd);
1494
+ if (cwd === undefined || !statSync(cwd).isDirectory())
1495
+ throw new Error('Member working directory is unavailable');
1496
+ const binding = { childSessionId, parentSessionId, provider, cwd,
1497
+ ...options.scope === undefined ? {} : { scope: options.scope },
1498
+ ...options.model === undefined ? {} : { model: options.model },
1499
+ ...options.effort === undefined ? {} : { effort: options.effort },
1500
+ ...options.configurationLock === undefined ? {} : { configurationLock: options.configurationLock },
1501
+ };
1502
+ const previous = this.preparedMembers.read(childSessionId);
1503
+ if (previous !== undefined && JSON.stringify(previous.binding) !== JSON.stringify(binding))
1504
+ throw new Error('Prepared member identity cannot change');
1505
+ if (previous?.phase === 'starting' || previous?.phase === 'used' || this.getDelegation(childSessionId) !== undefined)
1506
+ throw new Error('Member has already entered execution; use its existing controls');
1507
+ const status = await this.statusOf(harness.name, options.scope);
1508
+ if (!status.authenticated)
1509
+ throw new Error('Member harness is not authenticated in its bound scope');
1510
+ this.preparedMembers.write({ version: 1, binding, phase: 'preparing' });
1511
+ const sessions = this.ctx.get('sessions');
1512
+ if (sessions === undefined)
1513
+ throw new Error('Session store is unavailable');
1514
+ let childSession = sessions.get(SessionId(childSessionId));
1515
+ if (childSession === undefined) {
1516
+ const stored = await this.ctx.get('sessionPersistence')?.stat(SessionId(childSessionId));
1517
+ if (stored !== undefined && stored !== null) {
1518
+ await this.reattachChildSession(childSessionId);
1519
+ childSession = sessions.get(SessionId(childSessionId));
1520
+ }
1521
+ childSession ??= sessions.create(SessionId(childSessionId), { meta: { cwd, parentSession: parent.session.id, origin: 'subagent', delegationDepth: (parent.session.header.delegationDepth ?? 0) + 1 } });
1522
+ }
1523
+ if (childSession.header.parentSession !== parent.session.id || childSession.header.cwd !== cwd)
1524
+ throw new Error('Prepared transcript identity does not match its parent');
1525
+ const control = this.memberControls.get(binding);
1526
+ // An explicit repeated preparation retries only pre-execution failure.
1527
+ // The durable starting/used barrier above never admits uncertain paid work.
1528
+ if (control.read().status === 'failed')
1529
+ await control.retry(control.read().revision);
1530
+ const lease = await control.admit(`prepare:${randomUUID()}`);
1531
+ try {
1532
+ await harness.prepareMember({ binding, childSession, configuration: lease.configuration, signal: options.signal ?? AbortSignal.timeout(30_000) });
1533
+ await this.syncChildSession(childSession, true);
1534
+ this.preparedMembers.write({ version: 1, binding, phase: 'ready' });
1535
+ }
1536
+ catch (error) {
1537
+ control.rejectAdmission(error);
1538
+ throw error;
1539
+ }
1540
+ finally {
1541
+ lease.release();
1542
+ }
1543
+ return childSessionId;
1544
+ }
1545
+ isPreparedMember(childSessionId) { return this.preparedMembers.read(childSessionId)?.phase === 'ready' && this.getDelegation(childSessionId) === undefined; }
1546
+ /**
1547
+ * Start a FRESH delegation on one subagent provider — the programmatic
1548
+ * equivalent of the family tool's fresh call, for plugins acting on the
1549
+ * user's behalf (room, orchestrators). One call performs the provider
1550
+ * pre-check, resolves the live parent agent, stages exactly one
1551
+ * `{ kind: 'fresh' }` intent, and starts the provider in the same
1552
+ * synchronous flow, so the per-(parent, provider) FIFO pairing holds. When
1553
+ * the start throws, the staged intent is rolled back via
1554
+ * {@link unstageDelegationIntent} (a no-op once the provider consumed it).
1555
+ *
1556
+ * The returned run is tracked under its `run.id` (== the new dsh child
1557
+ * session id) until `run.result` settles; {@link cancel} aborts it by that
1558
+ * id. Note on trust: cordis plugins share one process, so the ownership
1559
+ * checks here and in {@link resume} guard against caller MISTAKES, not
1560
+ * against a malicious plugin — their real protection target is untrusted
1561
+ * model text, which never carries the resume handle (first-class parameter
1562
+ * + intent channel only).
1563
+ * @param parentSessionId - the delegating parent session id; must have a
1564
+ * live agent (`ctx.agents.get`, a harness hard constraint).
1565
+ * @param provider - the `ctx.subagents` provider name.
1566
+ * @param prompt - the child user message content blocks.
1567
+ * @param options - optional label and caller-owned abort signal.
1568
+ * @returns the published subagent run.
1569
+ * @throws when the subagents/agents service is not mounted, the provider is
1570
+ * not registered, or the parent session has no live agent — in every case
1571
+ * BEFORE anything is staged.
1572
+ */
1573
+ async start(parentSessionId, provider, prompt, options) {
1574
+ const prepared = options?.preparedMemberId === undefined ? undefined : this.preparedMembers.read(options.preparedMemberId);
1575
+ if (options?.preparedMemberId !== undefined) {
1576
+ if (prepared?.phase !== 'ready' || prepared.binding.parentSessionId !== parentSessionId || prepared.binding.provider !== provider)
1577
+ throw new Error('Prepared member is unavailable or belongs to another parent/provider');
1578
+ if (options.model !== undefined || options.effort !== undefined || options.cwd !== undefined || options.scope !== undefined || options.exec !== undefined || options.configurationLock !== undefined)
1579
+ throw new Error('Prepared member start inherits its fixed creation identity');
1580
+ this.requireProvider(this.requireSubagents(), provider);
1581
+ this.requireLiveParent(parentSessionId);
1582
+ if (this.preparations.has(prepared.binding.childSessionId))
1583
+ throw new Error('Member preparation is still in progress');
1584
+ this.preparedMembers.write({ ...prepared, phase: 'starting' });
1585
+ options = { ...options, ...prepared.binding, preparedMemberId: prepared.binding.childSessionId };
1586
+ await this.reattachChildSession(prepared.binding.childSessionId);
1587
+ }
1588
+ const subagents = this.requireSubagents();
1589
+ this.requireProvider(subagents, provider);
1590
+ const parent = this.requireLiveParent(parentSessionId);
1591
+ // The cwd override rides the staged intent, not the host seam (the
1592
+ // SubagentStartRequest contract has no cwd field and must not grow one):
1593
+ // the provider consumes it as the resolveChildCwd override.
1594
+ const intent = {
1595
+ kind: 'fresh',
1596
+ ...options?.onAdmitted === undefined ? {} : { onAdmitted: options.onAdmitted },
1597
+ ...options?.preparedMemberId === undefined ? {} : { preparedMemberId: options.preparedMemberId },
1598
+ ...options?.cwd === undefined ? {} : { cwd: options.cwd },
1599
+ // The container exec target rides the same channel for the same
1600
+ // reason: it is a family-private start fact, and the host
1601
+ // SubagentStartRequest contract has no place for it.
1602
+ ...options?.exec === undefined ? {} : { exec: options.exec },
1603
+ // The scoped home this round runs against, for the same reason again.
1604
+ // The provider resolves `homeDir(<harness>, scope)` from it and records
1605
+ // the scope, so the resume of this delegation must repeat it.
1606
+ ...options?.scope === undefined ? {} : { scope: options.scope },
1607
+ // The model this DELEGATION runs. The provider passes it to the CLI and
1608
+ // records it, so a resume round re-requests the same value without the
1609
+ // caller restating it (and without being able to change it).
1610
+ ...options?.model === undefined ? {} : { model: options.model },
1611
+ ...options?.effort === undefined ? {} : { effort: options.effort },
1612
+ ...options?.configurationLock === undefined ? {} : { configurationLock: options.configurationLock },
1613
+ };
1614
+ this.stageDelegationIntent(parentSessionId, provider, intent);
1615
+ const controller = new AbortController();
1616
+ let run;
1617
+ try {
1618
+ run = await subagents.start(provider, {
1619
+ prompt,
1620
+ parent,
1621
+ signal: fusedSignal(controller, options?.signal),
1622
+ ...options?.label === undefined ? {} : { label: options.label },
1623
+ });
1624
+ }
1625
+ catch (error) {
1626
+ this.unstageDelegationIntent(parentSessionId, provider, intent);
1627
+ throw error;
1628
+ }
1629
+ this.trackRun(run.id, controller, run, options?.onProgress);
1630
+ if (prepared !== undefined) {
1631
+ try {
1632
+ this.preparedMembers.write({ ...prepared, phase: 'used' });
1633
+ }
1634
+ catch (error) {
1635
+ // The durable starting barrier still prevents replay. Keep the actual
1636
+ // published run reachable for cancellation and result delivery.
1637
+ this.ctx.logger.warn(`localAgent: finalizing prepared member ${run.id} failed: ${String(error)}`);
1638
+ }
1639
+ }
1640
+ return run;
1641
+ }
1642
+ /**
1643
+ * Resume a recorded delegation: continue the SAME CLI session inside the
1644
+ * SAME dsh child session. One call performs, in order:
1645
+ *
1646
+ * 1. provider pre-check (`ctx.subagents.getProvider`) — fails loud WITHOUT
1647
+ * staging, so an unknown provider never leaves an orphan intent;
1648
+ * 2. `resolveDelegation` ownership check (unchanged semantics): the handle
1649
+ * must name a delegation THIS parent session recorded through THIS
1650
+ * provider;
1651
+ * 3. read-only resume-lock probe ({@link isResumeLocked}) — fails fast
1652
+ * without staging; the provider still takes the authoritative lock inside
1653
+ * its `start()`;
1654
+ * 4. live parent agent resolution (`ctx.agents.get`) — a harness hard
1655
+ * constraint, since `ctx.subagents.start` requires a live parent;
1656
+ * 5. child-session REATTACH when `ctx.sessions.get(childSessionId)` is
1657
+ * undefined (recipe below; `opts.reattach === false` disables this step
1658
+ * and fails loud on a non-live child instead);
1659
+ * 6. stage `{ kind: 'resume', childSessionId, cliSessionId }` and, in the
1660
+ * same synchronous flow, `await ctx.subagents.start(provider, …)`;
1661
+ * 7. on start failure, roll the intent back via
1662
+ * {@link unstageDelegationIntent} (no-op once consumed);
1663
+ * 8. on success, track the run under `childSessionId` for {@link cancel}
1664
+ * until `run.result` settles.
1665
+ *
1666
+ * **Reattach recipe** (callers replicating this flow — e.g. room — copy
1667
+ * exactly this sequence):
1668
+ *
1669
+ * ```ts
1670
+ * const handle = await ctx.sessionPersistence.open(SessionId(childSessionId), 'write')
1671
+ * try {
1672
+ * const cold = await handle.read(0)
1673
+ * const session = ctx.sessions.prepare(SessionId(childSessionId), {
1674
+ * seed: [...cold.events],
1675
+ * meta: structuredClone(handle.header),
1676
+ * inheritedEventCount: handle.inheritedEventCount,
1677
+ * eventState: cold.eventState,
1678
+ * })
1679
+ * // The seeded constructor's session/end-seed marker lands before enter()
1680
+ * // installs publication hooks — push the unstored suffix through the
1681
+ * // handle directly, or the writer's contiguous cursor diverges.
1682
+ * const unstored = session.snapshotEvents().slice(cold.events.length)
1683
+ * if (unstored.length > 0) await handle.append(unstored)
1684
+ * const detach = ctx.sessions.enter(session)
1685
+ * try { ctx.sessions.announce(session) }
1686
+ * catch (error) { detach(); throw error }
1687
+ * // Hold BOTH for the reattached lifetime: the backend routes live
1688
+ * // session/event appends into the per-id writer only while the write
1689
+ * // handle is open; closing it ends persistence for the session.
1690
+ * } catch (error) {
1691
+ * await handle.close().catch(() => {})
1692
+ * throw error
1693
+ * }
1694
+ * ```
1695
+ *
1696
+ * Hold `detach` and `handle` for the plugin lifetime. Announce the restored
1697
+ * live session after entering it, rolling back detach if announcement fails.
1698
+ * History followers need this lifecycle edge to replay the constructor's
1699
+ * end-seed suffix before the provider publishes subsequent events.
1700
+ * @param parentSessionId - the delegating parent session id; must match the
1701
+ * recorded one and have a live agent.
1702
+ * @param provider - the `ctx.subagents` provider that owns the CLI session.
1703
+ * @param childSessionId - the dsh child session id from the first round (the
1704
+ * resume handle).
1705
+ * @param prompt - the follow-up user message content blocks.
1706
+ * @param options - optional label and caller-owned abort signal.
1707
+ * @returns the published subagent run.
1708
+ * @throws on unknown provider, unknown/foreign-parent/wrong-provider handle,
1709
+ * an in-flight resume of the same child, or a missing live parent agent —
1710
+ * all BEFORE anything is staged.
1711
+ */
1712
+ async resume(parentSessionId, provider, childSessionId, prompt, options) {
1713
+ const subagents = this.requireSubagents();
1714
+ this.requireProvider(subagents, provider);
1715
+ const { cliSessionId } = this.resolveDelegation(childSessionId, { provider, parentSessionId });
1716
+ // The scoped home is anchored by the record: a resume naming another
1717
+ // scope (or none, when the first round named one) would continue the CLI
1718
+ // session against a different account's credentials. Checked here so the
1719
+ // facade fails before staging; the provider repeats the check at the same
1720
+ // point it checks the cwd anchor.
1721
+ assertResumeScopeUnchanged(this.delegations.get(childSessionId), options?.scope, 'localAgent');
1722
+ // A model belongs to the delegation, not to one of its rounds: the first
1723
+ // round recorded what it requested and this round re-requests exactly
1724
+ // that. Naming one here would switch a live conversation's model mid-way
1725
+ // — which the CLI would honour and the transcript would not show — so it
1726
+ // is a caller error, not a silently ignored field.
1727
+ if (options?.effort !== undefined || options?.configurationLock !== undefined) {
1728
+ throw new Error('localAgent: resume reuses the member configuration; use member controls to change effort');
1729
+ }
1730
+ if (options?.model !== undefined) {
1731
+ throw new Error(`localAgent: resume does not take a model — child session ${childSessionId} re-requests the model its first `
1732
+ + 'round recorded; start a new delegation to run another model');
1733
+ }
1734
+ if (this.isResumeLocked(childSessionId) && !this.supportsMemberConfiguration(provider)) {
1735
+ throw new Error(`localAgent: child session ${childSessionId} already has an in-flight resume`);
1736
+ }
1737
+ const parent = this.requireLiveParent(parentSessionId);
1738
+ if (options?.reattach === false) {
1739
+ // Explicit opt-out: the pre-facade behavior — fail loud on a child that
1740
+ // is not live instead of restoring it from persistence.
1741
+ if (this.ctx.get('sessions')?.get(SessionId(childSessionId)) === undefined) {
1742
+ throw new Error(`localAgent: child session ${childSessionId} is not live and reattach is disabled`);
1743
+ }
1744
+ }
1745
+ else {
1746
+ await this.reattachChildSession(childSessionId);
1747
+ }
1748
+ // The cwd override rides the staged intent (see start): the provider
1749
+ // compares the round's effective cwd against the recorded first-round one
1750
+ // and fails loud on a mismatch.
1751
+ const intent = {
1752
+ kind: 'resume',
1753
+ ...options?.onAdmitted === undefined ? {} : { onAdmitted: options.onAdmitted },
1754
+ childSessionId,
1755
+ cliSessionId,
1756
+ ...options?.cwd === undefined ? {} : { cwd: options.cwd },
1757
+ // Repeat of the first round's target: the caller owns the pairing (the
1758
+ // recorded anchor is the host cwd, which a container swap leaves equal).
1759
+ ...options?.exec === undefined ? {} : { exec: options.exec },
1760
+ // Repeat of the first round's scope — and unlike the container target,
1761
+ // this one IS anchored: the record carries the scope and the provider
1762
+ // refuses a round that names another one (or none).
1763
+ ...options?.scope === undefined ? {} : { scope: options.scope },
1764
+ };
1765
+ this.stageDelegationIntent(parentSessionId, provider, intent);
1766
+ const controller = new AbortController();
1767
+ let run;
1768
+ try {
1769
+ run = await subagents.start(provider, {
1770
+ prompt,
1771
+ parent,
1772
+ signal: fusedSignal(controller, options?.signal),
1773
+ ...options?.label === undefined ? {} : { label: options.label },
1774
+ });
1775
+ }
1776
+ catch (error) {
1777
+ this.unstageDelegationIntent(parentSessionId, provider, intent);
1778
+ throw error;
1779
+ }
1780
+ this.trackRun(childSessionId, controller, run, options?.onProgress);
1781
+ return run;
1782
+ }
1783
+ /**
1784
+ * Cancel an in-flight facade-started run by its dsh child session id: aborts
1785
+ * the internal controller whose (fused) signal was passed to
1786
+ * `ctx.subagents.start`, which is the canonical cancellation channel for a
1787
+ * one-shot run. The provider settles the run with an `aborted`-class stop
1788
+ * reason. A caller holding the `SubagentRun` may also `run.dispose()`.
1789
+ * @param childSessionId - the dsh child session id of the run to cancel.
1790
+ * @returns whether an in-flight run was found and signalled (false on a
1791
+ * miss — never a silent success, never a throw).
1792
+ */
1793
+ cancel(childSessionId) {
1794
+ const entry = this.runs.get(childSessionId);
1795
+ if (entry === undefined)
1796
+ return this.memberInbox.cancelStarting(childSessionId);
1797
+ if (entry.controller !== undefined) {
1798
+ entry.controller.abort();
1799
+ }
1800
+ else {
1801
+ // Tool-registered run: the caller-supplied cancel lever (its fused
1802
+ // controller), the same abort channel the facade entry aborts.
1803
+ entry.cancel?.();
1804
+ }
1805
+ return true;
1806
+ }
1807
+ /**
1808
+ * Register a NON-facade in-flight delegation run with the active-delegation
1809
+ * registry, keyed by the dsh child session id, so the `/local-agent stop`
1810
+ * command and {@link cancel} can reach it. This is the family TOOL's entry
1811
+ * point: the tool starts its runs through `ctx.subagents.start` directly
1812
+ * (not the facade), so without this registration its in-flight runs would
1813
+ * be invisible to the stop command. The caller owns the cancel lever — a
1814
+ * controller fused with the run's request signal, aborted exactly like the
1815
+ * facade's tracked controller. The entry clears itself when the run's
1816
+ * result settles (any stop reason); an existing facade entry for the same
1817
+ * child session wins (it already owns cancellation and progress).
1818
+ * @param childSessionId - the dsh child session id (the run id).
1819
+ * @param run - the published subagent run.
1820
+ * @param cancel - the lever that aborts the run's request signal.
1821
+ */
1822
+ trackDelegationRun(childSessionId, run, cancel) {
1823
+ if (this.runs.has(childSessionId))
1824
+ return;
1825
+ this.runs.set(childSessionId, {
1826
+ cancel,
1827
+ run,
1828
+ onProgress: undefined,
1829
+ startedAt: Date.now(),
1830
+ });
1831
+ const clear = () => {
1832
+ const entry = this.runs.get(childSessionId);
1833
+ if (entry?.run !== run)
1834
+ return;
1835
+ this.runs.delete(childSessionId);
1836
+ };
1837
+ void run.result.then(clear, clear);
1838
+ }
1839
+ /**
1840
+ * Whether the active-delegation registry holds an in-flight run for the
1841
+ * child session — the registry's read side for the `/local-agent stop`
1842
+ * command's miss report (the command cancels through {@link cancel}).
1843
+ * @param childSessionId - the dsh child session id.
1844
+ * @returns whether a run is currently in flight under that id.
1845
+ */
1846
+ isDelegationActive(childSessionId) {
1847
+ return this.runs.has(childSessionId);
1848
+ }
1849
+ /**
1850
+ * The child session ids with an in-flight delegation run — the gateway's
1851
+ * `activeDelegations` Remote exposes this to the browser so surfaces can
1852
+ * mark one-shot rows as running.
1853
+ * @returns the in-flight child session ids.
1854
+ */
1855
+ activeDelegations() {
1856
+ return [...this.runs.keys()];
1857
+ }
1858
+ /**
1859
+ * Read the kimi transcript lines already mirrored into one child session,
1860
+ * so a resumed round mirrors only its delta instead of duplicating earlier
1861
+ * messages. Absent means the first round has not mirrored yet. Reads the
1862
+ * dedicated offset map (never gated on a delegation record).
1863
+ * @param childSessionId - the dsh child session id.
1864
+ * @returns the mirrored transcript-line count, or undefined.
1865
+ */
1866
+ kimiMirroredLines(childSessionId) {
1867
+ return this.kimiMirrorOffsets.get(childSessionId);
1868
+ }
1869
+ /**
1870
+ * Advance the kimi transcript-line mirror offset for one child session.
1871
+ * The kimi provider calls this after every mirror (fresh or resumed) with
1872
+ * the new total transcript-line count. Writes the dedicated offset map so
1873
+ * the offset survives even without a delegation record; the record's own
1874
+ * field is mirrored for completeness.
1875
+ * @param childSessionId - the dsh child session id.
1876
+ * @param lines - the total transcript lines mirrored so far.
1877
+ */
1878
+ setKimiMirroredLines(childSessionId, lines) {
1879
+ this.kimiMirrorOffsets.set(childSessionId, lines);
1880
+ const record = this.delegations.get(childSessionId);
1881
+ if (record !== undefined) {
1882
+ const updated = { ...record, kimiMirroredLines: lines };
1883
+ this.delegations.set(childSessionId, updated);
1884
+ // Keep the persisted record's offset current so a post-restart resume
1885
+ // mirrors only its delta instead of re-mirroring earlier rounds.
1886
+ this.persistDelegation(updated);
1887
+ }
1888
+ }
1889
+ /** Resolve the subagents service or fail loud naming the missing service. */
1890
+ requireSubagents() {
1891
+ const subagents = this.ctx.get('subagents');
1892
+ if (subagents === undefined) {
1893
+ throw new Error('localAgent: delegation requires the subagents service, which is not mounted (install a subagent provider plugin)');
1894
+ }
1895
+ return subagents;
1896
+ }
1897
+ /** Fail loud on an unknown provider BEFORE any intent is staged. */
1898
+ requireProvider(subagents, provider) {
1899
+ if (subagents.getProvider(provider) === undefined) {
1900
+ throw new Error(`localAgent: subagent provider ${JSON.stringify(provider)} is not registered`);
1901
+ }
1902
+ }
1903
+ /** Resolve the live parent Agent or fail loud (harness hard constraint). */
1904
+ requireLiveParent(parentSessionId) {
1905
+ const agents = this.ctx.get('agents');
1906
+ if (agents === undefined) {
1907
+ throw new Error('localAgent: delegation requires the agents service, which is not mounted');
1908
+ }
1909
+ const parent = agents.get(SessionId(parentSessionId));
1910
+ if (parent === undefined) {
1911
+ throw new Error(`localAgent: parent session ${parentSessionId} has no live agent; delegation requires a live parent agent`);
1912
+ }
1913
+ return parent;
1914
+ }
1915
+ /**
1916
+ * Ensure a delegated child session is live in this process, reattaching it
1917
+ * from persistence when it is not — a host restart evicts every child from
1918
+ * the live store. This is the public entry of the facade resume's reattach
1919
+ * recipe for callers that start resume rounds WITHOUT the facade (the
1920
+ * family subagent tool starts runs directly through `ctx.subagents.start`;
1921
+ * without this call the provider refuses the resume as "not live").
1922
+ * Idempotent; a child that was never persisted fails through the
1923
+ * persistence layer's own not-found error.
1924
+ * @param childSessionId - the dsh child session id (the resume handle).
1925
+ */
1926
+ async ensureChildLive(childSessionId) {
1927
+ await this.reattachChildSession(childSessionId);
1928
+ }
1929
+ /**
1930
+ * Restore a persisted child session into the live store when it is absent —
1931
+ * the reattach recipe documented on {@link resume}. Announce the restored
1932
+ * lifecycle so open history followers receive the constructor suffix before
1933
+ * subsequent appends. Hold detach in {@link reattachDisposers} until plugin
1934
+ * dispose; the write handle comes from the shared
1935
+ * {@link childWriteHandles} cache so a later {@link syncChildSession}
1936
+ * reuses it instead of failing SessionAlreadyOwnedError on a second open.
1937
+ */
1938
+ async reattachChildSession(childSessionId) {
1939
+ const sessions = this.ctx.get('sessions');
1940
+ if (sessions === undefined) {
1941
+ throw new Error('localAgent: delegation requires the sessions service, which is not mounted');
1942
+ }
1943
+ if (sessions.get(SessionId(childSessionId)) !== undefined)
1944
+ return;
1945
+ if (this.reattachDisposers.has(childSessionId))
1946
+ return;
1947
+ if (this.ctx.get('sessionPersistence') === undefined) {
1948
+ throw new Error(`localAgent: child session ${childSessionId} is not live and the sessionPersistence service is not mounted to reattach it`);
1949
+ }
1950
+ // Host 0.1.5 handle-based persistence: the backend routes live
1951
+ // session/event appends into the per-id writer only while a WRITE handle
1952
+ // is open, so the reattach claims the write handle first and the cache
1953
+ // holds it for the child's lifetime (closed on dispose).
1954
+ const handle = await this.acquireChildWriteHandle(childSessionId);
1955
+ try {
1956
+ const cold = await handle.read(0);
1957
+ const session = sessions.prepare(SessionId(childSessionId), {
1958
+ seed: [...cold.events],
1959
+ meta: structuredClone(handle.header),
1960
+ inheritedEventCount: handle.inheritedEventCount,
1961
+ eventState: cold.eventState,
1962
+ });
1963
+ // The seeded constructor appends its `session/end-seed` marker BEFORE
1964
+ // enter() installs the publication hooks, so live routing never sees it
1965
+ // — push the unstored suffix through the handle directly (the agent
1966
+ // loop's resume does the same via appendUnstoredSuffix), or the writer's
1967
+ // contiguous cursor and the session log diverge.
1968
+ const unstored = session.snapshotEvents().slice(cold.events.length);
1969
+ if (unstored.length > 0)
1970
+ await handle.append(unstored);
1971
+ const detach = sessions.enter(session);
1972
+ try {
1973
+ sessions.announce(session);
1974
+ }
1975
+ catch (error) {
1976
+ detach();
1977
+ throw error;
1978
+ }
1979
+ this.reattachDisposers.set(childSessionId, detach);
1980
+ }
1981
+ catch (error) {
1982
+ // A failed reattach keeps no ownership: the cached handle would hold
1983
+ // write ownership of a session that never went live.
1984
+ await this.releaseChildWriteHandle(childSessionId);
1985
+ throw error;
1986
+ }
1987
+ }
1988
+ /**
1989
+ * Sync a live delegated child session's events into durable storage through
1990
+ * the registry-cached write handle, creating the stored session on first
1991
+ * sync. The host's live write-behind routes `session/event` appends into
1992
+ * the per-id writer only while a write handle is open AND a checkpoint
1993
+ * drains it — a delegated mirror session has no agent loop, so nothing ever
1994
+ * checkpoints it and production child logs stayed header-only. This explicit
1995
+ * suffix sync is the mirror's durability path: read the stored prefix,
1996
+ * append only what is missing (idempotent under repetition and under a
1997
+ * working write-behind), then flush. A failure downgrades to a warn and the
1998
+ * handle stays cached for the next sync — a persistence hiccup must never
1999
+ * fail the delegation round.
2000
+ * @param session - the live child session to sync.
2001
+ */
2002
+ async syncChildSession(session, strict = false) {
2003
+ try {
2004
+ const handle = await this.acquireChildWriteHandle(String(session.id), session.header);
2005
+ const stored = await handle.read(0);
2006
+ const suffix = session.snapshotEvents().slice(stored.events.length);
2007
+ if (suffix.length > 0)
2008
+ await handle.append(suffix);
2009
+ await handle.flush();
2010
+ }
2011
+ catch (error) {
2012
+ if (strict)
2013
+ throw error;
2014
+ this.ctx.logger.warn(`localAgent: syncing child session ${String(session.id)} to persistence failed: ${error instanceof Error ? error.message : String(error)}`);
2015
+ }
2016
+ }
2017
+ /**
2018
+ * Get or open the child session's registry-owned WRITE handle (the
2019
+ * {@link childWriteHandles} cache). When the caller carries the session
2020
+ * header (a sync), a missing stored session is created from it; a create
2021
+ * that loses a stat/create race adopts the winner's stored session. A
2022
+ * failed acquisition is evicted so the next call retries from scratch.
2023
+ */
2024
+ acquireChildWriteHandle(childSessionId, header) {
2025
+ const cached = this.childWriteHandles.get(childSessionId);
2026
+ if (cached !== undefined)
2027
+ return cached;
2028
+ const persistence = this.ctx.get('sessionPersistence');
2029
+ if (persistence === undefined) {
2030
+ throw new Error('localAgent: the sessionPersistence service is not mounted');
2031
+ }
2032
+ const id = SessionId(childSessionId);
2033
+ const acquired = (async () => {
2034
+ if (header !== undefined && (await persistence.stat(id)) === undefined) {
2035
+ try {
2036
+ return await persistence.create(header);
2037
+ }
2038
+ catch (error) {
2039
+ // Another writer stored the session between the stat and the
2040
+ // create: adopt it instead of failing the sync.
2041
+ if (error.name !== 'SessionAlreadyExistsError')
2042
+ throw error;
2043
+ return persistence.open(id, 'write');
2044
+ }
2045
+ }
2046
+ return persistence.open(id, 'write');
2047
+ })();
2048
+ this.childWriteHandles.set(childSessionId, acquired);
2049
+ acquired.catch(() => {
2050
+ if (this.childWriteHandles.get(childSessionId) === acquired) {
2051
+ this.childWriteHandles.delete(childSessionId);
2052
+ }
2053
+ });
2054
+ return acquired;
2055
+ }
2056
+ /**
2057
+ * Evict one child session's cached write handle and close it. Used when an
2058
+ * owner gives the session up (a failed reattach); normal lifetimes close
2059
+ * with the cache on plugin dispose.
2060
+ */
2061
+ async releaseChildWriteHandle(childSessionId) {
2062
+ const cached = this.childWriteHandles.get(childSessionId);
2063
+ if (cached === undefined)
2064
+ return;
2065
+ this.childWriteHandles.delete(childSessionId);
2066
+ const handle = await cached.catch(() => undefined);
2067
+ await handle?.close().catch((error) => {
2068
+ this.ctx.logger.warn(`localAgent: closing the child session ${childSessionId} write handle failed: ${String(error)}`);
2069
+ });
2070
+ }
2071
+ /**
2072
+ * Track one facade-started run under its child session id for
2073
+ * {@link cancel} and progress routing; the entry (and its heartbeat timer)
2074
+ * clears itself when the run's result settles (any stop reason, resolved or
2075
+ * rejected). While tracked, the facade emits a `heartbeat` progress every
2076
+ * {@link RUN_PROGRESS_HEARTBEAT_MS}; the timer is unref'd so it never keeps
2077
+ * the process alive.
2078
+ */
2079
+ trackRun(childSessionId, controller, run, onProgress) {
2080
+ const startedAt = Date.now();
2081
+ const heartbeat = setInterval(() => {
2082
+ this.reportRunProgress(childSessionId, { kind: 'heartbeat', elapsedMs: Date.now() - startedAt });
2083
+ }, RUN_PROGRESS_HEARTBEAT_MS);
2084
+ heartbeat.unref();
2085
+ // A resume round reuses the child session id, so a previous round's parked
2086
+ // route (whose settle report never arrived) must not outlive it.
2087
+ this.dropSettledRun(childSessionId);
2088
+ this.runs.set(childSessionId, { controller, run, onProgress, startedAt, heartbeat });
2089
+ const clear = () => {
2090
+ const entry = this.runs.get(childSessionId);
2091
+ if (entry?.run !== run)
2092
+ return;
2093
+ clearInterval(entry.heartbeat);
2094
+ this.runs.delete(childSessionId);
2095
+ // The provider computes this round's settle observation AFTER the result
2096
+ // resolves (the CLI's stream is only complete once the process is
2097
+ // reaped), so the route stays open for it — bounded, and closed by the
2098
+ // report itself. See RUN_PROGRESS_SETTLE_GRACE_MS.
2099
+ if (onProgress === undefined)
2100
+ return;
2101
+ const timer = setTimeout(() => { this.dropSettledRun(childSessionId); }, RUN_PROGRESS_SETTLE_GRACE_MS);
2102
+ timer.unref();
2103
+ this.settledRuns.set(childSessionId, { onProgress, timer });
2104
+ };
2105
+ void run.result.then(clear, clear);
2106
+ }
2107
+ /** Close one parked progress route, if any. */
2108
+ dropSettledRun(childSessionId) {
2109
+ const parked = this.settledRuns.get(childSessionId);
2110
+ if (parked === undefined)
2111
+ return;
2112
+ clearTimeout(parked.timer);
2113
+ this.settledRuns.delete(childSessionId);
2114
+ }
2115
+ /**
2116
+ * The provider-facing progress reporting channel: a provider reports a
2117
+ * run's data-bearing progress (mirror counts now, live deltas with M3), and
2118
+ * the registry forwards it — as the `localAgent/run-progress` cordis event,
2119
+ * and to the `onProgress` callback when the child session has a
2120
+ * facade-tracked run. Reports for untracked child sessions still emit the
2121
+ * event (a provider-only run must stay observable) and invoke no callback.
2122
+ * The facade's own heartbeat rides the same path.
2123
+ * @param childSessionId - the dsh child session id of the running delegation.
2124
+ * @param progress - the progress payload.
2125
+ */
2126
+ reportRunProgress(childSessionId, progress) {
2127
+ this.ctx.emit('localAgent/run-progress', childSessionId, progress);
2128
+ const tracked = this.runs.get(childSessionId);
2129
+ if (tracked !== undefined) {
2130
+ tracked.onProgress?.(progress);
2131
+ return;
2132
+ }
2133
+ // The run's result already settled; its call's route is parked for the
2134
+ // provider's late settle observation and closes on delivery.
2135
+ const parked = this.settledRuns.get(childSessionId);
2136
+ if (parked === undefined)
2137
+ return;
2138
+ parked.onProgress(progress);
2139
+ if (progress.kind === 'settled')
2140
+ this.dropSettledRun(childSessionId);
2141
+ }
2142
+ /**
2143
+ * Resolve one harness or fail loud. */
2144
+ requireHarness(name) {
2145
+ const harness = this.harnesses.get(name);
2146
+ if (harness === undefined)
2147
+ throw new Error(`localAgent: unknown harness ${name}`);
2148
+ return harness;
2149
+ }
2150
+ /**
2151
+ * Append one delegation record to the owning harness's `delegations.jsonl`.
2152
+ * Durability is synchronous (recording happens once per fresh-round settle)
2153
+ * and best-effort: a provider with no claiming harness or a filesystem
2154
+ * failure keeps the in-memory record and warns — recording must never break
2155
+ * a settling run.
2156
+ */
2157
+ persistDelegation(record) {
2158
+ const harness = [...this.harnesses.values()]
2159
+ .find(candidate => candidate.delegationProvider === record.provider);
2160
+ if (harness === undefined) {
2161
+ this.ctx.logger.warn(`localAgent: no registered harness claims provider ${JSON.stringify(record.provider)}; the delegation for child session ${record.childSessionId} stays in memory only`);
2162
+ return;
2163
+ }
2164
+ try {
2165
+ // The log belongs to the DIRECTORY the delegation ran in: a scoped
2166
+ // round's mapping lands in that scope's own `delegations.jsonl`, so a
2167
+ // scope carries its whole state (credentials, session records, resume
2168
+ // mappings) and stays readable on its own after a host restart.
2169
+ appendFileSync(join(this.homeDir(harness.name, record.scope), DELEGATIONS_FILENAME), `${JSON.stringify(record)}\n`);
2170
+ }
2171
+ catch (error) {
2172
+ this.ctx.logger.warn(`localAgent: failed to persist the delegation for child session ${record.childSessionId}: ${error instanceof Error ? error.message : String(error)}`);
2173
+ }
2174
+ }
2175
+ /**
2176
+ * Load one scoped home's persisted delegation mappings into the in-memory
2177
+ * maps. The default scope runs synchronously inside {@link register} before
2178
+ * `localAgent/harness-added` fires — unchanged; a named scope's file is read
2179
+ * when that scope's directory is materialized ({@link homeDir}). Malformed
2180
+ * or foreign-provider lines skip with a warn; the last line per child
2181
+ * session wins (matching {@link recordDelegation}'s replace semantics), and
2182
+ * a record's `kimiMirroredLines` restores the mirror offset.
2183
+ * @param harness - the harness whose log is read.
2184
+ * @param scope - the scope whose directory holds the log; absent means the
2185
+ * default scope.
2186
+ */
2187
+ loadDelegations(harness, scope) {
2188
+ const dirName = scopedHomeName(harness.name, scope);
2189
+ let text;
2190
+ try {
2191
+ text = readFileSync(join(this.homesRoot, dirName, DELEGATIONS_FILENAME), 'utf8');
2192
+ }
2193
+ catch {
2194
+ // No mappings persisted yet.
2195
+ return;
2196
+ }
2197
+ for (const line of text.split('\n')) {
2198
+ if (line.trim() === '')
2199
+ continue;
2200
+ const parsed = parseDelegationLine(line, harness.delegationProvider);
2201
+ if (parsed === undefined) {
2202
+ this.ctx.logger.warn(`localAgent: skipping a malformed or foreign line in ${dirName}/${DELEGATIONS_FILENAME}`);
2203
+ continue;
2204
+ }
2205
+ // A file inside a named scope's directory describes that scope, so a
2206
+ // line that predates the field is restored as belonging to the
2207
+ // directory it was found in rather than as a default-scope record.
2208
+ const record = parsed.scope === undefined && scope !== undefined ? { ...parsed, scope } : parsed;
2209
+ this.delegations.set(record.childSessionId, record);
2210
+ this.noteObservedModel(record);
2211
+ if (record.kimiMirroredLines !== undefined) {
2212
+ this.kimiMirrorOffsets.set(record.childSessionId, record.kimiMirroredLines);
2213
+ }
2214
+ }
2215
+ }
2216
+ /**
2217
+ * Dispatch `/login`, `/status`, `/sessions` and `/logout` for one harness,
2218
+ * in the default scope or — with `--scope <name>` — in a named one. The
2219
+ * flag is parsed off the input before the verb is matched, so every family
2220
+ * verb takes it and an input without it is dispatched exactly as before.
2221
+ * A harness's own extra subcommands receive the RAW input, flag included:
2222
+ * the family does not rewrite a subcommand it does not own.
2223
+ */
2224
+ handle(invocation, harness) {
2225
+ const raw = invocation.rawInput.trim();
2226
+ const parsed = parseScopeFlag(raw);
2227
+ if (parsed.error !== undefined)
2228
+ return Promise.resolve({ kind: 'error', text: `/${harness.name}: ${parsed.error}` });
2229
+ const input = parsed.input;
2230
+ const scope = parsed.scope;
2231
+ if (input === 'login') {
2232
+ // A login changes which account the scoped home holds, so whatever
2233
+ // rounds observed about the previous credential stops describing it.
2234
+ this.forgetCredentialObservations(harness.name, scope);
2235
+ return this.login(harness, scope);
2236
+ }
2237
+ if (input.startsWith('code '))
2238
+ return Promise.resolve(this.submitLoginCode(harness, input.slice('code '.length).trim(), scope));
2239
+ if (input === 'status') {
2240
+ return this.statusOf(harness.name, scope).then(status => ({ kind: 'success', text: renderStatus(status) }));
2241
+ }
2242
+ if (input === 'sessions' || input === '') {
2243
+ return this.sessionsOf(harness.name, scope)
2244
+ .then(records => ({ kind: 'success', text: renderSessions(harness, records) }));
2245
+ }
2246
+ if (input === 'logout') {
2247
+ if (harness.logout === undefined) {
2248
+ return Promise.resolve({
2249
+ kind: 'error',
2250
+ text: `${harness.name} has no logout path; delete ${this.homeDir(harness.name, scope)} to sign out.`,
2251
+ });
2252
+ }
2253
+ return harness.logout(this.homeDir(harness.name, scope))
2254
+ .then(() => {
2255
+ this.forgetCredentialObservations(harness.name, scope);
2256
+ return { kind: 'success', text: `${harness.displayName} signed out of the scoped home; log in again to switch accounts.` };
2257
+ });
2258
+ }
2259
+ if (harness.subcommand !== undefined) {
2260
+ const extra = harness.subcommand(raw, invocation);
2261
+ if (extra !== undefined)
2262
+ return Promise.resolve(extra);
2263
+ }
2264
+ return Promise.resolve({
2265
+ kind: 'error',
2266
+ text: `Unknown /${harness.name} subcommand; use /${harness.name} login, /${harness.name} status, /${harness.name} sessions, or /${harness.name} logout.`,
2267
+ });
2268
+ }
2269
+ /**
2270
+ * Run one login flow. The device-code variant spawns the CLI in the scoped
2271
+ * home and captures its prompt from stderr/stdout; the reply surfaces that
2272
+ * prompt immediately while the child keeps polling in the background. The
2273
+ * manual variant spawns nothing — the reply carries the terminal
2274
+ * instructions and a watch polls the credential probe. A second login while
2275
+ * one is pending replaces it — a stale or abandoned attempt (browser never
2276
+ * opened, user gave up) must not hold the slot forever, and the user's
2277
+ * retry should just get a fresh start. An abandoned device-code login with
2278
+ * no retry self-heals when the CLI's own device code expires and its
2279
+ * polling child exits (kimi/codex do); no idle timer is added because a
2280
+ * too-short one would kill a user who is genuinely authorizing (the manual
2281
+ * watch is bounded instead). A harness without a login flow answers with an
2282
+ * error instead of spawning anything.
2283
+ * Each scope logs in on its own: credentials are never copied between
2284
+ * scopes, so `--scope <name>` authorizes THAT directory's account and the
2285
+ * pending-login slot is per (harness, scope) too — a login in one scope
2286
+ * neither replaces nor observes another's.
2287
+ * @param harness - the harness whose login flow runs.
2288
+ * @param scope - the scope to log in; absent means the default one.
2289
+ * @returns the command result: the device-code prompt, or the manual
2290
+ * handoff's terminal instructions.
2291
+ */
2292
+ login(harness, scope) {
2293
+ const login = harness.login;
2294
+ if (login === undefined) {
2295
+ return Promise.resolve({
2296
+ kind: 'error',
2297
+ text: `${harness.name} has no device-code login; it authenticates through the host instance's credentials.`,
2298
+ });
2299
+ }
2300
+ const loginKey = scopedHomeName(harness.name, scope);
2301
+ const existing = this.logins.get(loginKey);
2302
+ if (existing !== undefined) {
2303
+ // A pending login is replaced, not refused: stop its watch / terminate
2304
+ // its child so the new login gets a clean slot. The old controller's
2305
+ // done signal already cleared the map entry or will (its settle path
2306
+ // deletes only when the map still holds THAT controller, which the new
2307
+ // set() below makes false).
2308
+ existing.stop?.();
2309
+ if (existing.terminal !== undefined)
2310
+ void existing.terminal.terminate();
2311
+ const stale = existing.child;
2312
+ if (stale !== undefined) {
2313
+ stale.kill();
2314
+ // SIGTERM first, then SIGKILL after a grace period: a CLI that ignores
2315
+ // SIGTERM must not leave a zombie polling process behind (the slot is
2316
+ // already replaced, so this is process hygiene, not a functional lock).
2317
+ const hardKill = setTimeout(() => {
2318
+ if (stale.exitCode === null && stale.signalCode === null)
2319
+ stale.kill('SIGKILL');
2320
+ }, REPLACE_LOGIN_GRACE_MS);
2321
+ stale.once('exit', () => { clearTimeout(hardKill); });
2322
+ }
2323
+ }
2324
+ // The manual handoff spawns nothing: reply with the terminal instructions
2325
+ // and watch for the credential.
2326
+ if ('manual' in login)
2327
+ return this.manualLogin(harness, login, scope);
2328
+ if ('pty' in login)
2329
+ return this.ptyLogin(harness, login, scope);
2330
+ const controller = { done: Promise.resolve() };
2331
+ this.logins.set(loginKey, controller);
2332
+ const resultPromise = this.runLogin(harness, login, controller, scope);
2333
+ // The settle signal is the real one only after runLogin populated it;
2334
+ // chaining earlier would clear the guard on the placeholder promise.
2335
+ void controller.done.then(() => {
2336
+ if (this.logins.get(loginKey) === controller)
2337
+ this.logins.delete(loginKey);
2338
+ });
2339
+ return resultPromise;
2340
+ }
2341
+ /**
2342
+ * The shared credential watch behind the manual and pty login variants:
2343
+ * polls the probe until the credential lands (presence AND, when the
2344
+ * harness exposes a credential stamp, a stamp rewritten after the watch
2345
+ * started — a leftover marker must not conclude a login), the window
2346
+ * expires, or a new login replaces the watch. Outcomes are logged; the
2347
+ * surfaces' own status polling picks the landed credential up.
2348
+ * @param harness - the harness being logged in.
2349
+ * @param probe - the credential probe.
2350
+ * @param controller - the pending login's controller (done resolves here).
2351
+ * @param scope - the scope being logged in; absent means the default one.
2352
+ */
2353
+ watchCredential(harness, probe, controller, scope) {
2354
+ const homeDir = this.homeDir(harness.name, scope);
2355
+ const deadline = Date.now() + MANUAL_LOGIN_LIMIT_MS;
2356
+ const watchStart = Date.now();
2357
+ let stopped = false;
2358
+ let timer;
2359
+ controller.done = new Promise((resolve) => {
2360
+ const finish = (message, level) => {
2361
+ if (stopped)
2362
+ return;
2363
+ stopped = true;
2364
+ if (timer !== undefined)
2365
+ clearInterval(timer);
2366
+ this.ctx.logger[level](message);
2367
+ resolve();
2368
+ };
2369
+ controller.stop = () => { finish(`${harness.name} login watch replaced`, 'info'); };
2370
+ const tick = async () => {
2371
+ if (stopped)
2372
+ return;
2373
+ try {
2374
+ if (await probe(homeDir)) {
2375
+ // Presence alone must not conclude a login: a stale marker (e.g.
2376
+ // a revoked token's leftover record) would report success without
2377
+ // any new login. When the harness exposes a credential stamp,
2378
+ // require it to be rewritten after this watch started.
2379
+ if (harness.credentialStamp !== undefined) {
2380
+ const stamp = await harness.credentialStamp(homeDir).catch(() => undefined);
2381
+ if (stamp === undefined || stamp <= watchStart)
2382
+ return;
2383
+ }
2384
+ finish(`${harness.name} login detected in the scoped home`, 'info');
2385
+ return;
2386
+ }
2387
+ }
2388
+ catch {
2389
+ // A probe failure is a transient read; keep watching.
2390
+ }
2391
+ if (Date.now() >= deadline) {
2392
+ finish(`${harness.name} login watch expired after ${Math.round(MANUAL_LOGIN_LIMIT_MS / 1000)}s with no credential; run /${harness.name} login again to retry`, 'warn');
2393
+ }
2394
+ };
2395
+ timer = setInterval(() => { void tick(); }, MANUAL_LOGIN_POLL_MS);
2396
+ timer.unref();
2397
+ });
2398
+ }
2399
+ /**
2400
+ * The manual-handoff login: the reply carries the exact command the user
2401
+ * runs in their own terminal, and a bounded watch polls the credential probe
2402
+ * (default: the harness's `isAuthenticated`) until the credential lands, the
2403
+ * window expires, or a new login replaces the watch. The reply cannot carry
2404
+ * the outcome — the command channel has already returned — so success and
2405
+ * timeout are logged, and the surfaces' own status polling (the settings
2406
+ * section re-probes every few seconds) picks the landed credential up.
2407
+ * @param harness - the harness declaring the manual flow.
2408
+ * @param login - the manual variant declaration.
2409
+ * @param scope - the scope being logged in; absent means the default one.
2410
+ * @returns the instructions as the command success text.
2411
+ */
2412
+ manualLogin(harness, login, scope) {
2413
+ const loginKey = scopedHomeName(harness.name, scope);
2414
+ const controller = { done: Promise.resolve() };
2415
+ this.logins.set(loginKey, controller);
2416
+ const probe = login.watch ?? harness.isAuthenticated;
2417
+ if (probe !== undefined)
2418
+ this.watchCredential(harness, probe, controller, scope);
2419
+ void controller.done.then(() => {
2420
+ if (this.logins.get(loginKey) === controller)
2421
+ this.logins.delete(loginKey);
2422
+ });
2423
+ return Promise.resolve({
2424
+ kind: 'success',
2425
+ text: `${harness.displayName} CLI login needs an interactive terminal. Run this in your own terminal:\n\n ${login.manual.commandDisplay}\n\nWatching the scoped home for the credential for up to ${Math.round(MANUAL_LOGIN_LIMIT_MS / 60_000)} minutes; this surface picks the login up automatically.`,
2426
+ });
2427
+ }
2428
+ /**
2429
+ * The pty login: spawn the CLI under a pseudo-terminal wrapper so its
2430
+ * TTY-only auth flow runs — current claude auto-opens the user's browser
2431
+ * itself. The OAuth URL is captured from the merged output as a fallback,
2432
+ * and when the CLI prompts for a code the user pastes it through
2433
+ * `/<name> code <value>` (written to the child's stdin). Completion is the
2434
+ * shared credential watch. Windows has no script(1) wrapper; there the
2435
+ * reply falls back to the manual instruction.
2436
+ * @param harness - the harness declaring the pty flow.
2437
+ * @param login - the pty variant declaration.
2438
+ * @param scope - the scope being logged in; absent means the default one.
2439
+ * The CLI runs with its scoped-home variable pointed at THAT directory,
2440
+ * so the grant it writes lands in the scope that asked for it.
2441
+ * @returns the browser/paste instructions as the command success text.
2442
+ */
2443
+ async ptyLogin(harness, login, scope) {
2444
+ const homeDir = this.homeDir(harness.name, scope);
2445
+ const loginKey = scopedHomeName(harness.name, scope);
2446
+ // The argv may depend on the directory being logged in (see the pty
2447
+ // variant's `args`), so it is resolved against THIS scope's home.
2448
+ const args = typeof login.pty.args === 'function' ? login.pty.args(homeDir) : login.pty.args;
2449
+ const displayCommand = [login.pty.command, ...args].join(' ');
2450
+ const subprocess = this.ctx.get('subprocess');
2451
+ if (subprocess === undefined) {
2452
+ // The seam is absent in this composition: degrade to the manual handoff.
2453
+ const controller = { done: Promise.resolve() };
2454
+ this.logins.set(loginKey, controller);
2455
+ const probe = login.watch ?? harness.isAuthenticated;
2456
+ if (probe !== undefined)
2457
+ this.watchCredential(harness, probe, controller, scope);
2458
+ void controller.done.then(() => {
2459
+ if (this.logins.get(loginKey) === controller)
2460
+ this.logins.delete(loginKey);
2461
+ });
2462
+ return Promise.resolve({
2463
+ kind: 'success',
2464
+ text: `${harness.displayName} CLI login needs an interactive terminal. Run this in your own terminal:\n\n ${displayCommand}\n\nWatching the scoped home for the credential; this surface picks the login up automatically.`,
2465
+ });
2466
+ }
2467
+ const controller = { done: Promise.resolve(), awaitingCode: true };
2468
+ const terminal = await subprocess.spawnTerminal({
2469
+ argv: [login.pty.command, ...args],
2470
+ cwd: homeDir,
2471
+ env: { [harness.homeEnvVar]: homeDir },
2472
+ terminalType: 'xterm-256color',
2473
+ rows: 24,
2474
+ cols: 80,
2475
+ graceMs: REPLACE_LOGIN_GRACE_MS,
2476
+ });
2477
+ controller.terminal = terminal;
2478
+ this.logins.set(loginKey, controller);
2479
+ controller.writeCode = (code) => {
2480
+ void terminal.write(`${code}\r`);
2481
+ controller.awaitingCode = false;
2482
+ };
2483
+ let url;
2484
+ const urlCaptured = new Promise((resolve) => {
2485
+ const promptTimeout = setTimeout(() => { resolve(); }, this.loginPromptTimeoutMs);
2486
+ terminal.output.on('data', (chunk) => {
2487
+ if (url === undefined) {
2488
+ const match = /https?:\/\/[^\s\]]+/.exec(stripAnsi(chunk.toString()));
2489
+ if (match !== null) {
2490
+ url = match[0];
2491
+ clearTimeout(promptTimeout);
2492
+ resolve();
2493
+ }
2494
+ }
2495
+ });
2496
+ void terminal.done.then(() => { clearTimeout(promptTimeout); resolve(); }, () => { clearTimeout(promptTimeout); resolve(); });
2497
+ });
2498
+ controller.done = new Promise((resolve) => {
2499
+ void terminal.done.then(() => { resolve(); }, () => { resolve(); });
2500
+ });
2501
+ const probe = login.watch ?? harness.isAuthenticated;
2502
+ if (probe !== undefined)
2503
+ this.watchCredential(harness, probe, controller, scope);
2504
+ void controller.done.then(() => {
2505
+ if (this.logins.get(loginKey) === controller)
2506
+ this.logins.delete(loginKey);
2507
+ });
2508
+ // Give the CLI a bounded moment to print its OAuth URL so the reply can
2509
+ // carry the fallback link; the browser opens on its own either way.
2510
+ return urlCaptured.then(() => ({
2511
+ kind: 'success',
2512
+ text: `${harness.displayName} 授权页应已在浏览器中打开。`
2513
+ + (url !== undefined ? `\n\n若浏览器未打开,请访问:\n ${url}` : '')
2514
+ + `\n\n授权完成后若页面给出一个 code,请粘贴回来:\n /${harness.name} code <你的code>`,
2515
+ }));
2516
+ }
2517
+ /**
2518
+ * Deliver the user's pasted OAuth code to a pending pty login's stdin.
2519
+ * @param harness - the harness whose login is pending.
2520
+ * @param code - the pasted code.
2521
+ * @param scope - the scope whose login is pending; absent means the default
2522
+ * one. The pending-login slot is per scope, so the code reaches the login
2523
+ * that asked for it.
2524
+ * @returns the command result.
2525
+ */
2526
+ submitLoginCode(harness, code, scope) {
2527
+ const controller = this.logins.get(scopedHomeName(harness.name, scope));
2528
+ if (controller?.writeCode === undefined || code === '') {
2529
+ return {
2530
+ kind: 'error',
2531
+ text: `${harness.name} 没有等待授权 code 的登录;先运行 /${harness.name} login。`,
2532
+ };
2533
+ }
2534
+ controller.writeCode(code);
2535
+ return { kind: 'success', text: 'code 已提交,等待授权完成…' };
2536
+ }
2537
+ /**
2538
+ * Spawn the harness login command and capture its device-code prompt. The
2539
+ * scoped-home variable points the CLI at the scope being logged in, so the
2540
+ * grant lands in that directory and nowhere else.
2541
+ */
2542
+ runLogin(harness, login, controller, scope) {
2543
+ const child = spawn(login.command, [...login.args], {
2544
+ env: { ...process.env, [harness.homeEnvVar]: this.homeDir(harness.name, scope) },
2545
+ stdio: ['ignore', 'pipe', 'pipe'],
2546
+ });
2547
+ controller.child = child;
2548
+ controller.done = new Promise((resolve) => {
2549
+ child.on('exit', () => { resolve(); });
2550
+ child.on('error', () => { resolve(); });
2551
+ });
2552
+ const capture = login.capture ?? 'stderr';
2553
+ let prompt = '';
2554
+ let answered = false;
2555
+ return new Promise((resolve) => {
2556
+ const probe = capture === 'stderr' ? child.stderr : child.stdout;
2557
+ if (capture === 'stdout') {
2558
+ // Drain stderr so a stdout-captured CLI cannot stall on a full pipe.
2559
+ child.stderr.resume();
2560
+ }
2561
+ probe.on('data', (chunk) => {
2562
+ // ANSI escapes ride CLI output even when piped (codex colorizes the
2563
+ // auth URL); strip before the URL check and the reply.
2564
+ prompt += stripAnsi(chunk.toString());
2565
+ // Reply fast only once a URL is in hand — a fast print-and-exit CLI
2566
+ // (kimi's already-logged-in 'Logged in to …') never prints one and
2567
+ // must fall through to the close handler instead of showing a bogus
2568
+ // 'complete the flow in the browser' wrapper.
2569
+ if (!answered && /https?:\/\//.test(prompt)) {
2570
+ answered = true;
2571
+ resolve({
2572
+ kind: 'success',
2573
+ text: `Device login started in the scoped home.\n${prompt}\nComplete the flow in the browser; the CLI keeps polling in the background.`,
2574
+ });
2575
+ }
2576
+ });
2577
+ // 'close', not 'exit': exit can fire before the captured stream's
2578
+ // final data events are delivered, and a fast print-and-exit CLI would
2579
+ // then be misreported as prompt-less. close guarantees stdio drained.
2580
+ child.on('close', () => {
2581
+ if (answered)
2582
+ return;
2583
+ answered = true;
2584
+ if (child.exitCode === 0) {
2585
+ // The CLI finished without asking for anything — it considers the
2586
+ // harness already authenticated (kimi's 'Logged in to …').
2587
+ resolve({
2588
+ kind: 'success',
2589
+ text: prompt.trim() === '' ? `${harness.displayName} 已是登录状态。` : prompt.trim(),
2590
+ });
2591
+ return;
2592
+ }
2593
+ resolve({ kind: 'error', text: loginFailure(harness, child.exitCode, null) });
2594
+ });
2595
+ child.on('error', (error) => {
2596
+ if (answered)
2597
+ return;
2598
+ answered = true;
2599
+ resolve({ kind: 'error', text: `${harness.name} login failed to start: ${error.message}` });
2600
+ });
2601
+ // A CLI that prints nothing at all should not hold the reply hostage.
2602
+ setTimeout(() => {
2603
+ if (answered)
2604
+ return;
2605
+ answered = true;
2606
+ child.kill('SIGTERM');
2607
+ resolve({
2608
+ kind: 'error',
2609
+ text: `${harness.name} login printed no device-code prompt; is the ${harness.displayName} CLI installed and configured?`,
2610
+ });
2611
+ }, this.loginPromptTimeoutMs);
2612
+ });
2613
+ }
2614
+ }
2615
+ /** Render the harness roster for the family command. */
2616
+ function renderRoster(registry) {
2617
+ const names = registry.list();
2618
+ if (names.length === 0)
2619
+ return 'No local-agent harnesses registered.';
2620
+ return names.map((name) => {
2621
+ const harness = registry.get(name);
2622
+ return `${name}: ${harness?.displayName ?? name}`;
2623
+ }).join('\n');
2624
+ }
2625
+ /**
2626
+ * The `/local-agent stop <childSessionId>` result: fire-and-return like the
2627
+ * official `subagents.interrupt(targetSessionId)` — the cancel signal is
2628
+ * issued before the reply, and an absent target (unknown child or no
2629
+ * in-flight run) is an accepted no-op, named explicitly rather than a silent
2630
+ * success. The authority is implicit: the dispatching agent's UI initiated
2631
+ * the command, exactly as every other slash command.
2632
+ * @param registry - the active-delegation registry.
2633
+ * @param childSessionId - the dsh child session id of the delegation to stop.
2634
+ * @returns the command result.
2635
+ */
2636
+ function stopDelegation(registry, childSessionId) {
2637
+ if (registry.cancel(childSessionId)) {
2638
+ return { kind: 'success', text: `stop requested for child session ${childSessionId}` };
2639
+ }
2640
+ return {
2641
+ kind: 'success',
2642
+ text: `child session ${childSessionId} has no in-flight local-agent run to stop`,
2643
+ };
2644
+ }
2645
+ /**
2646
+ * Mount the local-agent core: provide the harness registry and the family
2647
+ * command listing the registered harnesses (the client roster channel).
2648
+ * @param ctx - plugin context carrying the command registry.
2649
+ * @param config - shared scoped-homes root and login prompt wait.
2650
+ */
2651
+ export async function apply(ctx, config) {
2652
+ // A linked development plugin can have its own installed host peers. Resolve
2653
+ // through the mounted loader too, so the running backend sees the same set.
2654
+ const loader = ctx.get('loader');
2655
+ if (loader?.import !== undefined) {
2656
+ for (const specifier of ['@deepseek-ai/dsh-session', streamCatalogSpecifier]) {
2657
+ try {
2658
+ const catalog = await loader.import(specifier);
2659
+ catalog.KNOWN_SESSION_EVENT_TYPES?.add('local-agent/stream');
2660
+ }
2661
+ catch { /* Optional source export or loader capability; root registration remains. */ }
2662
+ }
2663
+ }
2664
+ const registry = new LocalAgentRegistry(ctx, config.homesRoot, config.loginPromptTimeoutMs ?? 10_000);
2665
+ ctx.provide(LOCAL_AGENT_SERVICE, registry);
2666
+ // The read-only Remote channel the web client polls through; it depends on
2667
+ // the registry, so it mounts after the provide above. Service registration
2668
+ // follows the owning fiber, so unload unregisters it automatically.
2669
+ new LocalAgentGateway(ctx);
2670
+ // The member channel's loopback listener (bridge MCP servers call back here).
2671
+ // Degrade, don't explode: a failed bind leaves member_message failing with a
2672
+ // tool error while everything else works.
2673
+ const memberChannel = new MemberChannel(ctx, registry, registry.memberBridgeSocketPath());
2674
+ void memberChannel.start();
2675
+ ctx.effect(() => () => { void memberChannel.dispose(); });
2676
+ ctx.commands.register({
2677
+ name: 'local-agent',
2678
+ description: 'list the registered local code-agent harnesses, or stop an in-flight delegation',
2679
+ input: { hint: 'list | stop <childSessionId>' },
2680
+ handler: (invocation) => {
2681
+ const input = invocation.rawInput.trim();
2682
+ if (input === 'list' || input === '') {
2683
+ return Promise.resolve({ kind: 'success', text: renderRoster(registry) });
2684
+ }
2685
+ if (input.startsWith('stop')) {
2686
+ // /local-agent stop <childSessionId>: cancel the active delegation
2687
+ // keyed by the child session id (semantics aligned with the official
2688
+ // subagents.interrupt — fire-and-return, absent target accepted).
2689
+ const childSessionId = input.slice('stop'.length).trim();
2690
+ if (childSessionId === '') {
2691
+ return Promise.resolve({
2692
+ kind: 'error',
2693
+ text: 'usage: /local-agent stop <childSessionId>',
2694
+ });
2695
+ }
2696
+ return Promise.resolve(stopDelegation(registry, childSessionId));
2697
+ }
2698
+ return Promise.resolve({
2699
+ kind: 'error',
2700
+ text: 'Unknown /local-agent subcommand; use /local-agent list or /local-agent stop <childSessionId>.',
2701
+ });
2702
+ },
2703
+ });
2704
+ }