@khorsheed/dsh-local-agent-kimi 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 +132 -0
- package/README.i18n.yaml +6 -0
- package/README.md +132 -0
- package/cordis.patch.yml +34 -0
- package/lib/client.js +1066 -0
- package/lib/index.js +4716 -0
- package/lib/invariant.js +31 -0
- package/lib/types/client/SettingsCard.d.ts +77 -0
- package/lib/types/client/SettingsCard.js +197 -0
- package/lib/types/client/icons.d.ts +18 -0
- package/lib/types/client/icons.js +2 -0
- package/lib/types/client/index.d.ts +42 -0
- package/lib/types/client/index.js +89 -0
- package/lib/types/client/locales.d.ts +64 -0
- package/lib/types/client/locales.js +55 -0
- package/lib/types/credential-guard.d.ts +29 -0
- package/lib/types/credential-guard.js +107 -0
- package/lib/types/index.d.ts +69 -0
- package/lib/types/index.js +270 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.js +33 -0
- package/lib/types/kimi-cli-provider.d.ts +215 -0
- package/lib/types/kimi-cli-provider.js +855 -0
- package/lib/types/live-driver.d.ts +245 -0
- package/lib/types/live-driver.js +1312 -0
- package/lib/types/live-switch.d.ts +78 -0
- package/lib/types/live-switch.js +112 -0
- package/lib/types/member-bridge-config.d.ts +43 -0
- package/lib/types/member-bridge-config.js +80 -0
- package/lib/types/model-broker.d.ts +97 -0
- package/lib/types/model-broker.js +205 -0
- package/lib/types/model-catalog.d.ts +26 -0
- package/lib/types/model-catalog.js +86 -0
- package/lib/types/model-configuration.d.ts +11 -0
- package/lib/types/model-configuration.js +55 -0
- package/lib/types/preset-tools.d.ts +20 -0
- package/lib/types/preset-tools.js +36 -0
- package/lib/types/provision.d.ts +131 -0
- package/lib/types/provision.js +363 -0
- package/lib/types/records.d.ts +35 -0
- package/lib/types/records.js +85 -0
- package/lib/types/session-configuration.d.ts +5 -0
- package/lib/types/session-configuration.js +26 -0
- package/lib/types/session-mirror.d.ts +180 -0
- package/lib/types/session-mirror.js +446 -0
- package/lib/types/session-view.d.ts +121 -0
- package/lib/types/session-view.js +423 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +107 -0
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot and resumable Kimi CLI subagent lifecycle: spawn `kimi -p "<task>"`
|
|
3
|
+
* through the subprocess seam, capture its printed response as the run output,
|
|
4
|
+
* and dispose to whole-tree quiescence. Mirrors the official one-shot Codex
|
|
5
|
+
* provider, and adds the family's own resume: a later round calls the tool with
|
|
6
|
+
* the first round's dsh child session id, the family registry maps it back to
|
|
7
|
+
* the kimi session, and the provider spawns `kimi -S session_<id> -p` so the
|
|
8
|
+
* SAME kimi conversation continues inside the SAME dsh child session.
|
|
9
|
+
* @module @khorsheed/dsh-local-agent-kimi/kimi-cli-provider
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { SessionId } from '@deepseek-ai/dsh-session';
|
|
13
|
+
import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
|
|
14
|
+
import { assertResumeCwdUnchanged, assertResumeScopeUnchanged, resolveRoundModel, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, probeCliVersion, resolveChildCwd, subagentDelegationLabel, } from '@khorsheed/dsh-local-agent';
|
|
15
|
+
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
|
|
16
|
+
import { LiveChannelUnavailableError } from './live-driver.js';
|
|
17
|
+
import { guardKimiCredential } from './credential-guard.js';
|
|
18
|
+
import { injectMemberBridge, memberBridgeServerKey, removeMemberBridge } from './member-bridge-config.js';
|
|
19
|
+
import { readKimiBaseUrl } from './provision.js';
|
|
20
|
+
import { kimiExecConfiguration } from './model-configuration.js';
|
|
21
|
+
import { addTokenUsage } from './session-view.js';
|
|
22
|
+
import { mirrorKimiSessionDelta } from './session-mirror.js';
|
|
23
|
+
/** Default POSIX grace between subprocess termination tiers. */
|
|
24
|
+
export const DEFAULT_DISPOSE_GRACE_MS = 3_000;
|
|
25
|
+
/**
|
|
26
|
+
* Auth-shaped failure signatures in kimi's stderr or output: the endpoint
|
|
27
|
+
* rejected the credential. Narrow on purpose — matched against
|
|
28
|
+
* provider-controlled error output only.
|
|
29
|
+
*/
|
|
30
|
+
export const KIMI_AUTH_FAILURE = /401|unauthorized|invalid api key|not authenticated/i;
|
|
31
|
+
/** Default interval between live transcript-mirror polls during a run. */
|
|
32
|
+
export const DEFAULT_LIVE_MIRROR_INTERVAL_MS = 2_000;
|
|
33
|
+
/**
|
|
34
|
+
* One-shot Kimi CLI subagent provider: every accepted fresh run starts a
|
|
35
|
+
* fresh `kimi -p` process in the delegating Session's workspace, under the
|
|
36
|
+
* harness scoped home; a resume round (the family tool's staged resume intent)
|
|
37
|
+
* continues the SAME kimi session with `kimi -S session_<id> -p` inside the
|
|
38
|
+
* SAME dsh child session. Mirrors the official one-shot Codex provider for the
|
|
39
|
+
* fresh path, including `NO_START_CAPABILITIES` — continuation is the family's
|
|
40
|
+
* own resume mechanism, not the official Agent-type continuable seam.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Mark the harness credential verified after a completed round, degrading
|
|
44
|
+
* silently on a core that predates the mark (the family's companion-pair
|
|
45
|
+
* rule: a provider paired with an older core loses the grade, never the run).
|
|
46
|
+
* @param ctx - host context carrying the family registry.
|
|
47
|
+
*/
|
|
48
|
+
function markCredentialVerified(ctx, scope) {
|
|
49
|
+
const registry = ctx.get('localAgent');
|
|
50
|
+
if (registry === undefined || typeof registry.reportAuthSuccess !== 'function')
|
|
51
|
+
return;
|
|
52
|
+
// The grade belongs to the scope the round ran against: each scoped home
|
|
53
|
+
// holds its own account.
|
|
54
|
+
registry.reportAuthSuccess('kimi', scope);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The version `kimi --version` reports, probed through the shared subprocess
|
|
58
|
+
* seam inside the SCOPED home (never the user's own installation) and cached
|
|
59
|
+
* by the family probe against the executable's identity. Kimi's wire log names
|
|
60
|
+
* no build, so this is the only version channel the harness has — both the
|
|
61
|
+
* effective-settings snapshot and a settled round's read-back read it.
|
|
62
|
+
* @param ctx - host context carrying the subprocess seam.
|
|
63
|
+
* @param homeDir - the harness's scoped home.
|
|
64
|
+
* @returns the version, or undefined when the CLI cannot be asked.
|
|
65
|
+
*/
|
|
66
|
+
export function kimiCliVersion(ctx, homeDir) {
|
|
67
|
+
// Degrade, don't explode: a composition without the subprocess seam simply
|
|
68
|
+
// reports no version, exactly as an unaskable CLI does.
|
|
69
|
+
const subprocess = ctx.get('subprocess');
|
|
70
|
+
if (subprocess === undefined)
|
|
71
|
+
return Promise.resolve(undefined);
|
|
72
|
+
return probeCliVersion({
|
|
73
|
+
argv: ['kimi', '--version'],
|
|
74
|
+
cwd: homeDir,
|
|
75
|
+
spawn: spec => subprocess.spawn(spec),
|
|
76
|
+
env: delegationEnv({ KIMI_CODE_HOME: homeDir }),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export class KimiCliProvider {
|
|
80
|
+
ctx;
|
|
81
|
+
live;
|
|
82
|
+
model;
|
|
83
|
+
memberModels;
|
|
84
|
+
name = 'kimi-cli';
|
|
85
|
+
capabilities = NO_START_CAPABILITIES;
|
|
86
|
+
inheritsParentContext = false;
|
|
87
|
+
/**
|
|
88
|
+
* @param live - the live driver, or a resolver returning the current
|
|
89
|
+
* generation's driver per member (the settings toggle swaps generations;
|
|
90
|
+
* a resolver may return undefined to steer one member's round to exec
|
|
91
|
+
* while a retiring generation still hosts it).
|
|
92
|
+
* @param model - resolver for the configured model, read PER ROUND so a
|
|
93
|
+
* settings-card write takes effect on the next delegation without a
|
|
94
|
+
* reload. Undefined (the resolver absent, or returning undefined) leaves
|
|
95
|
+
* the argv exactly as it was before the key rode every round — the scoped
|
|
96
|
+
* config.toml's `default_model` then decides, as it always did.
|
|
97
|
+
* @param memberModels - the broker's member-model ledger: the provider
|
|
98
|
+
* records each delegation's start model here (the live driver's spawn
|
|
99
|
+
* resolver binds it) and reads the session-level override as the exec
|
|
100
|
+
* path's top layer. Absent (older wiring), both drop out.
|
|
101
|
+
*/
|
|
102
|
+
constructor(ctx, live, model, memberModels) {
|
|
103
|
+
this.ctx = ctx;
|
|
104
|
+
this.live = live;
|
|
105
|
+
this.model = model;
|
|
106
|
+
this.memberModels = memberModels;
|
|
107
|
+
}
|
|
108
|
+
/** Resolve the live driver for one round's member, if live is on for it. */
|
|
109
|
+
liveDriver(childSessionId) {
|
|
110
|
+
const live = this.live;
|
|
111
|
+
if (live === undefined)
|
|
112
|
+
return undefined;
|
|
113
|
+
return typeof live === 'function' ? live(childSessionId) : live;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Register one run with the member channel and declare the bridge MCP server
|
|
117
|
+
* in the scoped home's mcp.json (per-run entry, socket + token in its env).
|
|
118
|
+
* Returns the release handle, or undefined when the mounted core
|
|
119
|
+
* predates the member channel (declare-and-degrade: the run proceeds
|
|
120
|
+
* exactly as before). Token-only auth: host 0.1.5 removed the child pid the
|
|
121
|
+
* parentage cross-check used; the per-run token is the sole credential (see
|
|
122
|
+
* the package README's threat model). `release` invalidates the token and
|
|
123
|
+
* prunes the config entry on any settle path.
|
|
124
|
+
*/
|
|
125
|
+
memberRun(childSessionId, parentSessionId, homeDir) {
|
|
126
|
+
const registry = this.ctx.localAgent;
|
|
127
|
+
if (typeof registry.registerMemberRun !== 'function'
|
|
128
|
+
|| typeof registry.memberBridgeSocketPath !== 'function'
|
|
129
|
+
|| typeof registry.memberBridgeCommand !== 'function')
|
|
130
|
+
return undefined;
|
|
131
|
+
const token = registry.registerMemberRun({ childSessionId, parentSessionId, provider: this.name });
|
|
132
|
+
const key = memberBridgeServerKey(token);
|
|
133
|
+
try {
|
|
134
|
+
injectMemberBridge(homeDir, key, registry.memberBridgeCommand(), {
|
|
135
|
+
[MEMBER_BRIDGE_SOCKET_ENV]: registry.memberBridgeSocketPath(),
|
|
136
|
+
[MEMBER_BRIDGE_TOKEN_ENV]: token,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
registry.unregisterMemberRun(token);
|
|
141
|
+
this.ctx.logger.warn(`subagent-kimi: member bridge config injection failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
let released = false;
|
|
145
|
+
return {
|
|
146
|
+
release: () => {
|
|
147
|
+
if (released)
|
|
148
|
+
return;
|
|
149
|
+
released = true;
|
|
150
|
+
registry.unregisterMemberRun(token);
|
|
151
|
+
try {
|
|
152
|
+
removeMemberBridge(homeDir, key);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
this.ctx.logger.warn(`subagent-kimi: member bridge config cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
async start(request) {
|
|
161
|
+
// The family tool stages exactly one intent per delegation call; the
|
|
162
|
+
// provider consumes exactly one per start. A resume intent continues the
|
|
163
|
+
// recorded kimi session inside the existing child session.
|
|
164
|
+
const intent = this.ctx.localAgent.takeDelegationIntent(request.parent.session.id, this.name);
|
|
165
|
+
// The scoped home this round runs against: the staged intent's scope
|
|
166
|
+
// names a sibling directory under the homes root, absent means the
|
|
167
|
+
// default one — the directory every round used before scopes existed.
|
|
168
|
+
const scope = intent?.scope;
|
|
169
|
+
const homeDir = this.ctx.localAgent.homeDir('kimi', scope);
|
|
170
|
+
// The effective cwd: the caller's override (the staged intent's `cwd`,
|
|
171
|
+
// riding DelegationCallOptions.cwd) when present, else the parent
|
|
172
|
+
// session's workspace — the behavior before overrides existed.
|
|
173
|
+
const cwd = resolveChildCwd(request.parent.session.header.cwd, intent?.cwd);
|
|
174
|
+
if (cwd === undefined) {
|
|
175
|
+
throw new Error('subagent-kimi: the parent session has no working directory to run the CLI in');
|
|
176
|
+
}
|
|
177
|
+
// Container exec target: the same round, spawned through `docker exec`
|
|
178
|
+
// inside a unit the caller acquired. Validated here so a target missing
|
|
179
|
+
// the in-container scoped home fails before any session record is made.
|
|
180
|
+
const exec = intent?.exec;
|
|
181
|
+
if (exec !== undefined)
|
|
182
|
+
containerScopedHome(exec, 'KIMI_CODE_HOME', 'subagent-kimi');
|
|
183
|
+
if (intent !== undefined && intent.kind === 'resume') {
|
|
184
|
+
// A CLI session continues in the directory its first round ran in; a
|
|
185
|
+
// round resolving elsewhere is rejected before any process spawns.
|
|
186
|
+
const record = this.ctx.localAgent.getDelegation(intent.childSessionId);
|
|
187
|
+
assertResumeCwdUnchanged(record, cwd, 'subagent-kimi');
|
|
188
|
+
// …and in the scoped home its first round ran in.
|
|
189
|
+
assertResumeScopeUnchanged(record, scope, 'subagent-kimi');
|
|
190
|
+
// A resume re-requests the model the FIRST round recorded — the caller
|
|
191
|
+
// cannot name one (the facade refuses it), and a record without one is
|
|
192
|
+
// a delegation that named none, which this round repeats.
|
|
193
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function')
|
|
194
|
+
return this.startKimiResume(request, intent, cwd, homeDir, exec, scope, record?.model);
|
|
195
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
196
|
+
childSessionId: intent.childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
|
|
197
|
+
...scope === undefined ? {} : { scope },
|
|
198
|
+
...record?.model === undefined ? {} : { model: record.model },
|
|
199
|
+
...record?.effort === undefined ? {} : { effort: record.effort },
|
|
200
|
+
...record?.configurationLock === undefined ? {} : { configurationLock: record.configurationLock },
|
|
201
|
+
}, configuration => this.startKimiResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
|
|
202
|
+
}
|
|
203
|
+
const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
|
|
204
|
+
if (typeof this.ctx.localAgent.withMemberConfigurationRound !== 'function') {
|
|
205
|
+
if (intent?.effort !== undefined)
|
|
206
|
+
throw new Error('Kimi effort requires the configuration admission core');
|
|
207
|
+
return this.startKimiFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
|
|
208
|
+
}
|
|
209
|
+
return this.ctx.localAgent.withMemberConfigurationRound({
|
|
210
|
+
childSessionId, provider: this.name, parentSessionId: request.parent.session.id, cwd,
|
|
211
|
+
...scope === undefined ? {} : { scope },
|
|
212
|
+
...intent?.model === undefined ? {} : { model: intent.model },
|
|
213
|
+
...intent?.effort === undefined ? {} : { effort: intent.effort },
|
|
214
|
+
...intent?.configurationLock === undefined ? {} : { configurationLock: intent.configurationLock },
|
|
215
|
+
}, configuration => this.startKimiFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId, configuration), request.signal, intent?.onAdmitted);
|
|
216
|
+
}
|
|
217
|
+
/** Fresh round: record the child session, spawn `kimi -p`, mirror after settle. */
|
|
218
|
+
async startKimiFresh(request, cwd, homeDir, exec, scope,
|
|
219
|
+
/** The model this DELEGATION requested, when the caller named one. */
|
|
220
|
+
requestedModel, runId, configuration) {
|
|
221
|
+
let childSession;
|
|
222
|
+
try {
|
|
223
|
+
// Strict global read, never the caller-scope `ctx.sessions` proxy: the
|
|
224
|
+
// bundle does not inject `sessions`, and the proxy would throw on access.
|
|
225
|
+
const sessions = this.ctx.get('sessions');
|
|
226
|
+
if (sessions === undefined) {
|
|
227
|
+
throw new Error('the sessions service is not mounted');
|
|
228
|
+
}
|
|
229
|
+
childSession = sessions.get(runId) ?? sessions.create(runId, {
|
|
230
|
+
meta: {
|
|
231
|
+
cwd,
|
|
232
|
+
parentSession: request.parent.session.id,
|
|
233
|
+
origin: 'subagent',
|
|
234
|
+
delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
// The durable one-shot descriptor the runtime resolved marks this child
|
|
238
|
+
// as session-backed; without it the 子代理 projection cannot classify
|
|
239
|
+
// the session and the delegation stays invisible. The label carries the
|
|
240
|
+
// harness display name as the source marker so the dropdown shows which
|
|
241
|
+
// local agent produced the conversation.
|
|
242
|
+
const harness = this.ctx.localAgent.get('kimi');
|
|
243
|
+
const label = subagentDelegationLabel(harness?.displayName ?? 'kimi', request.descriptor.label);
|
|
244
|
+
childSession.append('subagent/descriptor', {
|
|
245
|
+
...request.descriptor,
|
|
246
|
+
label,
|
|
247
|
+
});
|
|
248
|
+
// The official runtime appends the parent-side subagent/catalog
|
|
249
|
+
// discovery row only for in-process children (run.localAgent); this run
|
|
250
|
+
// is remote, so the provider appends it here — once per child, where
|
|
251
|
+
// the descriptor lands. A failure degrades to the warn below; a missing
|
|
252
|
+
// row never blocks the delegation.
|
|
253
|
+
establishSubagentCatalogChild(request.parent.session, childSession.header, label);
|
|
254
|
+
// No persistence create here: the stored session is materialized by the
|
|
255
|
+
// first persistChildSession sync through the CORE's cached write handle.
|
|
256
|
+
// A fire-and-forget create from the provider used to hold (and leak) a
|
|
257
|
+
// second write handle, which blocks the core's claim with
|
|
258
|
+
// SessionAlreadyOwnedError.
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.ctx.logger.warn(`subagent-kimi: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
262
|
+
}
|
|
263
|
+
// Resolve the effective endpoint from the scoped config.toml for
|
|
264
|
+
// diagnostics: the CLI reads it directly, and a user-edited value is
|
|
265
|
+
// authoritative. Log it so a failing delegation reports which endpoint
|
|
266
|
+
// it actually used.
|
|
267
|
+
const baseUrl = await readKimiBaseUrl(homeDir).catch(() => undefined);
|
|
268
|
+
this.ctx.logger.info(`subagent-kimi: delegating via ${baseUrl ?? 'kimi default endpoint'}`);
|
|
269
|
+
// Live driver: the round goes to the member's resident `kimi acp` process.
|
|
270
|
+
// A channel that fails at spawn/handshake falls through to the exec
|
|
271
|
+
// one-shot below — and stays there until the breaker cools down.
|
|
272
|
+
// A container target always takes the exec one-shot: the live driver runs
|
|
273
|
+
// a resident `kimi acp` process on the HOST, which is the transport the
|
|
274
|
+
// target exists to replace.
|
|
275
|
+
const live = exec === undefined ? this.liveDriver(runId) : undefined;
|
|
276
|
+
if (live !== undefined && childSession !== undefined && !live.disabled) {
|
|
277
|
+
// The member-bound live driver receives this exact scoped home.
|
|
278
|
+
// A delegation naming its own model is NOT refused anymore: the model
|
|
279
|
+
// becomes the member's start model, which the live driver's spawn
|
|
280
|
+
// resolver binds (rewriting the scoped `default_model` before spawn; a
|
|
281
|
+
// runtime already bound to another model is retired and respawned).
|
|
282
|
+
if (requestedModel !== undefined)
|
|
283
|
+
this.memberModels?.noteStartModel(runId, requestedModel);
|
|
284
|
+
try {
|
|
285
|
+
return await live.startRound(request, {
|
|
286
|
+
cwd,
|
|
287
|
+
homeDir,
|
|
288
|
+
childSession,
|
|
289
|
+
...configuration === undefined ? {} : { configuration: configuration.resolved },
|
|
290
|
+
parentSessionId: request.parent.session.id,
|
|
291
|
+
// The ACP session id arrives with session/new (server-assigned), far
|
|
292
|
+
// earlier than the exec path's settle-time stderr parse.
|
|
293
|
+
onCliSessionId: (cliSessionId) => {
|
|
294
|
+
this.ctx.localAgent.recordDelegation({
|
|
295
|
+
childSessionId: runId,
|
|
296
|
+
provider: this.name,
|
|
297
|
+
parentSessionId: request.parent.session.id,
|
|
298
|
+
cliSessionId,
|
|
299
|
+
// The round's resolved working directory anchors the
|
|
300
|
+
// resume-consistency check.
|
|
301
|
+
cwd,
|
|
302
|
+
// …and its scoped home anchors the resume-scope check.
|
|
303
|
+
...scope === undefined ? {} : { scope },
|
|
304
|
+
// The model this delegation asked for: every resume round reads
|
|
305
|
+
// it back from here instead of the caller restating it.
|
|
306
|
+
...requestedModel === undefined ? {} : { model: requestedModel },
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
|
|
313
|
+
throw error;
|
|
314
|
+
this.ctx.logger.warn(`subagent-kimi: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Member channel: register this run and declare the bridge MCP before the
|
|
318
|
+
// spawn, so the CLI session starts with member_message available.
|
|
319
|
+
// The member bridge is a host unix socket the container cannot reach, and
|
|
320
|
+
// its declaration is written INTO the scoped home's mcp.json naming a host
|
|
321
|
+
// node path — a containerized round therefore runs WITHOUT the member
|
|
322
|
+
// channel rather than leaving a broken server in a shared config file.
|
|
323
|
+
// A scoped round also runs without it: the bridge declaration is written
|
|
324
|
+
// INTO a scoped home's `mcp.json`, and the member channel's socket and
|
|
325
|
+
// roster are the default scope's — the family binds one member bridge per
|
|
326
|
+
// homes root, not one per scope. A named absence beats a half-wired one.
|
|
327
|
+
const member = exec === undefined && scope === undefined
|
|
328
|
+
? this.memberRun(runId, request.parent.session.id, homeDir)
|
|
329
|
+
: undefined;
|
|
330
|
+
try {
|
|
331
|
+
const run = await startKimiCliRun(request, {
|
|
332
|
+
cwd,
|
|
333
|
+
env: delegationEnv({ KIMI_CODE_HOME: homeDir }),
|
|
334
|
+
...exec === undefined ? {} : { exec },
|
|
335
|
+
endpointLabel: baseUrl,
|
|
336
|
+
// The session-level override (the composer picker) outranks even the
|
|
337
|
+
// delegation's own model — the family order puts it first.
|
|
338
|
+
...(configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(runId) ?? requestedModel, this.model)),
|
|
339
|
+
...configuration === undefined ? {} : { controlled: true },
|
|
340
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
341
|
+
spawn: spec => this.ctx.subprocess.spawn(spec),
|
|
342
|
+
onError: (error, stopReason) => {
|
|
343
|
+
this.ctx.logger.warn(`subagent-kimi: child run failed (${stopReason}) via ${baseUrl ?? 'kimi default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
|
|
344
|
+
},
|
|
345
|
+
onAuthFailure: (detail) => {
|
|
346
|
+
// The sentinel: an empty-shell wipe restored here makes the
|
|
347
|
+
// caller's retry (or the next round) succeed.
|
|
348
|
+
void guardKimiCredential(homeDir, message => { this.ctx.logger.warn(message); });
|
|
349
|
+
this.ctx.localAgent.reportAuthFailure('kimi', detail, scope);
|
|
350
|
+
},
|
|
351
|
+
onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
|
|
352
|
+
cliVersion: () => kimiCliVersion(this.ctx, homeDir),
|
|
353
|
+
childSession,
|
|
354
|
+
homeDir,
|
|
355
|
+
ctx: this.ctx,
|
|
356
|
+
// The first round records the kimi session id so a later resume round
|
|
357
|
+
// can continue it; the mirror offset starts at zero.
|
|
358
|
+
onCliSessionId: (cliSessionId) => {
|
|
359
|
+
if (cliSessionId === undefined)
|
|
360
|
+
return;
|
|
361
|
+
this.ctx.localAgent.recordDelegation({
|
|
362
|
+
childSessionId: runId,
|
|
363
|
+
provider: this.name,
|
|
364
|
+
parentSessionId: request.parent.session.id,
|
|
365
|
+
cliSessionId,
|
|
366
|
+
// The round's resolved working directory anchors the
|
|
367
|
+
// resume-consistency check.
|
|
368
|
+
cwd,
|
|
369
|
+
// …and its scoped home anchors the resume-scope check.
|
|
370
|
+
...scope === undefined ? {} : { scope },
|
|
371
|
+
// The model this delegation asked for (see the live branch).
|
|
372
|
+
...requestedModel === undefined ? {} : { model: requestedModel },
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
// Every settled round reports its observed model and usage through the
|
|
376
|
+
// registry's observation channel (record merge + `settled` event).
|
|
377
|
+
onRoundSettled: (round) => {
|
|
378
|
+
this.ctx.localAgent.recordRoundSettled(runId, round);
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
// The member-channel token dies with the run, whatever its stop reason.
|
|
382
|
+
if (member !== undefined)
|
|
383
|
+
void run.result.then(member.release, member.release);
|
|
384
|
+
return run;
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
member?.release();
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Resume round: continue the recorded kimi session inside the existing child session. */
|
|
392
|
+
async startKimiResume(request, intent, cwd, homeDir, exec, scope,
|
|
393
|
+
/** The model the delegation's FIRST round recorded, re-requested here. */
|
|
394
|
+
requestedModel, configuration) {
|
|
395
|
+
// One in-flight resume per child session: a second resume of the same
|
|
396
|
+
// child fails loud instead of racing the first process. The lock releases
|
|
397
|
+
// on every settle path (the run.result handler below) and on the error
|
|
398
|
+
// path, so a deadlock never strands a later resume.
|
|
399
|
+
if (!this.ctx.localAgent.acquireResumeLock(intent.childSessionId)) {
|
|
400
|
+
throw new Error(`subagent-kimi: 该子会话有进行中的委派,等其完成后再追问 (child session ${intent.childSessionId})`);
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const sessions = this.ctx.get('sessions');
|
|
404
|
+
const childSession = sessions?.get(SessionId(intent.childSessionId));
|
|
405
|
+
if (childSession === undefined) {
|
|
406
|
+
throw new Error(`subagent-kimi: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
|
|
407
|
+
}
|
|
408
|
+
// The next turn follows the rounds already recorded in the child session.
|
|
409
|
+
const nextTurn = childSession.snapshotEvents().filter(event => event.type === 'turn/start').length + 1;
|
|
410
|
+
// Live driver: continue the member's resident ACP session. Channel
|
|
411
|
+
// spawn/handshake failure falls through to the exec one-shot below.
|
|
412
|
+
// See the fresh path: a container target is exec-only.
|
|
413
|
+
const live = exec === undefined ? this.liveDriver(intent.childSessionId) : undefined;
|
|
414
|
+
if (live !== undefined && !live.disabled) {
|
|
415
|
+
// Resume retains the recorded scope and its native session.
|
|
416
|
+
// See the fresh path: the recorded model is the member's start model,
|
|
417
|
+
// which the spawn resolver binds (retiring a mismatched runtime).
|
|
418
|
+
if (requestedModel !== undefined)
|
|
419
|
+
this.memberModels?.noteStartModel(intent.childSessionId, requestedModel);
|
|
420
|
+
try {
|
|
421
|
+
const liveRun = await live.startRound(request, {
|
|
422
|
+
cwd,
|
|
423
|
+
homeDir,
|
|
424
|
+
childSession,
|
|
425
|
+
...configuration === undefined ? {} : { configuration: configuration.resolved },
|
|
426
|
+
parentSessionId: request.parent.session.id,
|
|
427
|
+
resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
|
|
428
|
+
});
|
|
429
|
+
void liveRun.result.then(() => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); }, () => { this.ctx.localAgent.releaseResumeLock(intent.childSessionId); });
|
|
430
|
+
return liveRun;
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (!(error instanceof LiveChannelUnavailableError) || request.signal.aborted)
|
|
434
|
+
throw error;
|
|
435
|
+
this.ctx.logger.warn(`subagent-kimi: live driver unavailable, using the exec one-shot: ${error.message}`);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Member channel: register the resume round (same child session, fresh
|
|
439
|
+
// per-run token) and declare the bridge MCP before the spawn.
|
|
440
|
+
// See the fresh path: no member channel across the container boundary.
|
|
441
|
+
// See the fresh path: a scoped round carries no member channel either.
|
|
442
|
+
const member = exec === undefined && scope === undefined
|
|
443
|
+
? this.memberRun(intent.childSessionId, request.parent.session.id, homeDir)
|
|
444
|
+
: undefined;
|
|
445
|
+
const baseUrl = await readKimiBaseUrl(homeDir).catch(() => undefined);
|
|
446
|
+
this.ctx.logger.info(`subagent-kimi: resuming via ${baseUrl ?? 'kimi default endpoint'}`);
|
|
447
|
+
let run;
|
|
448
|
+
try {
|
|
449
|
+
run = await startKimiCliRun(request, {
|
|
450
|
+
cwd,
|
|
451
|
+
env: delegationEnv({ KIMI_CODE_HOME: homeDir }),
|
|
452
|
+
...exec === undefined ? {} : { exec },
|
|
453
|
+
endpointLabel: baseUrl,
|
|
454
|
+
// See the fresh path: the session-level override outranks the
|
|
455
|
+
// delegation's recorded model on the exec path too.
|
|
456
|
+
...(configuration?.resolved ?? resolveRoundModel(this.memberModels?.overrideFor(intent.childSessionId) ?? requestedModel, this.model)),
|
|
457
|
+
...configuration === undefined ? {} : { controlled: true },
|
|
458
|
+
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
459
|
+
spawn: spec => this.ctx.subprocess.spawn(spec),
|
|
460
|
+
onError: (error, stopReason) => {
|
|
461
|
+
this.ctx.logger.warn(`subagent-kimi: child run failed (${stopReason}) via ${baseUrl ?? 'kimi default endpoint'}: ${error instanceof Error ? error.message : String(error)}`);
|
|
462
|
+
},
|
|
463
|
+
onAuthFailure: (detail) => {
|
|
464
|
+
// The sentinel: an empty-shell wipe restored here makes the
|
|
465
|
+
// caller's retry (or the next round) succeed.
|
|
466
|
+
void guardKimiCredential(homeDir, message => { this.ctx.logger.warn(message); });
|
|
467
|
+
this.ctx.localAgent.reportAuthFailure('kimi', detail, scope);
|
|
468
|
+
},
|
|
469
|
+
onAuthSuccess: () => { markCredentialVerified(this.ctx, scope); },
|
|
470
|
+
cliVersion: () => kimiCliVersion(this.ctx, homeDir),
|
|
471
|
+
childSession,
|
|
472
|
+
homeDir,
|
|
473
|
+
ctx: this.ctx,
|
|
474
|
+
resume: { cliSessionId: intent.cliSessionId, turn: nextTurn },
|
|
475
|
+
// Every settled round reports its observed model and usage through
|
|
476
|
+
// the registry's observation channel.
|
|
477
|
+
onRoundSettled: (round) => {
|
|
478
|
+
this.ctx.localAgent.recordRoundSettled(intent.childSessionId, round);
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch (error) {
|
|
483
|
+
member?.release();
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
void run.result.then(() => {
|
|
487
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
488
|
+
member?.release();
|
|
489
|
+
}, () => {
|
|
490
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
491
|
+
member?.release();
|
|
492
|
+
});
|
|
493
|
+
return run;
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
this.ctx.localAgent.releaseResumeLock(intent.childSessionId);
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function thrown(value) {
|
|
502
|
+
/* v8 ignore next -- typed subprocess failures reject with Error. */
|
|
503
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Extract the kimi session id from the CLI's printed response. `kimi -p`
|
|
507
|
+
* prints a resume hint (`To resume this session: kimi -r session_<id>`) on
|
|
508
|
+
* stdout after a run; mirroring that exact session instead of the newest-by-
|
|
509
|
+
* mtime keeps concurrent delegations from cross-mirroring.
|
|
510
|
+
* @param output - the collected CLI stdout.
|
|
511
|
+
* @returns the session id, or undefined when the hint is absent.
|
|
512
|
+
*/
|
|
513
|
+
export function kimiSessionIdFromOutput(output) {
|
|
514
|
+
const match = /kimi -r session_([0-9a-fA-F-]+)/.exec(output);
|
|
515
|
+
return match?.[1];
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Validate and join the one-shot task before crossing the process boundary.
|
|
519
|
+
* @param prompt - task content accepted from the shared subagent service.
|
|
520
|
+
* @returns the joined non-empty text.
|
|
521
|
+
*/
|
|
522
|
+
export function textTask(prompt) {
|
|
523
|
+
if (prompt.length === 0) {
|
|
524
|
+
throw new Error('subagent-kimi: the one-shot task must contain only text blocks');
|
|
525
|
+
}
|
|
526
|
+
const texts = [];
|
|
527
|
+
for (const block of prompt) {
|
|
528
|
+
if (block.type !== 'text') {
|
|
529
|
+
throw new Error('subagent-kimi: the one-shot task must contain only text blocks');
|
|
530
|
+
}
|
|
531
|
+
texts.push(block.text);
|
|
532
|
+
}
|
|
533
|
+
if (texts.every(text => text.trim().length === 0)) {
|
|
534
|
+
throw new Error('subagent-kimi: the one-shot task must not be empty');
|
|
535
|
+
}
|
|
536
|
+
return texts.join('\n');
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Mirror the transcript delta after the recorded offset, advance the offset,
|
|
540
|
+
* and report one `delta` progress per newly mirrored line. Shared by the live
|
|
541
|
+
* poll and the settle-time mirror so the two paths cannot drift. The offset
|
|
542
|
+
* read (`kimiMirroredLines`) and write (`setKimiMirroredLines`) are the same
|
|
543
|
+
* bookkeeping the settle path used before live mirroring existed.
|
|
544
|
+
* @param ctx - host context carrying localAgent and session persistence.
|
|
545
|
+
* @param childSession - the dsh child session being mirrored into.
|
|
546
|
+
* @param homeDir - the `kimi` harness's scoped home.
|
|
547
|
+
* @param kimiSessionId - the kimi session to mirror.
|
|
548
|
+
* @returns the mirror delta (new total + newly mirrored line texts).
|
|
549
|
+
*/
|
|
550
|
+
export async function mirrorKimiDelta(ctx, childSession, homeDir, kimiSessionId, options) {
|
|
551
|
+
// Degrade without the localAgent service: the transcript still mirrors (the
|
|
552
|
+
// pre-live-mirror behavior for a bare context), only the offset bookkeeping
|
|
553
|
+
// and progress reporting drop out.
|
|
554
|
+
const localAgent = ctx.get('localAgent');
|
|
555
|
+
const fromLines = localAgent?.kimiMirroredLines(childSession.id) ?? 0;
|
|
556
|
+
const delta = await mirrorKimiSessionDelta(ctx, childSession, homeDir, kimiSessionId, fromLines, options);
|
|
557
|
+
if (delta.texts.length === 0)
|
|
558
|
+
return delta;
|
|
559
|
+
localAgent?.setKimiMirroredLines(childSession.id, delta.total);
|
|
560
|
+
for (const text of delta.texts) {
|
|
561
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
|
|
562
|
+
}
|
|
563
|
+
return delta;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Mirror the kimi session's transcript into the child session AFTER the CLI
|
|
567
|
+
* process has exited, whatever its stop reason. A cancelled or failed round
|
|
568
|
+
* still preserves whatever the wire already recorded (partial answer, tool
|
|
569
|
+
* activity, usage) instead of leaving the child blank — the run result
|
|
570
|
+
* settles 'aborted'/'error' at the cancel moment, but the CLI may have
|
|
571
|
+
* produced content before the kill landed, and that content belongs in the
|
|
572
|
+
* 子代理 surface with its real token accounting. Also records the delegation
|
|
573
|
+
* (fresh rounds) so a later resume can continue the session.
|
|
574
|
+
* @param spec - the run spec carrying the child session, home, and context.
|
|
575
|
+
* @param stderr - the collected CLI stderr (the resume hint lives here).
|
|
576
|
+
* @returns this pass's observation — the wire's model identifier and the
|
|
577
|
+
* window's usage — or undefined when nothing was mirrored; the caller sums
|
|
578
|
+
* the window usage into the round total and reports the round's settled
|
|
579
|
+
* observation.
|
|
580
|
+
*/
|
|
581
|
+
async function mirrorKimiAfterExit(spec, stderr) {
|
|
582
|
+
if (spec.childSession === undefined || spec.homeDir === undefined || spec.ctx === undefined)
|
|
583
|
+
return undefined;
|
|
584
|
+
try {
|
|
585
|
+
const kimiSessionId = spec.resume?.cliSessionId ?? kimiSessionIdFromOutput(stderr);
|
|
586
|
+
if (spec.resume === undefined)
|
|
587
|
+
spec.onCliSessionId?.(kimiSessionId);
|
|
588
|
+
// The round's turn selects which transcript lines the tool-call accounting
|
|
589
|
+
// belongs to — the mirror window would under-count a settle pass a live
|
|
590
|
+
// poll already drained, and over-count a resume round's earlier turns.
|
|
591
|
+
const delta = await mirrorKimiDelta(spec.ctx, spec.childSession, spec.homeDir, kimiSessionId, {
|
|
592
|
+
turn: spec.resume?.turn ?? 1,
|
|
593
|
+
});
|
|
594
|
+
// Report the final mirrored-line count even when the live mirror already
|
|
595
|
+
// advanced the offset (empty delta): the settle report is authoritative.
|
|
596
|
+
spec.ctx.get('localAgent')?.reportRunProgress(spec.childSession.id, { kind: 'mirror', mirroredLines: delta.total });
|
|
597
|
+
return {
|
|
598
|
+
...delta.model === undefined ? {} : { model: delta.model },
|
|
599
|
+
...delta.usage === undefined ? {} : { usage: delta.usage },
|
|
600
|
+
...delta.toolCalls === undefined ? {} : { toolCalls: delta.toolCalls },
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
catch (error) {
|
|
604
|
+
spec.onError?.(thrown(error), 'error');
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Start the real `kimi -p` (fresh) or `kimi -S session_<id> -p` (resume)
|
|
610
|
+
* child and publish its run. The kimi response is printed to stdout; stderr
|
|
611
|
+
* is inherited for diagnostics. The run id is the child session id for a
|
|
612
|
+
* session-backed run, so the tool's `resume="<id>"` self-description names
|
|
613
|
+
* the same handle the registry records.
|
|
614
|
+
* @param request - resolved shared subagent request.
|
|
615
|
+
* @param spec - workspace, environment, process service, and diagnostic policy.
|
|
616
|
+
* @returns the published run after the child starts.
|
|
617
|
+
*/
|
|
618
|
+
export async function startKimiCliRun(request, spec) {
|
|
619
|
+
const task = textTask(request.prompt);
|
|
620
|
+
if (request.signal.aborted) {
|
|
621
|
+
throw new Error('subagent-kimi: request was aborted before the CLI started');
|
|
622
|
+
}
|
|
623
|
+
const turn = spec.resume?.turn ?? 1;
|
|
624
|
+
// The turn opens at the real spawn moment so the timing projection
|
|
625
|
+
// measures actual CLI runtime, not the post-hoc mirror time.
|
|
626
|
+
spec.childSession?.append('turn/start', { turn });
|
|
627
|
+
// -S must precede -p: after -p, kimi parses the id as a command. The
|
|
628
|
+
// recorded id may already carry the ACP directory prefix (a record
|
|
629
|
+
// written by a live round): never double-prefix.
|
|
630
|
+
// The configured model rides `-m`, placed immediately before `-p` on both
|
|
631
|
+
// variants — after `-p` the CLI reads the next token as its prompt, and the
|
|
632
|
+
// resume id must still lead. Nothing configured appends nothing: the argv
|
|
633
|
+
// is then byte-for-byte the shape that shipped before the key rode rounds.
|
|
634
|
+
const modelArgv = spec.model === undefined ? [] : ['-m', spec.model];
|
|
635
|
+
const argv = spec.resume === undefined
|
|
636
|
+
? ['kimi', ...modelArgv, '-p', task]
|
|
637
|
+
: ['kimi', '-S', spec.resume.cliSessionId.startsWith('session_') ? spec.resume.cliSessionId : `session_${spec.resume.cliSessionId}`, ...modelArgv, '-p', task];
|
|
638
|
+
// Container target: the same argv, wrapped in `docker exec`. The host cwd
|
|
639
|
+
// still applies — it is the docker CLIENT's working directory now, while
|
|
640
|
+
// the CLI's own is the target's in-container workdir.
|
|
641
|
+
const configurationHome = spec.homeDir ?? spec.env['KIMI_CODE_HOME'];
|
|
642
|
+
if (spec.controlled && spec.effort !== undefined && !configurationHome)
|
|
643
|
+
throw new Error('Kimi effort requires an explicit scoped configuration home');
|
|
644
|
+
const configEnv = spec.controlled && spec.effort !== undefined
|
|
645
|
+
? await kimiExecConfiguration(configurationHome, spec.model, spec.effort) : {};
|
|
646
|
+
const env = { ...spec.env, ...configEnv };
|
|
647
|
+
const launch = spec.exec === undefined
|
|
648
|
+
? { argv, env }
|
|
649
|
+
: containerExecSpawn(spec.exec, { argv, env }, 'subagent-kimi');
|
|
650
|
+
const child = spec.spawn({
|
|
651
|
+
argv: launch.argv,
|
|
652
|
+
cwd: spec.cwd,
|
|
653
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
654
|
+
graceMs: spec.disposeGraceMs,
|
|
655
|
+
env: launch.env,
|
|
656
|
+
});
|
|
657
|
+
let output = '';
|
|
658
|
+
child.stdout?.on('data', (chunk) => { output += chunk.toString(); });
|
|
659
|
+
// stderr carries diagnostics plus the resume hint naming this run's session;
|
|
660
|
+
// parsed for the mirror and the delegation record, never folded into output.
|
|
661
|
+
let stderr = '';
|
|
662
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
663
|
+
// The round's usage accumulates across mirror passes: each pass (live poll,
|
|
664
|
+
// settle) folds a disjoint transcript window and reports that window's
|
|
665
|
+
// accounting, so the disjoint windows sum to the round's total — exactly
|
|
666
|
+
// once, whatever the poll/settle split turned out to be.
|
|
667
|
+
let roundUsage;
|
|
668
|
+
const addRoundUsage = (usage) => {
|
|
669
|
+
roundUsage = addTokenUsage(roundUsage, usage);
|
|
670
|
+
};
|
|
671
|
+
// Live transcript mirroring: poll the kimi wire log while the run is in
|
|
672
|
+
// flight and mirror the delta through the SAME folding path the settle-time
|
|
673
|
+
// mirror uses, so a caller watching the child session sees progress instead
|
|
674
|
+
// of silence. The offset is the shared kimiMirroredLines bookkeeping, so the
|
|
675
|
+
// settle-time final mirror is a no-op when polling kept up. A poll failure
|
|
676
|
+
// never kills the run: it logs, and the settle mirror remains the fallback.
|
|
677
|
+
// All mirror passes (polls and the settle mirror) serialize through
|
|
678
|
+
// mirrorQueue so concurrent passes cannot fold overlapping ranges.
|
|
679
|
+
let mirrorQueue = Promise.resolve();
|
|
680
|
+
const enqueueMirror = (task) => {
|
|
681
|
+
mirrorQueue = mirrorQueue.then(task);
|
|
682
|
+
};
|
|
683
|
+
if (spec.childSession !== undefined && spec.homeDir !== undefined && spec.ctx !== undefined) {
|
|
684
|
+
const childSession = spec.childSession;
|
|
685
|
+
const homeDir = spec.homeDir;
|
|
686
|
+
const mirrorCtx = spec.ctx;
|
|
687
|
+
const poll = async () => {
|
|
688
|
+
// A fresh run's session id arrives on stderr with the resume hint; skip
|
|
689
|
+
// the tick until it is known (the newest-session heuristic would risk
|
|
690
|
+
// cross-mirroring a concurrent delegation).
|
|
691
|
+
const kimiSessionId = spec.resume?.cliSessionId ?? kimiSessionIdFromOutput(stderr);
|
|
692
|
+
if (kimiSessionId === undefined)
|
|
693
|
+
return;
|
|
694
|
+
const delta = await mirrorKimiDelta(mirrorCtx, childSession, homeDir, kimiSessionId);
|
|
695
|
+
addRoundUsage(delta.usage);
|
|
696
|
+
if (delta.texts.length > 0) {
|
|
697
|
+
mirrorCtx.get('localAgent')?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
const liveMirror = setInterval(() => {
|
|
701
|
+
enqueueMirror(async () => {
|
|
702
|
+
try {
|
|
703
|
+
await poll();
|
|
704
|
+
}
|
|
705
|
+
catch (error) {
|
|
706
|
+
mirrorCtx.logger.warn(`subagent-kimi: live mirror poll failed: ${thrown(error).message}`);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
}, spec.liveMirrorIntervalMs ?? DEFAULT_LIVE_MIRROR_INTERVAL_MS);
|
|
710
|
+
liveMirror.unref();
|
|
711
|
+
// Polling stops when the process exits; the settle mirror (enqueued below)
|
|
712
|
+
// runs the final fold afterwards through the same queue.
|
|
713
|
+
void child.done.then(() => { clearInterval(liveMirror); }, () => { clearInterval(liveMirror); });
|
|
714
|
+
}
|
|
715
|
+
const disposeProcess = async () => {
|
|
716
|
+
// Host 0.1.5 hides the child pid (the managed range owns termination):
|
|
717
|
+
// terminate() is an idempotent no-op once the range is gone, so a failed
|
|
718
|
+
// spawn needs no guard; done rejects there, which dispose must swallow.
|
|
719
|
+
child.terminate();
|
|
720
|
+
await child.waitForExit().catch(() => false);
|
|
721
|
+
await child.done.catch(() => { });
|
|
722
|
+
};
|
|
723
|
+
const runAbort = new AbortController();
|
|
724
|
+
const requestCancel = () => {
|
|
725
|
+
if (runAbort.signal.aborted)
|
|
726
|
+
return;
|
|
727
|
+
runAbort.abort(new Error('subagent-kimi: run cancelled locally'));
|
|
728
|
+
};
|
|
729
|
+
const onAbort = () => { requestCancel(); };
|
|
730
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
731
|
+
// Abort branch: the moment the run is cancelled locally, the attempt settles
|
|
732
|
+
// immediately — settleRunResult observes `cancelled()` and resolves the
|
|
733
|
+
// result with 'aborted' WITHOUT waiting for the child to exit. The actual
|
|
734
|
+
// process kill is dispose's job (SIGTERM → grace → SIGKILL teardown ladder),
|
|
735
|
+
// per the subprocessRunHandle contract: requestCancel settles the result,
|
|
736
|
+
// teardown reaps the process. Without this branch the result would only
|
|
737
|
+
// settle after the child exits, and dispose (which runs after the result)
|
|
738
|
+
// would never fire — a dead wait on a long-running CLI.
|
|
739
|
+
const abortBranch = new Promise((_, reject) => {
|
|
740
|
+
runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-kimi: run cancelled locally')), { once: true });
|
|
741
|
+
});
|
|
742
|
+
const processFailure = child.done.then(outcome => Promise.reject(new Error('subagent-kimi: CLI exited before the run settled '
|
|
743
|
+
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
744
|
+
// A normal post-result dispose also closes the process; keep the expected
|
|
745
|
+
// late rejection observed after the result race has already settled.
|
|
746
|
+
processFailure.catch(() => { });
|
|
747
|
+
const collectOutput = () => {
|
|
748
|
+
// Post-exit the seam's collected buffer is authoritative — a fast-exiting
|
|
749
|
+
// process can settle `done` before the streamed data events land.
|
|
750
|
+
const drained = child.collected.stdout?.readFrom(0).text;
|
|
751
|
+
const text = (drained !== undefined && drained !== '' ? drained : output).trim();
|
|
752
|
+
return text === '' ? [] : [{ type: 'text', text }];
|
|
753
|
+
};
|
|
754
|
+
const result = settleRunResult({
|
|
755
|
+
attempt: () => Promise.race([
|
|
756
|
+
child.done.then((outcome) => {
|
|
757
|
+
if (outcome.exitCode !== 0) {
|
|
758
|
+
const via = spec.endpointLabel ?? 'kimi default endpoint';
|
|
759
|
+
throw new Error(`subagent-kimi: kimi -p exited with code ${String(outcome.exitCode)} via ${via}`);
|
|
760
|
+
}
|
|
761
|
+
// A zero exit with no printed answer is a silent failure, not a
|
|
762
|
+
// success: the CLI produced nothing, so the delegation did not
|
|
763
|
+
// deliver a result. Throwing here settles 'error' through the seam
|
|
764
|
+
// instead of reporting an empty 'completed' (which the upstream
|
|
765
|
+
// settleRunResult does not re-check).
|
|
766
|
+
const output = collectOutput();
|
|
767
|
+
if (output.length === 0) {
|
|
768
|
+
throw new Error('subagent-kimi: kimi -p exited 0 but produced no answer');
|
|
769
|
+
}
|
|
770
|
+
return { output, stopReason: 'completed' };
|
|
771
|
+
}),
|
|
772
|
+
processFailure,
|
|
773
|
+
abortBranch,
|
|
774
|
+
]),
|
|
775
|
+
collectOutput,
|
|
776
|
+
cancelled: () => runAbort.signal.aborted,
|
|
777
|
+
onError: spec.onError,
|
|
778
|
+
signal: request.signal,
|
|
779
|
+
onAbort,
|
|
780
|
+
}).then((settled) => {
|
|
781
|
+
// A completed round proves the scoped credential is live — the endpoint
|
|
782
|
+
// answered. Reported here rather than from the mirror so it lands whether
|
|
783
|
+
// or not the round is session-backed.
|
|
784
|
+
if (settled.stopReason === 'completed')
|
|
785
|
+
spec.onAuthSuccess?.();
|
|
786
|
+
// The turn closes at the real settle moment, so the timing projection's
|
|
787
|
+
// duration equals the actual CLI runtime. Every terminal path closes the
|
|
788
|
+
// window — a failed or cancelled run settles 'error'/'aborted' instead of
|
|
789
|
+
// leaving the turn open with a distorted tiny duration.
|
|
790
|
+
if (spec.childSession !== undefined) {
|
|
791
|
+
if (settled.stopReason === 'completed') {
|
|
792
|
+
spec.childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
|
|
793
|
+
}
|
|
794
|
+
else if (settled.stopReason === 'aborted') {
|
|
795
|
+
spec.childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
|
|
796
|
+
}
|
|
797
|
+
else {
|
|
798
|
+
spec.childSession.append('turn/end', {
|
|
799
|
+
turn,
|
|
800
|
+
reason: { kind: 'error', error: { message: 'kimi -p exited before the run completed', code: 'UNKNOWN' } },
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return settled;
|
|
805
|
+
});
|
|
806
|
+
// After the child EXITS — however it ended (completed, killed by dispose, or
|
|
807
|
+
// crashed) — mirror whatever the kimi session already produced into the dsh
|
|
808
|
+
// subagent session, so a cancelled round still preserves its partial work
|
|
809
|
+
// and real token usage instead of leaving the child blank. The mirror reads
|
|
810
|
+
// the wire file, so it only reflects what kimi flushed before the kill.
|
|
811
|
+
// Waits for the settle chain first (so turn/end is already appended) AND for
|
|
812
|
+
// the process to actually exit (so the wire file is complete) before
|
|
813
|
+
// reading it. Enqueued behind any in-flight live poll on the mirror queue.
|
|
814
|
+
void result.then(() => child.done).then(() => {
|
|
815
|
+
// Auth detection runs post-exit: streams are drained by then, so a fast
|
|
816
|
+
// failure's stderr is complete (a settle-time read could race the flush).
|
|
817
|
+
if (spec.onAuthFailure !== undefined) {
|
|
818
|
+
// The seam's collected buffers are complete at process exit; the
|
|
819
|
+
// streamed variables can lag `done` by a tick.
|
|
820
|
+
const drainedStderr = child.collected.stderr?.readFrom(0).text || stderr;
|
|
821
|
+
const drainedStdout = child.collected.stdout?.readFrom(0).text || output;
|
|
822
|
+
const authDetail = `${drainedStderr}\n${drainedStdout}`;
|
|
823
|
+
if (KIMI_AUTH_FAILURE.test(authDetail)) {
|
|
824
|
+
spec.onAuthFailure(authDetail.split('\n').find(line => line.trim() !== '') ?? 'auth failure');
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// The settle pass runs last on the mirror queue: it sums its window's
|
|
828
|
+
// usage into the round total, then reports the round's settled
|
|
829
|
+
// observation — the wire's model plus the round's accumulated usage.
|
|
830
|
+
enqueueMirror(async () => {
|
|
831
|
+
const settled = await mirrorKimiAfterExit(spec, stderr);
|
|
832
|
+
addRoundUsage(settled?.usage);
|
|
833
|
+
// The wire log names the model but never the CLI build, so the version
|
|
834
|
+
// is the executable's own answer, probed once per binary.
|
|
835
|
+
const cliVersion = await spec.cliVersion?.();
|
|
836
|
+
spec.onRoundSettled?.({
|
|
837
|
+
...settled?.model === undefined ? {} : { observedModel: settled.model },
|
|
838
|
+
...cliVersion === undefined ? {} : { cliVersion },
|
|
839
|
+
...roundUsage === undefined ? {} : { usage: roundUsage },
|
|
840
|
+
...settled?.toolCalls === undefined ? {} : { toolCalls: settled.toolCalls },
|
|
841
|
+
});
|
|
842
|
+
});
|
|
843
|
+
}, () => { });
|
|
844
|
+
return Promise.resolve(subprocessRunHandle({
|
|
845
|
+
// A session-backed run's id is the child session id (the seam's local-run
|
|
846
|
+
// contract), so the tool's resume self-description names the same handle
|
|
847
|
+
// the delegation registry records.
|
|
848
|
+
id: spec.childSession?.id ?? SessionId(randomUUID()),
|
|
849
|
+
result,
|
|
850
|
+
signal: request.signal,
|
|
851
|
+
onAbort,
|
|
852
|
+
requestCancel,
|
|
853
|
+
teardown: disposeProcess,
|
|
854
|
+
}));
|
|
855
|
+
}
|