@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.
- package/README.en.md +181 -0
- package/README.i18n.yaml +2 -0
- package/README.md +181 -0
- package/cordis.patch.yml +21 -0
- package/lib/client.js +8832 -0
- package/lib/index.js +5281 -0
- package/lib/invariant.js +85 -0
- package/lib/member-bridge.js +298 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3137 -0
- package/lib/typert.remote-client.d.ts +67 -0
- package/lib/typert.remote-client.js +1971 -0
- package/lib/types/cli-version.d.ts +82 -0
- package/lib/types/cli-version.js +165 -0
- package/lib/types/client/AuthStatusDot.d.ts +17 -0
- package/lib/types/client/AuthStatusDot.js +9 -0
- package/lib/types/client/HarnessModelPicker.d.ts +17 -0
- package/lib/types/client/HarnessModelPicker.js +48 -0
- package/lib/types/client/LocalAgentRecordsAction.d.ts +41 -0
- package/lib/types/client/LocalAgentRecordsAction.js +74 -0
- package/lib/types/client/MemberComposer.d.ts +79 -0
- package/lib/types/client/MemberComposer.js +308 -0
- package/lib/types/client/MemberConfiguration.d.ts +9 -0
- package/lib/types/client/MemberConfiguration.js +46 -0
- package/lib/types/client/MemberInboxView.d.ts +12 -0
- package/lib/types/client/MemberInboxView.js +38 -0
- package/lib/types/client/MemberLiveNode.d.ts +14 -0
- package/lib/types/client/MemberLiveNode.js +74 -0
- package/lib/types/client/MemberPaintDiagnostics.d.ts +7 -0
- package/lib/types/client/MemberPaintDiagnostics.js +20 -0
- package/lib/types/client/ModelConfigurationFields.d.ts +15 -0
- package/lib/types/client/ModelConfigurationFields.js +23 -0
- package/lib/types/client/ProviderAuthBlock.d.ts +59 -0
- package/lib/types/client/ProviderAuthBlock.js +164 -0
- package/lib/types/client/auth-status.d.ts +28 -0
- package/lib/types/client/auth-status.js +56 -0
- package/lib/types/client/icons.d.ts +22 -0
- package/lib/types/client/icons.js +6 -0
- package/lib/types/client/index.d.ts +62 -0
- package/lib/types/client/index.js +135 -0
- package/lib/types/client/live-node.d.ts +14 -0
- package/lib/types/client/live-node.js +34 -0
- package/lib/types/client/live-output.d.ts +34 -0
- package/lib/types/client/live-output.js +94 -0
- package/lib/types/client/live-paint.d.ts +40 -0
- package/lib/types/client/live-paint.js +74 -0
- package/lib/types/client/locales.d.ts +120 -0
- package/lib/types/client/locales.js +227 -0
- package/lib/types/client/member-configuration.d.ts +41 -0
- package/lib/types/client/member-configuration.js +114 -0
- package/lib/types/client/member-dock.d.ts +94 -0
- package/lib/types/client/member-dock.js +102 -0
- package/lib/types/client/member-feed.d.ts +24 -0
- package/lib/types/client/member-feed.js +134 -0
- package/lib/types/client/request-id.d.ts +3 -0
- package/lib/types/client/request-id.js +6 -0
- package/lib/types/client/settings-scope.d.ts +74 -0
- package/lib/types/client/settings-scope.js +98 -0
- package/lib/types/client/use-configuration-menu.d.ts +10 -0
- package/lib/types/client/use-configuration-menu.js +61 -0
- package/lib/types/container.d.ts +69 -0
- package/lib/types/container.js +129 -0
- package/lib/types/env.d.ts +17 -0
- package/lib/types/env.js +89 -0
- package/lib/types/gateway.d.ts +162 -0
- package/lib/types/gateway.js +470 -0
- package/lib/types/index.d.ts +1463 -0
- package/lib/types/index.js +2704 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +105 -0
- package/lib/types/live-flush.d.ts +21 -0
- package/lib/types/live-flush.js +59 -0
- package/lib/types/live-stream.d.ts +41 -0
- package/lib/types/live-stream.js +178 -0
- package/lib/types/member-bridge.d.ts +50 -0
- package/lib/types/member-bridge.js +216 -0
- package/lib/types/member-channel.d.ts +86 -0
- package/lib/types/member-channel.js +232 -0
- package/lib/types/member-control-storage.d.ts +10 -0
- package/lib/types/member-control-storage.js +100 -0
- package/lib/types/member-control.d.ts +62 -0
- package/lib/types/member-control.js +359 -0
- package/lib/types/member-controls.d.ts +23 -0
- package/lib/types/member-controls.js +143 -0
- package/lib/types/member-feed.d.ts +4 -0
- package/lib/types/member-feed.js +45 -0
- package/lib/types/member-inbox.d.ts +30 -0
- package/lib/types/member-inbox.js +225 -0
- package/lib/types/model-directory.d.ts +43 -0
- package/lib/types/model-directory.js +192 -0
- package/lib/types/prepared-members.d.ts +15 -0
- package/lib/types/prepared-members.js +61 -0
- package/lib/types/settings-face.d.ts +61 -0
- package/lib/types/settings-face.js +61 -0
- package/lib/types/types.d.ts +959 -0
- package/lib/types/types.js +24 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- 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
|
+
}
|