@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,1312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kimi provider's live driver: one resident `kimi acp` process per member
|
|
3
|
+
* (child session), driven over the vendor's Agent Client Protocol (ACP) —
|
|
4
|
+
* JSON-RPC NDJSON over stdio (protocol-probed against kimi 0.36.1; the
|
|
5
|
+
* harness `subagent-acp` package is the reference implementation). A
|
|
6
|
+
* delegation round is a `session/prompt` on the member's resident session
|
|
7
|
+
* instead of a fresh `kimi -p` process, which buys what the process boundary
|
|
8
|
+
* could not: runtime-level graceful interrupt (`session/cancel`; the process
|
|
9
|
+
* survives and the session stays continuable) and push-driven mirroring
|
|
10
|
+
* (`session/update` notifications trigger throttled file-mirror passes — see
|
|
11
|
+
* below why the transcript itself stays file-sourced).
|
|
12
|
+
*
|
|
13
|
+
* Mirroring contract (the kimi-specific call): kimi's ACP updates are
|
|
14
|
+
* token-level chunks, NOT the wire.jsonl line fold the exec mirror owns — so
|
|
15
|
+
* completed items always fold from the file: push events merely TRIGGER
|
|
16
|
+
* throttled `mirrorKimiDelta` passes and the settle pass stays authoritative
|
|
17
|
+
* (one fold, one offset, zero divergence between the two drivers). Every
|
|
18
|
+
* transcript item folds 1:1 into the child session log.
|
|
19
|
+
* Live output also mirrors the in-flight item: chunk
|
|
20
|
+
* deltas accumulate per streaming item and append throttled snapshot
|
|
21
|
+
* assistant/messages at the item's reserved (turn, step) — the host folds
|
|
22
|
+
* repeated settles at one coordinate into one live-updating chat node — and
|
|
23
|
+
* the wire line completing the item folds at the same step, finalizing it.
|
|
24
|
+
*
|
|
25
|
+
* Lifecycle mirrors the dsh/codex live drivers' discipline: lazy spawn with an
|
|
26
|
+
* initialize handshake (loadSession capability required — crash recovery is
|
|
27
|
+
* `session/load` of the recorded session id), one in-flight spawn per member,
|
|
28
|
+
* idle-timeout reclaim (stdin EOF → grace → SIGTERM ladder), a channel breaker
|
|
29
|
+
* with cooldown, `disposeAll` on unload, and cancellation honored in every
|
|
30
|
+
* window. The wire adapter is self-contained (no `@agentclientprotocol/sdk`
|
|
31
|
+
* dependency): StringDecoder framing, request correlation, and unattended
|
|
32
|
+
* auto-answers for `session/request_permission` (allow — matching the exec
|
|
33
|
+
* mode's auto-approve behavior; the proposal's default policy).
|
|
34
|
+
* @module @khorsheed/dsh-local-agent-kimi/live-driver
|
|
35
|
+
*/
|
|
36
|
+
import { configureKimiSession } from './session-configuration.js';
|
|
37
|
+
import { kimiNativeConfiguration } from './model-catalog.js';
|
|
38
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
39
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
40
|
+
import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
|
|
41
|
+
import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
|
|
42
|
+
import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
|
|
43
|
+
import { DEFAULT_DISPOSE_GRACE_MS, mirrorKimiDelta, textTask, } from './kimi-cli-provider.js';
|
|
44
|
+
import { assistantEvent, nextKimiSessionStep } from './session-mirror.js';
|
|
45
|
+
import { addTokenUsage } from './session-view.js';
|
|
46
|
+
import { guardKimiCredential } from './credential-guard.js';
|
|
47
|
+
import { writeKimiDefaultModel } from './provision.js';
|
|
48
|
+
/** Default idle lifetime of an unused resident runtime before reclaim. */
|
|
49
|
+
export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
|
|
50
|
+
/** Default timeout for a wire request acknowledgement (never for session/prompt). */
|
|
51
|
+
export const DEFAULT_LIVE_REQUEST_TIMEOUT_MS = 30_000;
|
|
52
|
+
/** Default timeout for the ACP handshake (a cold boot can be slow). */
|
|
53
|
+
export const DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS = 60_000;
|
|
54
|
+
/** Bounded wait for an interrupted turn to converge while a run disposes. */
|
|
55
|
+
export const DEFAULT_LIVE_DISPOSE_CONVERGE_MS = 5_000;
|
|
56
|
+
/** How long a tripped channel breaker stays on the exec fallback. */
|
|
57
|
+
export const DEFAULT_LIVE_CHANNEL_RETRY_MS = 5 * 60_000;
|
|
58
|
+
/** Minimum gap between push-triggered mirror passes. */
|
|
59
|
+
export const DEFAULT_LIVE_MIRROR_THROTTLE_MS = 500;
|
|
60
|
+
/** Default minimum interval between one streaming item's snapshot messages. */
|
|
61
|
+
export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
|
|
62
|
+
/** Default minimum text growth between one streaming item's snapshot messages. */
|
|
63
|
+
export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
|
|
64
|
+
/** Grace between stdin EOF and SIGTERM when reclaiming an ACP server. */
|
|
65
|
+
const RECLAIM_EOF_GRACE_MS = 1_000;
|
|
66
|
+
/** The ACP protocol version this driver speaks (kimi 0.36.1 probe). */
|
|
67
|
+
const ACP_PROTOCOL_VERSION = 1;
|
|
68
|
+
/**
|
|
69
|
+
* The ACP channel could not come up (spawn failure, handshake
|
|
70
|
+
* timeout/mismatch, or a missing loadSession capability). The provider
|
|
71
|
+
* catches exactly this and falls back to the exec one-shot; any other error
|
|
72
|
+
* is a real round failure.
|
|
73
|
+
*/
|
|
74
|
+
export class LiveChannelUnavailableError extends Error {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'LiveChannelUnavailableError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const DEFAULT_TIMEOUTS = {
|
|
81
|
+
initializeMs: DEFAULT_LIVE_INITIALIZE_TIMEOUT_MS,
|
|
82
|
+
requestMs: DEFAULT_LIVE_REQUEST_TIMEOUT_MS,
|
|
83
|
+
convergeMs: DEFAULT_LIVE_DISPOSE_CONVERGE_MS,
|
|
84
|
+
channelRetryMs: DEFAULT_LIVE_CHANNEL_RETRY_MS,
|
|
85
|
+
mirrorThrottleMs: DEFAULT_LIVE_MIRROR_THROTTLE_MS,
|
|
86
|
+
};
|
|
87
|
+
/** Settle-mirror quiescence: poll cadence, required consecutive stable reads, and the overall bound. */
|
|
88
|
+
const SETTLE_MIRROR_POLL_MS = 300;
|
|
89
|
+
const SETTLE_MIRROR_STABLE_READS = 3;
|
|
90
|
+
const SETTLE_MIRROR_QUIESCE_MS = 3_000;
|
|
91
|
+
function thrown(value) {
|
|
92
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Auth-shaped failure signatures in kimi's error output: the endpoint
|
|
96
|
+
* rejected the credential. Mirrors the exec path's detector
|
|
97
|
+
* (KIMI_AUTH_FAILURE in kimi-cli-provider.ts); the live path matches against
|
|
98
|
+
* session/prompt failure messages, which is where a mid-run 401 surfaces
|
|
99
|
+
* when the process does not exit.
|
|
100
|
+
*/
|
|
101
|
+
export const KIMI_LIVE_AUTH_FAILURE = /401|unauthorized|invalid api key|not authenticated|authentication required/i;
|
|
102
|
+
/**
|
|
103
|
+
* kimi ACP session ids are directory names (`session_<uuid>`); the family
|
|
104
|
+
* delegation record convention is the bare uuid (the exec path's settle-time
|
|
105
|
+
* stderr parse). Strip the prefix so live- and exec-written records stay
|
|
106
|
+
* interchangeable.
|
|
107
|
+
*/
|
|
108
|
+
export function bareKimiSessionId(acpSessionId) {
|
|
109
|
+
return acpSessionId.startsWith('session_') ? acpSessionId.slice('session_'.length) : acpSessionId;
|
|
110
|
+
}
|
|
111
|
+
/** The ACP-native form of a recorded kimi session id (idempotent). */
|
|
112
|
+
export function acpKimiSessionId(recordedId) {
|
|
113
|
+
return recordedId.startsWith('session_') ? recordedId : `session_${recordedId}`;
|
|
114
|
+
}
|
|
115
|
+
function delay(ms) {
|
|
116
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Map an ACP prompt stop reason onto the shared stop contract (the harness
|
|
120
|
+
* subagent-acp mapping, verbatim): an unclean stop is never 'completed'.
|
|
121
|
+
*/
|
|
122
|
+
export function acpStopReasonToHarness(reason) {
|
|
123
|
+
switch (reason) {
|
|
124
|
+
case 'end_turn':
|
|
125
|
+
return 'completed';
|
|
126
|
+
case 'max_tokens':
|
|
127
|
+
return 'max-tokens';
|
|
128
|
+
case 'refusal':
|
|
129
|
+
return 'refusal';
|
|
130
|
+
case 'cancelled':
|
|
131
|
+
return 'aborted';
|
|
132
|
+
default:
|
|
133
|
+
return 'error';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The ACP wire peer: newline-delimited JSON-RPC over the child's stdio.
|
|
138
|
+
* Self-contained on purpose (no SDK dependency): StringDecoder framing for
|
|
139
|
+
* split UTF-8 tails, request correlation, and server→client request answering
|
|
140
|
+
* (the permission surface).
|
|
141
|
+
*/
|
|
142
|
+
class AcpWirePeer {
|
|
143
|
+
output;
|
|
144
|
+
warn;
|
|
145
|
+
onServerRequest;
|
|
146
|
+
onNotification;
|
|
147
|
+
requestMs;
|
|
148
|
+
buffer = '';
|
|
149
|
+
decoder = new StringDecoder('utf8');
|
|
150
|
+
nextId = 0;
|
|
151
|
+
pending = new Map();
|
|
152
|
+
closed = false;
|
|
153
|
+
constructor(input, output, warn, onServerRequest, onNotification, requestMs) {
|
|
154
|
+
this.output = output;
|
|
155
|
+
this.warn = warn;
|
|
156
|
+
this.onServerRequest = onServerRequest;
|
|
157
|
+
this.onNotification = onNotification;
|
|
158
|
+
this.requestMs = requestMs;
|
|
159
|
+
input?.on('data', (chunk) => { this.feed(this.decoder.write(chunk)); });
|
|
160
|
+
}
|
|
161
|
+
write(message) {
|
|
162
|
+
this.output?.write(JSON.stringify(message) + '\n');
|
|
163
|
+
}
|
|
164
|
+
/** Send one request; `timeoutMs` 0 means turn-length (no timer — session/prompt). */
|
|
165
|
+
request(method, params, timeoutMs) {
|
|
166
|
+
if (this.closed)
|
|
167
|
+
return Promise.reject(new Error('subagent-kimi live: the wire is closed'));
|
|
168
|
+
this.nextId += 1;
|
|
169
|
+
const id = `req_${this.nextId}`;
|
|
170
|
+
const timeout = timeoutMs ?? this.requestMs;
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
let timer;
|
|
173
|
+
if (timeout > 0) {
|
|
174
|
+
timer = setTimeout(() => {
|
|
175
|
+
this.pending.delete(id);
|
|
176
|
+
reject(new Error(`subagent-kimi live: ${method} timed out`));
|
|
177
|
+
timer?.unref();
|
|
178
|
+
}, timeout);
|
|
179
|
+
timer.unref();
|
|
180
|
+
}
|
|
181
|
+
this.pending.set(id, { resolve: result => resolve(result), reject, timer });
|
|
182
|
+
this.write({ jsonrpc: '2.0', id, method, params });
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
notify(method, params) {
|
|
186
|
+
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params });
|
|
187
|
+
}
|
|
188
|
+
/** Detach and reject every outstanding request (a turn-length prompt settles here on teardown). */
|
|
189
|
+
close() {
|
|
190
|
+
if (this.closed)
|
|
191
|
+
return;
|
|
192
|
+
this.closed = true;
|
|
193
|
+
for (const request of this.pending.values()) {
|
|
194
|
+
if (request.timer !== undefined)
|
|
195
|
+
clearTimeout(request.timer);
|
|
196
|
+
request.reject(new Error('subagent-kimi live: the wire closed'));
|
|
197
|
+
}
|
|
198
|
+
this.pending.clear();
|
|
199
|
+
}
|
|
200
|
+
feed(text) {
|
|
201
|
+
this.buffer += text;
|
|
202
|
+
let index = this.buffer.indexOf('\n');
|
|
203
|
+
while (index >= 0) {
|
|
204
|
+
const line = this.buffer.slice(0, index);
|
|
205
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
206
|
+
index = this.buffer.indexOf('\n');
|
|
207
|
+
if (line.trim() !== '')
|
|
208
|
+
this.dispatchLine(line);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Handle one wire line; a malformed or hostile line never escapes this frame. */
|
|
212
|
+
dispatchLine(line) {
|
|
213
|
+
let message;
|
|
214
|
+
try {
|
|
215
|
+
message = JSON.parse(line);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
this.warn('subagent-kimi live: ignored a malformed wire line');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const hasId = typeof message.id === 'string' || typeof message.id === 'number';
|
|
223
|
+
if (hasId && typeof message.method === 'string') {
|
|
224
|
+
// A server→client REQUEST (permission / fs / terminal): answer unattended.
|
|
225
|
+
const id = message.id;
|
|
226
|
+
try {
|
|
227
|
+
const result = this.onServerRequest(message.method, (message.params ?? {}));
|
|
228
|
+
this.write({ jsonrpc: '2.0', id: id, result: result });
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
this.write({
|
|
232
|
+
jsonrpc: '2.0', id: id,
|
|
233
|
+
error: { code: -32603, message: thrown(error).message },
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (hasId && ('result' in message || 'error' in message)) {
|
|
239
|
+
const request = this.pending.get(String(message.id));
|
|
240
|
+
if (request === undefined)
|
|
241
|
+
return;
|
|
242
|
+
this.pending.delete(String(message.id));
|
|
243
|
+
if (request.timer !== undefined)
|
|
244
|
+
clearTimeout(request.timer);
|
|
245
|
+
const wireError = message.error;
|
|
246
|
+
if (wireError !== undefined && wireError !== null) {
|
|
247
|
+
request.reject(new Error(`subagent-kimi live: ${wireError.message ?? 'wire error'}`));
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
request.resolve(message.result);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (typeof message.method === 'string') {
|
|
255
|
+
this.onNotification(message.method, (message.params ?? {}));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.warn(`subagent-kimi live: wire line handler failed: ${thrown(error).message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* One resident `kimi acp` process: the wire peer, the loaded session id, the
|
|
265
|
+
* per-session turn chain (one prompt at a time — a cancelled turn converges
|
|
266
|
+
* before the next prompt goes out), and the reclaim ladder.
|
|
267
|
+
*/
|
|
268
|
+
class KimiLiveRuntime {
|
|
269
|
+
child;
|
|
270
|
+
dead = false;
|
|
271
|
+
reclaimed = false;
|
|
272
|
+
peer;
|
|
273
|
+
/** The member's ACP session; assigned by the first round's session/new or session/load. */
|
|
274
|
+
sessionId;
|
|
275
|
+
/**
|
|
276
|
+
* The model this process was spawned with (the scoped `default_model` value
|
|
277
|
+
* rewritten just before spawn; undefined means the config decided). A round
|
|
278
|
+
* resolving a different model for the member retires this runtime so the
|
|
279
|
+
* respawn binds the new one.
|
|
280
|
+
*/
|
|
281
|
+
configurationKey;
|
|
282
|
+
boundModel;
|
|
283
|
+
modelConfiguration;
|
|
284
|
+
/** Serializes session/prompt per member (converge-before-next-turn). */
|
|
285
|
+
turnChain = Promise.resolve();
|
|
286
|
+
/** The active round's notification sink; installed per round, cleared at settle. */
|
|
287
|
+
onSessionUpdate;
|
|
288
|
+
onDead;
|
|
289
|
+
stderrTail = '';
|
|
290
|
+
stderrDecoder = new StringDecoder('utf8');
|
|
291
|
+
constructor(child, onServerRequest, warn, requestMs) {
|
|
292
|
+
this.child = child;
|
|
293
|
+
this.peer = new AcpWirePeer(child.stdout, child.stdin, warn, onServerRequest, (method, params) => {
|
|
294
|
+
if (method !== 'session/update')
|
|
295
|
+
return;
|
|
296
|
+
const sessionId = params['sessionId'];
|
|
297
|
+
if (sessionId !== this.sessionId)
|
|
298
|
+
return;
|
|
299
|
+
const update = (params['update'] ?? {});
|
|
300
|
+
if (update['sessionUpdate'] === 'config_option_update')
|
|
301
|
+
this.modelConfiguration = kimiNativeConfiguration(update);
|
|
302
|
+
this.onSessionUpdate?.(update);
|
|
303
|
+
}, requestMs);
|
|
304
|
+
child.stderr?.on('data', (chunk) => {
|
|
305
|
+
this.stderrTail = (this.stderrTail + this.stderrDecoder.write(chunk)).slice(-4096);
|
|
306
|
+
});
|
|
307
|
+
void child.done.then(() => { this.markDead(); }, () => { this.markDead(); });
|
|
308
|
+
}
|
|
309
|
+
get diagnostics() {
|
|
310
|
+
return this.stderrTail.trim();
|
|
311
|
+
}
|
|
312
|
+
markDead() {
|
|
313
|
+
if (this.dead)
|
|
314
|
+
return;
|
|
315
|
+
this.dead = true;
|
|
316
|
+
this.peer.close();
|
|
317
|
+
this.onDead?.();
|
|
318
|
+
}
|
|
319
|
+
/** Graceful teardown: close the wire, EOF stdin, grace, then the terminate ladder. */
|
|
320
|
+
async reclaim() {
|
|
321
|
+
if (this.reclaimed)
|
|
322
|
+
return;
|
|
323
|
+
this.reclaimed = true;
|
|
324
|
+
this.peer.close();
|
|
325
|
+
if (!this.dead) {
|
|
326
|
+
this.child.stdin?.end();
|
|
327
|
+
await Promise.race([this.child.done.catch(() => { }), delay(RECLAIM_EOF_GRACE_MS)]);
|
|
328
|
+
}
|
|
329
|
+
if (!this.dead) {
|
|
330
|
+
// Host 0.1.5 hides the child pid; terminate() is an idempotent no-op
|
|
331
|
+
// once the managed range is gone.
|
|
332
|
+
this.child.terminate();
|
|
333
|
+
await this.child.waitForExit();
|
|
334
|
+
}
|
|
335
|
+
await this.child.done.catch(() => { });
|
|
336
|
+
this.markDead();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The kimi live driver owns every resident ACP runtime of this provider
|
|
341
|
+
* generation. Created only when `live: true`; disposed with the plugin.
|
|
342
|
+
*/
|
|
343
|
+
export class KimiAcpLiveDriver {
|
|
344
|
+
ctx;
|
|
345
|
+
config;
|
|
346
|
+
timeouts;
|
|
347
|
+
runtimes = new Map();
|
|
348
|
+
ensuring = new Map();
|
|
349
|
+
/** The model each in-flight spawn is binding (a stale-model spawn never serves a switched member). */
|
|
350
|
+
ensuringModel = new Map();
|
|
351
|
+
ensuringConfiguration = new Map();
|
|
352
|
+
idleTimers = new Map();
|
|
353
|
+
/** Per-member round serialization (the resume lock covers resume-vs-resume only). */
|
|
354
|
+
roundChains = new Map();
|
|
355
|
+
channelBrokenAt;
|
|
356
|
+
disposed = false;
|
|
357
|
+
/**
|
|
358
|
+
* Set by drain() (a settings-driven generation handoff): new rounds are
|
|
359
|
+
* refused so the provider falls back to exec, while in-flight rounds finish
|
|
360
|
+
* on their runtime undisturbed. Unlike `disposed`, the driver still serves
|
|
361
|
+
* what it already accepted.
|
|
362
|
+
*/
|
|
363
|
+
draining = false;
|
|
364
|
+
disposeController = new AbortController();
|
|
365
|
+
constructor(ctx, config = {}, timeouts = DEFAULT_TIMEOUTS) {
|
|
366
|
+
this.ctx = ctx;
|
|
367
|
+
this.config = config;
|
|
368
|
+
this.timeouts = timeouts;
|
|
369
|
+
}
|
|
370
|
+
/** Whether the channel is in its post-failure cooldown (rounds fall back to exec). */
|
|
371
|
+
get disabled() {
|
|
372
|
+
return this.channelBrokenAt !== undefined
|
|
373
|
+
&& Date.now() - this.channelBrokenAt < (this.timeouts.channelRetryMs ?? DEFAULT_LIVE_CHANNEL_RETRY_MS);
|
|
374
|
+
}
|
|
375
|
+
/** Count of resident runtimes currently registered (zombie accounting in tests). */
|
|
376
|
+
get liveCount() {
|
|
377
|
+
return this.runtimes.size;
|
|
378
|
+
}
|
|
379
|
+
clearIdleTimer(key) {
|
|
380
|
+
const timer = this.idleTimers.get(key);
|
|
381
|
+
if (timer !== undefined)
|
|
382
|
+
clearTimeout(timer);
|
|
383
|
+
this.idleTimers.delete(key);
|
|
384
|
+
}
|
|
385
|
+
armIdleTimer(key) {
|
|
386
|
+
this.clearIdleTimer(key);
|
|
387
|
+
const idleMs = this.config.liveIdleMs ?? DEFAULT_LIVE_IDLE_MS;
|
|
388
|
+
const timer = setTimeout(() => { void this.reclaim(key); }, idleMs);
|
|
389
|
+
timer.unref();
|
|
390
|
+
this.idleTimers.set(key, timer);
|
|
391
|
+
}
|
|
392
|
+
async reclaim(key) {
|
|
393
|
+
const runtime = this.runtimes.get(key);
|
|
394
|
+
this.clearIdleTimer(key);
|
|
395
|
+
if (runtime === undefined)
|
|
396
|
+
return;
|
|
397
|
+
this.runtimes.delete(key);
|
|
398
|
+
await runtime.reclaim();
|
|
399
|
+
}
|
|
400
|
+
/** Reclaim every runtime (plugin unload); no process survives the profile. */
|
|
401
|
+
async disposeAll() {
|
|
402
|
+
this.disposed = true;
|
|
403
|
+
this.disposeController.abort();
|
|
404
|
+
await Promise.all([...this.ensuring.values()].map(pending => pending.catch(() => undefined)));
|
|
405
|
+
for (const key of [...this.runtimes.keys()]) {
|
|
406
|
+
await this.reclaim(key);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* True while the member's runtime exists or is being spawned. The settings
|
|
411
|
+
* controller gates a new generation on this: a fresh driver must not serve
|
|
412
|
+
* a member whose retiring generation still hosts the (same) kimi session.
|
|
413
|
+
*/
|
|
414
|
+
hasRuntime(key) {
|
|
415
|
+
return this.runtimes.has(key) || this.ensuring.has(key);
|
|
416
|
+
}
|
|
417
|
+
/** @deprecated Compatibility no-op: live output is always incremental. */
|
|
418
|
+
setLiveMirrorGranularity(_granularity) { }
|
|
419
|
+
/**
|
|
420
|
+
* Drain for a settings-driven generation handoff: refuse new rounds (the
|
|
421
|
+
* provider's catch falls back to exec), let every in-flight round finish on
|
|
422
|
+
* its runtime, then reclaim. Resolves when no runtime or spawn remains.
|
|
423
|
+
* Unlike disposeAll, in-flight work is never interrupted.
|
|
424
|
+
*/
|
|
425
|
+
async drain() {
|
|
426
|
+
this.draining = true;
|
|
427
|
+
// Snapshot: rounds chain synchronously at startRound, so every accepted
|
|
428
|
+
// round is already in roundChains; anything later is refused.
|
|
429
|
+
const keys = new Set([...this.runtimes.keys(), ...this.ensuring.keys(), ...this.roundChains.keys()]);
|
|
430
|
+
await Promise.all([...keys].map(async (key) => {
|
|
431
|
+
await (this.roundChains.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
432
|
+
await (this.ensuring.get(key) ?? Promise.resolve()).catch(() => undefined);
|
|
433
|
+
await this.reclaim(key);
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
/** The model a runtime spawned for this member right now would bind. */
|
|
437
|
+
resolveBoundModel(key) {
|
|
438
|
+
const model = this.config.model?.(key)?.trim();
|
|
439
|
+
return model === undefined || model === '' ? undefined : model;
|
|
440
|
+
}
|
|
441
|
+
ensureRuntime(spec, signal) {
|
|
442
|
+
const key = String(spec.childSession.id);
|
|
443
|
+
const model = spec.configuration === undefined ? this.resolveBoundModel(key) : spec.configuration.model;
|
|
444
|
+
const configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
|
|
445
|
+
const existing = this.runtimes.get(key);
|
|
446
|
+
if (existing !== undefined && !existing.dead && existing.boundModel === model
|
|
447
|
+
&& existing.configurationKey === configurationKey) {
|
|
448
|
+
this.clearIdleTimer(key);
|
|
449
|
+
return Promise.resolve(existing);
|
|
450
|
+
}
|
|
451
|
+
// A model switch retires the member's runtime: the resident process bound
|
|
452
|
+
// its model at spawn, so the next round respawns onto the new model and
|
|
453
|
+
// session/loads the SAME CLI session (the conversation carries over).
|
|
454
|
+
if (existing !== undefined) {
|
|
455
|
+
this.runtimes.delete(key);
|
|
456
|
+
this.clearIdleTimer(key);
|
|
457
|
+
if (!existing.dead)
|
|
458
|
+
void existing.reclaim();
|
|
459
|
+
}
|
|
460
|
+
const pending = this.ensuring.get(key);
|
|
461
|
+
if (pending !== undefined && this.ensuringModel.get(key) === model && this.ensuringConfiguration.get(key) === configurationKey)
|
|
462
|
+
return pending;
|
|
463
|
+
// A stale-model spawn in flight: chain behind it, then spawn onto the new
|
|
464
|
+
// model (the chained spawn reclaims the stale runtime via the mismatch
|
|
465
|
+
// path on its own ensure).
|
|
466
|
+
const spawn = (pending ?? Promise.resolve())
|
|
467
|
+
.catch(() => undefined)
|
|
468
|
+
.then(async (stale) => {
|
|
469
|
+
if (stale !== undefined && !stale.dead) {
|
|
470
|
+
if (this.runtimes.get(key) === stale)
|
|
471
|
+
this.runtimes.delete(key);
|
|
472
|
+
await stale.reclaim();
|
|
473
|
+
}
|
|
474
|
+
return this.spawnRuntime(spec, signal, model);
|
|
475
|
+
})
|
|
476
|
+
.finally(() => {
|
|
477
|
+
if (this.ensuring.get(key) === spawn) {
|
|
478
|
+
this.ensuring.delete(key);
|
|
479
|
+
this.ensuringModel.delete(key);
|
|
480
|
+
this.ensuringConfiguration.delete(key);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
this.ensuring.set(key, spawn);
|
|
484
|
+
this.ensuringModel.set(key, model);
|
|
485
|
+
this.ensuringConfiguration.set(key, configurationKey);
|
|
486
|
+
return spawn;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Retire one member's resident runtime (a composer-driven model switch):
|
|
490
|
+
* the process is reclaimed and the member's next round respawns onto the
|
|
491
|
+
* new model, resuming the same CLI session. A member with no runtime (or
|
|
492
|
+
* only a dead one) is a no-op. Lazy: nothing spawns here.
|
|
493
|
+
*/
|
|
494
|
+
async retireRuntime(childSessionId) {
|
|
495
|
+
const pending = this.ensuring.get(childSessionId);
|
|
496
|
+
if (pending !== undefined)
|
|
497
|
+
await pending.catch(() => undefined);
|
|
498
|
+
await this.reclaim(childSessionId);
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* The model the member's live runtime is bound to, or undefined when the
|
|
502
|
+
* member has no live runtime (or it bound none). The broker compares this
|
|
503
|
+
* against the post-switch effective model to make a same-model set a no-op.
|
|
504
|
+
*/
|
|
505
|
+
runtimeModel(childSessionId) {
|
|
506
|
+
const runtime = this.runtimes.get(childSessionId);
|
|
507
|
+
return runtime === undefined || runtime.dead ? undefined : runtime.boundModel;
|
|
508
|
+
}
|
|
509
|
+
runtimeConfiguration(childSessionId) {
|
|
510
|
+
const runtime = this.runtimes.get(childSessionId);
|
|
511
|
+
return runtime === undefined || runtime.dead ? undefined : runtime.modelConfiguration;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Spawn the member's resident `kimi acp` and prove the wire: initialize +
|
|
515
|
+
* the loadSession capability (crash recovery needs it — without it the
|
|
516
|
+
* channel is not live-capable and the breaker trips). A cancellation
|
|
517
|
+
* mid-spawn reclaims the half-started process without touching the breaker.
|
|
518
|
+
*/
|
|
519
|
+
async spawnRuntime(spec, signal, boundModel) {
|
|
520
|
+
const key = String(spec.childSession.id);
|
|
521
|
+
if (this.disposed) {
|
|
522
|
+
throw new LiveChannelUnavailableError('the live driver is disposed');
|
|
523
|
+
}
|
|
524
|
+
// The member bridge rides the ACP session's inline mcpServers declaration
|
|
525
|
+
// (NOT the scoped mcp.json — that file is the exec path's per-run
|
|
526
|
+
// channel); the resident process serves one member, so its token lives
|
|
527
|
+
// with the process.
|
|
528
|
+
const member = this.registerMember(spec);
|
|
529
|
+
// `kimi acp` has no `-m` (verified against kimi 0.39.1), so the resident
|
|
530
|
+
// path pins the model the only way the CLI offers: the scoped config's
|
|
531
|
+
// `default_model`, rewritten before the process reads it. The write is
|
|
532
|
+
// surgical and idempotent — a runtime already on this model rewrites
|
|
533
|
+
// nothing. Best-effort: a home whose config cannot be written still gets
|
|
534
|
+
// its runtime, running whatever the config already named, and the round's
|
|
535
|
+
// model read-back is what catches the mismatch.
|
|
536
|
+
if (spec.configuration === undefined && boundModel !== undefined) {
|
|
537
|
+
await writeKimiDefaultModel(spec.homeDir, boundModel).catch((error) => {
|
|
538
|
+
this.ctx.logger.warn(`local-agent-kimi: pinning default_model for the resident runtime failed: ${thrown(error).message}`);
|
|
539
|
+
return false;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const spawnSpec = {
|
|
543
|
+
argv: ['kimi', ...spec.configuration?.model === undefined ? [] : ['--model', spec.configuration.model], 'acp'],
|
|
544
|
+
cwd: spec.cwd,
|
|
545
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
546
|
+
graceMs: DEFAULT_DISPOSE_GRACE_MS,
|
|
547
|
+
env: delegationEnv({ KIMI_CODE_HOME: spec.homeDir }),
|
|
548
|
+
};
|
|
549
|
+
let child;
|
|
550
|
+
try {
|
|
551
|
+
child = this.ctx.subprocess.spawn(spawnSpec);
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
member?.release();
|
|
555
|
+
this.markChannelBroken();
|
|
556
|
+
throw new LiveChannelUnavailableError(`kimi acp failed to spawn: ${thrown(error).message}`);
|
|
557
|
+
}
|
|
558
|
+
const runtime = new KimiLiveRuntime(child, (method, params) => this.answerServerRequest(method, params), message => { this.ctx.logger.warn(message); }, this.timeouts.requestMs);
|
|
559
|
+
runtime.onDead = () => {
|
|
560
|
+
// Delete only OUR registration (crash-then-respawn interleave safety).
|
|
561
|
+
if (this.runtimes.get(key) === runtime)
|
|
562
|
+
this.runtimes.delete(key);
|
|
563
|
+
this.clearIdleTimer(key);
|
|
564
|
+
member?.release();
|
|
565
|
+
};
|
|
566
|
+
const aborted = new Promise((_, reject) => {
|
|
567
|
+
const cancel = () => { reject(new Error('subagent-kimi: run cancelled locally')); };
|
|
568
|
+
const unload = () => { reject(new LiveChannelUnavailableError('the live driver was disposed during spawn')); };
|
|
569
|
+
if (signal.aborted) {
|
|
570
|
+
cancel();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (this.disposeController.signal.aborted) {
|
|
574
|
+
unload();
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
578
|
+
this.disposeController.signal.addEventListener('abort', unload, { once: true });
|
|
579
|
+
});
|
|
580
|
+
aborted.catch(() => { });
|
|
581
|
+
try {
|
|
582
|
+
const hello = await Promise.race([
|
|
583
|
+
runtime.peer.request('initialize', {
|
|
584
|
+
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
585
|
+
// Advertise NO optional client capabilities (no fs, no terminal):
|
|
586
|
+
// the child self-serves in its own process.
|
|
587
|
+
clientCapabilities: {},
|
|
588
|
+
}, this.timeouts.initializeMs),
|
|
589
|
+
aborted,
|
|
590
|
+
]);
|
|
591
|
+
if (hello?.agentCapabilities?.loadSession !== true) {
|
|
592
|
+
throw new Error('kimi acp does not advertise loadSession (crash recovery requires it)');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
await runtime.reclaim();
|
|
597
|
+
if (signal.aborted)
|
|
598
|
+
throw thrown(error);
|
|
599
|
+
this.markChannelBroken();
|
|
600
|
+
throw new LiveChannelUnavailableError(`the kimi acp handshake failed: ${thrown(error).message}`);
|
|
601
|
+
}
|
|
602
|
+
if (this.disposed) {
|
|
603
|
+
await runtime.reclaim();
|
|
604
|
+
throw new LiveChannelUnavailableError('the live driver was disposed during spawn');
|
|
605
|
+
}
|
|
606
|
+
this.channelBrokenAt = undefined;
|
|
607
|
+
runtime.boundModel = boundModel;
|
|
608
|
+
runtime.configurationKey = spec.configuration === undefined ? undefined : JSON.stringify(spec.configuration);
|
|
609
|
+
this.runtimes.set(key, runtime);
|
|
610
|
+
// Stash the member handle for the session declarations.
|
|
611
|
+
runtimeMember.set(runtime, member);
|
|
612
|
+
return runtime;
|
|
613
|
+
}
|
|
614
|
+
markChannelBroken() {
|
|
615
|
+
this.channelBrokenAt = Date.now();
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Report an auth-shaped round failure to the family registry's auth-failure
|
|
619
|
+
* mark (the exec path's post-exit 401 detection ported to a process that
|
|
620
|
+
* never exits: the match runs against the settled error instead). Degrades
|
|
621
|
+
* silently on a core predating reportAuthFailure.
|
|
622
|
+
*/
|
|
623
|
+
reportAuthIfShaped(error, homeDir) {
|
|
624
|
+
if (!KIMI_LIVE_AUTH_FAILURE.test(error.message))
|
|
625
|
+
return;
|
|
626
|
+
// The sentinel: if the credential file was wiped into an empty shell, a
|
|
627
|
+
// restore here makes the caller's retry (or the next round) succeed.
|
|
628
|
+
if (homeDir !== undefined) {
|
|
629
|
+
void guardKimiCredential(homeDir, message => { this.ctx.logger.warn(message); });
|
|
630
|
+
}
|
|
631
|
+
// Duck-typed: the method lands with the auth-truthfulness core; older
|
|
632
|
+
// cores simply skip the mark.
|
|
633
|
+
const registry = this.ctx.localAgent;
|
|
634
|
+
if (typeof registry.reportAuthFailure === 'function') {
|
|
635
|
+
registry.reportAuthFailure('kimi', error.message.split('\n').find(line => line.trim() !== '') ?? error.message);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Register the member channel for one resident process (token + bridge
|
|
640
|
+
* coordinates), duck-typed like the exec path's memberRun but WITHOUT the
|
|
641
|
+
* mcp.json write — the ACP session declares the bridge inline.
|
|
642
|
+
*/
|
|
643
|
+
registerMember(spec) {
|
|
644
|
+
const registry = this.ctx.localAgent;
|
|
645
|
+
if (typeof registry.registerMemberRun !== 'function'
|
|
646
|
+
|| typeof registry.memberBridgeSocketPath !== 'function'
|
|
647
|
+
|| typeof registry.memberBridgeCommand !== 'function')
|
|
648
|
+
return undefined;
|
|
649
|
+
const token = registry.registerMemberRun({
|
|
650
|
+
childSessionId: String(spec.childSession.id),
|
|
651
|
+
parentSessionId: spec.parentSessionId,
|
|
652
|
+
provider: 'kimi-cli',
|
|
653
|
+
});
|
|
654
|
+
const bridge = registry.memberBridgeCommand();
|
|
655
|
+
let released = false;
|
|
656
|
+
return {
|
|
657
|
+
mcpServers: [{
|
|
658
|
+
name: 'dsh-member',
|
|
659
|
+
command: bridge.command,
|
|
660
|
+
args: [...bridge.args],
|
|
661
|
+
env: [
|
|
662
|
+
{ name: MEMBER_BRIDGE_SOCKET_ENV, value: registry.memberBridgeSocketPath() },
|
|
663
|
+
{ name: MEMBER_BRIDGE_TOKEN_ENV, value: token },
|
|
664
|
+
],
|
|
665
|
+
}],
|
|
666
|
+
release: () => {
|
|
667
|
+
if (released)
|
|
668
|
+
return;
|
|
669
|
+
released = true;
|
|
670
|
+
registry.unregisterMemberRun(token);
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Auto-answer the ACP permission surface unattended. `session/request_permission`
|
|
676
|
+
* selects the first allow option (allow_once/allow_always) — matching the
|
|
677
|
+
* exec mode's auto-approve behavior for `kimi -p` — and cancels when the
|
|
678
|
+
* child offered none. Anything else (fs/terminal, which we never
|
|
679
|
+
* advertised) is an error response.
|
|
680
|
+
*/
|
|
681
|
+
answerServerRequest(method, params) {
|
|
682
|
+
if (method === 'session/request_permission') {
|
|
683
|
+
const options = Array.isArray(params['options']) ? params['options'] : [];
|
|
684
|
+
const allow = options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always');
|
|
685
|
+
if (allow !== undefined && typeof allow.optionId === 'string') {
|
|
686
|
+
return { outcome: { outcome: 'selected', optionId: allow.optionId } };
|
|
687
|
+
}
|
|
688
|
+
return { outcome: { outcome: 'cancelled' } };
|
|
689
|
+
}
|
|
690
|
+
throw new Error(`subagent-kimi live: unsupported ACP request ${method}`);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Drive one delegation round on the member's resident ACP session. Mirrors
|
|
694
|
+
* the exec run's settlement contract exactly (settleRunResult + turn/end
|
|
695
|
+
* bookkeeping); cancel is `session/cancel` and the process survives.
|
|
696
|
+
*/
|
|
697
|
+
/** Initialize the native session and model controls without sending a prompt. */
|
|
698
|
+
async prepare(spec, signal) {
|
|
699
|
+
signal.throwIfAborted();
|
|
700
|
+
if (this.draining || this.disabled)
|
|
701
|
+
throw new LiveChannelUnavailableError('the live driver is unavailable for preparation');
|
|
702
|
+
try {
|
|
703
|
+
const runtime = await this.ensureRuntime(spec, signal);
|
|
704
|
+
await this.configureSession(runtime, spec);
|
|
705
|
+
signal.throwIfAborted();
|
|
706
|
+
this.armIdleTimer(String(spec.childSession.id));
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
await this.reclaim(String(spec.childSession.id));
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
async configureSession(rt, spec) {
|
|
714
|
+
const member = runtimeMember.get(rt);
|
|
715
|
+
if (rt.sessionId === undefined) {
|
|
716
|
+
if (spec.resume === undefined) {
|
|
717
|
+
const response = await rt.peer.request('session/new', {
|
|
718
|
+
cwd: spec.cwd,
|
|
719
|
+
mcpServers: member?.mcpServers ?? [],
|
|
720
|
+
});
|
|
721
|
+
if (typeof response?.sessionId !== 'string') {
|
|
722
|
+
throw new Error('subagent-kimi live: session/new returned no session id');
|
|
723
|
+
}
|
|
724
|
+
rt.sessionId = response.sessionId;
|
|
725
|
+
rt.modelConfiguration = kimiNativeConfiguration(response);
|
|
726
|
+
// The record convention is the bare uuid (the exec path's
|
|
727
|
+
// settle-time parse); the ACP id is a directory name
|
|
728
|
+
// (`session_<uuid>`). Strip before recording so live- and
|
|
729
|
+
// exec-written records stay interchangeable.
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
// Resume: the record may be bare (exec-written) or carry the ACP
|
|
733
|
+
// prefix (legacy live records); the wire always wants the
|
|
734
|
+
// ACP-native form.
|
|
735
|
+
rt.sessionId = acpKimiSessionId(spec.resume.cliSessionId);
|
|
736
|
+
const response = await rt.peer.request('session/load', {
|
|
737
|
+
sessionId: rt.sessionId,
|
|
738
|
+
cwd: spec.cwd,
|
|
739
|
+
mcpServers: member?.mcpServers ?? [],
|
|
740
|
+
});
|
|
741
|
+
rt.modelConfiguration = kimiNativeConfiguration(response);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (spec.configuration !== undefined) {
|
|
745
|
+
rt.modelConfiguration = await configureKimiSession(rt.sessionId, rt.modelConfiguration, spec.configuration, (method, params) => rt.peer.request(method, params));
|
|
746
|
+
}
|
|
747
|
+
if (spec.resume === undefined && rt.sessionId !== undefined)
|
|
748
|
+
spec.onCliSessionId?.(bareKimiSessionId(rt.sessionId));
|
|
749
|
+
}
|
|
750
|
+
async startRound(request, spec) {
|
|
751
|
+
// A draining generation refuses new rounds BEFORE chaining so the
|
|
752
|
+
// provider's exec fallback does not queue behind an in-flight round.
|
|
753
|
+
if (this.draining) {
|
|
754
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
755
|
+
}
|
|
756
|
+
const key = String(spec.childSession.id);
|
|
757
|
+
const previous = this.roundChains.get(key) ?? Promise.resolve();
|
|
758
|
+
const round = previous.catch(() => { }).then(() => this.startRoundLocked(request, spec));
|
|
759
|
+
this.roundChains.set(key, round.then(handle => handle.result.catch(() => ({})), () => ({})));
|
|
760
|
+
return round;
|
|
761
|
+
}
|
|
762
|
+
/** The serialized round body. */
|
|
763
|
+
async startRoundLocked(request, spec) {
|
|
764
|
+
const task = textTask(request.prompt);
|
|
765
|
+
if (request.signal.aborted) {
|
|
766
|
+
throw new Error('subagent-kimi: request was aborted before the run started');
|
|
767
|
+
}
|
|
768
|
+
// The drain race: the round chained before drain() but dequeued after it.
|
|
769
|
+
// Refuse so the provider falls back to exec instead of reusing a runtime
|
|
770
|
+
// the handoff is about to reclaim.
|
|
771
|
+
if (this.draining) {
|
|
772
|
+
throw new LiveChannelUnavailableError('the live driver is draining (a settings change retired this generation)');
|
|
773
|
+
}
|
|
774
|
+
if (this.disposed) {
|
|
775
|
+
throw new Error('subagent-kimi: the live driver is disposed');
|
|
776
|
+
}
|
|
777
|
+
const turn = spec.resume?.turn ?? 1;
|
|
778
|
+
const childSession = spec.childSession;
|
|
779
|
+
const localAgent = this.ctx.get('localAgent');
|
|
780
|
+
const runAbort = new AbortController();
|
|
781
|
+
let roundSettled = false;
|
|
782
|
+
let runtime;
|
|
783
|
+
/** True while this round's session/prompt is in flight (cancel targets it). */
|
|
784
|
+
let promptInFlight = false;
|
|
785
|
+
let turnOpened = false;
|
|
786
|
+
/** The round's accumulated assistant text (the run output — chunks are the only source). */
|
|
787
|
+
let roundText = '';
|
|
788
|
+
/**
|
|
789
|
+
* The step ledger: every step a stream reservation consumed this round
|
|
790
|
+
* (in increasing order — permanently, whether the stream later completes
|
|
791
|
+
* or stays an orphan snapshot). The file fold skips these steps for its
|
|
792
|
+
* sequential folds, so a tool card folded mid-stream lands ABOVE the
|
|
793
|
+
* stream's step and the projection renders the round's chronological
|
|
794
|
+
* order.
|
|
795
|
+
*/
|
|
796
|
+
const reservedSteps = [];
|
|
797
|
+
/**
|
|
798
|
+
* The streaming items seen this round . kimi's ACP
|
|
799
|
+
* chunks carry no item id, so the stream key is synthetic per kind per
|
|
800
|
+
* round (the codex live driver's fallback shape): thought chunks share
|
|
801
|
+
* one stream, message chunks another. Deltas accumulate into throttled
|
|
802
|
+
* snapshot assistant/messages appended at the stream's reserved
|
|
803
|
+
* (turn, step) — the host folds repeated settles at one coordinate into
|
|
804
|
+
* one live-updating chat node. The wire line that completes the stream's
|
|
805
|
+
* kind folds at the same step and finalizes it; an entry whose completion
|
|
806
|
+
* never folds is force-finalized at settle.
|
|
807
|
+
*/
|
|
808
|
+
const streams = new Map();
|
|
809
|
+
/** The stream the latest delta accumulated into (a kind switch force-snapshots it). */
|
|
810
|
+
let activeStream;
|
|
811
|
+
/**
|
|
812
|
+
* The round's usage NOT carried by a folded line, summed across mirror
|
|
813
|
+
* passes (each pass's window is disjoint). The settle's final snapshot
|
|
814
|
+
* carries the remainder when no folded message did — host 0.1.5 has no
|
|
815
|
+
* usage-backfill event.
|
|
816
|
+
*/
|
|
817
|
+
let roundUsage;
|
|
818
|
+
/**
|
|
819
|
+
* The model identifier the wire named this round (a mirror pass's
|
|
820
|
+
* `usage.record` / `llm.request` `model`, last one seen), reported as the
|
|
821
|
+
* round's settled observation — the live drive's half of the exec path's
|
|
822
|
+
* settle-mirror read-back.
|
|
823
|
+
*/
|
|
824
|
+
let roundModel;
|
|
825
|
+
let lastMirrorAt = 0;
|
|
826
|
+
let mirrorQueue = Promise.resolve();
|
|
827
|
+
let persistQueue = Promise.resolve();
|
|
828
|
+
const persist = () => {
|
|
829
|
+
persistQueue = persistQueue.then(() => persistChildSession(this.ctx, childSession)).catch((error) => {
|
|
830
|
+
this.ctx.logger.warn(`subagent-kimi: live persistence failed: ${thrown(error).message}`);
|
|
831
|
+
});
|
|
832
|
+
};
|
|
833
|
+
/** A stream's synthetic key: kimi's ACP deltas carry no item id, so kind + turn pairs them. */
|
|
834
|
+
let generation = 0;
|
|
835
|
+
const seenToolCalls = new Set();
|
|
836
|
+
const toolSteps = new Map();
|
|
837
|
+
const streamKey = (kind) => `kimi-stream-${kind}-${turn}-${generation}`;
|
|
838
|
+
/** The plan stream's key: a snapshot stream beside the think/text delta streams. */
|
|
839
|
+
const planKey = `kimi-stream-plan-${turn}`;
|
|
840
|
+
/**
|
|
841
|
+
* Fold one non-text chunk as a placeholder: image/resource_link content
|
|
842
|
+
* has no text channel, so the transcript keeps a visible marker (and the
|
|
843
|
+
* run output notes the drop) instead of the chunk vanishing. Flows exactly
|
|
844
|
+
* like a text delta from here.
|
|
845
|
+
*/
|
|
846
|
+
const foldDelta = (text) => {
|
|
847
|
+
roundText += text;
|
|
848
|
+
const key = streamKey('text');
|
|
849
|
+
reserveStream(key, 'text');
|
|
850
|
+
const stream = streams.get(key);
|
|
851
|
+
if (stream !== undefined) {
|
|
852
|
+
stream.text += text;
|
|
853
|
+
appendStreamSnapshot(stream, false, false);
|
|
854
|
+
}
|
|
855
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
|
|
856
|
+
};
|
|
857
|
+
/**
|
|
858
|
+
* Render one ACP plan snapshot ({entries: [{content, status}]}) as
|
|
859
|
+
* think-style lines. Each update carries the WHOLE plan, so the fold
|
|
860
|
+
* replaces rather than appends.
|
|
861
|
+
*/
|
|
862
|
+
const renderPlan = (update) => {
|
|
863
|
+
const entries = Array.isArray(update['entries']) ? update['entries'] : [];
|
|
864
|
+
const lines = entries.map((entry) => {
|
|
865
|
+
const content = typeof entry['content'] === 'string' ? entry['content'] : '';
|
|
866
|
+
const status = entry['status'];
|
|
867
|
+
const mark = status === 'completed' ? '☑' : status === 'in_progress' ? '▶' : '☐';
|
|
868
|
+
return `${mark} ${content}`;
|
|
869
|
+
}).filter(line => line.trim() !== '☐' && line !== '');
|
|
870
|
+
return lines.join('\n');
|
|
871
|
+
};
|
|
872
|
+
const foldPlan = (update) => {
|
|
873
|
+
const plan = renderPlan(update);
|
|
874
|
+
if (plan === '')
|
|
875
|
+
return;
|
|
876
|
+
// The plan's own snapshot stream (think-styled): every update replaces
|
|
877
|
+
// the text and lands a snapshot at the reserved (turn, step); the
|
|
878
|
+
// stream force-finalizes at settle (the wire carries no plan line
|
|
879
|
+
// that could complete it).
|
|
880
|
+
reserveStream(planKey, 'think');
|
|
881
|
+
const stream = streams.get(planKey);
|
|
882
|
+
if (stream !== undefined && stream.text !== plan) {
|
|
883
|
+
stream.text = plan;
|
|
884
|
+
appendStreamSnapshot(stream, true, false);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Append one snapshot of a streaming item at its reserved (turn, step).
|
|
889
|
+
* Batched per stream on a bounded deadline unless `force`; the forced
|
|
890
|
+
* final snapshot carries `interrupted` (a cancelled turn reads 已停止
|
|
891
|
+
* legitimately) and, when `withUsage`, the round's uncarried usage. An
|
|
892
|
+
* empty stream leaves no boundary at all.
|
|
893
|
+
*/
|
|
894
|
+
const streamPublisher = localAgent?.liveStreams === undefined ? undefined
|
|
895
|
+
: new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
|
|
896
|
+
const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
|
|
897
|
+
const pendingStreamArrival = new Map();
|
|
898
|
+
const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
|
|
899
|
+
if (stream.text.trim() === '')
|
|
900
|
+
return;
|
|
901
|
+
const now = Date.now();
|
|
902
|
+
if (!force) {
|
|
903
|
+
if (!pendingStreamArrival.has(stream.step))
|
|
904
|
+
pendingStreamArrival.set(stream.step, now);
|
|
905
|
+
liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
liveFlush.cancel(stream.step);
|
|
909
|
+
const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
|
|
910
|
+
pendingStreamArrival.delete(stream.step);
|
|
911
|
+
if (streamPublisher !== undefined && !final) {
|
|
912
|
+
if (!stream.opened) {
|
|
913
|
+
childSession.append('step/start', { turn, step: stream.step });
|
|
914
|
+
stream.opened = true;
|
|
915
|
+
}
|
|
916
|
+
streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
|
|
917
|
+
stream.lastSnapshotAt = now;
|
|
918
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (!stream.opened) {
|
|
922
|
+
childSession.append('step/start', { turn, step: stream.step });
|
|
923
|
+
stream.opened = true;
|
|
924
|
+
}
|
|
925
|
+
childSession.append('assistant/message', {
|
|
926
|
+
turn,
|
|
927
|
+
step: stream.step,
|
|
928
|
+
message: assistantEvent([
|
|
929
|
+
stream.kind === 'think'
|
|
930
|
+
? { type: 'reasoning', text: stream.text }
|
|
931
|
+
: { type: 'text', text: stream.text },
|
|
932
|
+
], runtime?.modelConfiguration?.currentModel ?? roundModel),
|
|
933
|
+
stream: [],
|
|
934
|
+
...withUsage && roundUsage !== undefined ? { usage: roundUsage } : {},
|
|
935
|
+
...interrupted ? { interrupted: true } : {},
|
|
936
|
+
}, { surfaceOp: 'append' });
|
|
937
|
+
if (withUsage)
|
|
938
|
+
roundUsage = undefined;
|
|
939
|
+
stream.lastSnapshotAt = now;
|
|
940
|
+
stream.lastSnapshotLen = stream.text.length;
|
|
941
|
+
persist();
|
|
942
|
+
};
|
|
943
|
+
/**
|
|
944
|
+
* Reserve the step one streaming item will occupy — past every folded
|
|
945
|
+
* line (the session's own event ledger: the file fold writes events
|
|
946
|
+
* synchronously) and every earlier reservation. A kind switch forces one
|
|
947
|
+
* freshness snapshot of the current stream (skipped when its text has not
|
|
948
|
+
* grown since the last one); its completion still folds at its own
|
|
949
|
+
* reserved step when the wire line lands.
|
|
950
|
+
*/
|
|
951
|
+
const reserveStream = (key, kind) => {
|
|
952
|
+
if (activeStream !== undefined && activeStream !== key) {
|
|
953
|
+
const previous = streams.get(activeStream);
|
|
954
|
+
if (previous !== undefined && previous.text.length !== previous.lastSnapshotLen) {
|
|
955
|
+
appendStreamSnapshot(previous, true, false);
|
|
956
|
+
}
|
|
957
|
+
activeStream = undefined;
|
|
958
|
+
}
|
|
959
|
+
let stream = streams.get(key);
|
|
960
|
+
if (stream === undefined) {
|
|
961
|
+
let step = nextKimiSessionStep(childSession, turn);
|
|
962
|
+
for (const reserved of reservedSteps)
|
|
963
|
+
step = Math.max(step, reserved + 1);
|
|
964
|
+
reservedSteps.push(step);
|
|
965
|
+
stream = { kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
|
|
966
|
+
streams.set(key, stream);
|
|
967
|
+
}
|
|
968
|
+
activeStream = key;
|
|
969
|
+
};
|
|
970
|
+
/**
|
|
971
|
+
* The fold-pass coordination surface : the file
|
|
972
|
+
* fold skips reserved steps for sequential folds and pairs a completed
|
|
973
|
+
* think/assistant line with its stream — scoped to THIS round's turn, so
|
|
974
|
+
* an earlier round's leftover lines never consume this round's stream.
|
|
975
|
+
*/
|
|
976
|
+
const mirrorStreams = {
|
|
977
|
+
reservedSteps: t => t === turn ? reservedSteps : [],
|
|
978
|
+
completeTool: (line) => {
|
|
979
|
+
if (line.turn !== turn)
|
|
980
|
+
return undefined;
|
|
981
|
+
const step = toolSteps.get(line.id);
|
|
982
|
+
if (step === undefined)
|
|
983
|
+
return undefined;
|
|
984
|
+
toolSteps.delete(line.id);
|
|
985
|
+
return { step, opened: true };
|
|
986
|
+
},
|
|
987
|
+
completeStream: (line) => {
|
|
988
|
+
if (line.turn !== turn)
|
|
989
|
+
return undefined;
|
|
990
|
+
const kind = line.kind === 'think' ? 'think' : 'text';
|
|
991
|
+
const entry = [...streams.entries()].find(([key, stream]) => key !== planKey && stream.kind === kind);
|
|
992
|
+
if (entry === undefined)
|
|
993
|
+
return undefined;
|
|
994
|
+
const [key, stream] = entry;
|
|
995
|
+
liveFlush.cancel(stream.step);
|
|
996
|
+
streamPublisher?.finish(stream.step);
|
|
997
|
+
streams.delete(key);
|
|
998
|
+
if (activeStream === key)
|
|
999
|
+
activeStream = undefined;
|
|
1000
|
+
return { step: stream.step, opened: stream.opened };
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
const mirrorOptions = () => ({ streams: mirrorStreams });
|
|
1004
|
+
/**
|
|
1005
|
+
* Observation accounting across mirror passes: a pass whose window
|
|
1006
|
+
* attached its usage to a folded message is already carried; anything else
|
|
1007
|
+
* sums into the round's uncarried remainder (the final snapshot's payload).
|
|
1008
|
+
* The wire's model rides every pass that saw one — the settle report reads
|
|
1009
|
+
* the latest.
|
|
1010
|
+
*/
|
|
1011
|
+
const noteMirrorUsage = (delta) => {
|
|
1012
|
+
if (delta.usage !== undefined && delta.usageAttached !== true) {
|
|
1013
|
+
roundUsage = addTokenUsage(roundUsage, delta.usage);
|
|
1014
|
+
}
|
|
1015
|
+
if (delta.model !== undefined)
|
|
1016
|
+
roundModel = delta.model;
|
|
1017
|
+
};
|
|
1018
|
+
/** Throttled push-triggered mirror pass; the file fold stays the only transcript source. */
|
|
1019
|
+
const triggerMirror = () => {
|
|
1020
|
+
const now = Date.now();
|
|
1021
|
+
if (now - lastMirrorAt < this.timeouts.mirrorThrottleMs)
|
|
1022
|
+
return;
|
|
1023
|
+
lastMirrorAt = now;
|
|
1024
|
+
mirrorQueue = mirrorQueue.then(() => mirrorKimiDelta(this.ctx, childSession, spec.homeDir, runtime?.sessionId === undefined ? undefined : bareKimiSessionId(runtime.sessionId), mirrorOptions()).then((delta) => {
|
|
1025
|
+
noteMirrorUsage(delta);
|
|
1026
|
+
}).catch((error) => {
|
|
1027
|
+
this.ctx.logger.warn(`subagent-kimi: live mirror pass failed: ${thrown(error).message}`);
|
|
1028
|
+
}));
|
|
1029
|
+
};
|
|
1030
|
+
const requestCancel = () => {
|
|
1031
|
+
if (roundSettled || runAbort.signal.aborted)
|
|
1032
|
+
return;
|
|
1033
|
+
runAbort.abort(new Error('subagent-kimi: run cancelled locally'));
|
|
1034
|
+
// The live driver's core win: a graceful runtime cancel instead of a
|
|
1035
|
+
// process kill. Best-effort; local settlement does not wait for it.
|
|
1036
|
+
if (promptInFlight && runtime?.sessionId !== undefined && !runtime.dead) {
|
|
1037
|
+
runtime.peer.notify('session/cancel', { sessionId: runtime.sessionId });
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
const onAbort = () => { requestCancel(); };
|
|
1041
|
+
request.signal.addEventListener('abort', onAbort, { once: true });
|
|
1042
|
+
const abortBranch = new Promise((_, reject) => {
|
|
1043
|
+
runAbort.signal.addEventListener('abort', () => reject(new Error('subagent-kimi: run cancelled locally')), { once: true });
|
|
1044
|
+
});
|
|
1045
|
+
const collectOutput = () => {
|
|
1046
|
+
const text = roundText.trim();
|
|
1047
|
+
return text === '' ? [] : [{ type: 'text', text: roundText }];
|
|
1048
|
+
};
|
|
1049
|
+
/** The round's update sink (installed on the runtime while it runs). */
|
|
1050
|
+
const onSessionUpdate = (update) => {
|
|
1051
|
+
const kind = update['sessionUpdate'];
|
|
1052
|
+
if (kind === 'agent_message_chunk') {
|
|
1053
|
+
const content = update['content'];
|
|
1054
|
+
if (content !== undefined && content.type !== undefined && content.type !== 'text') {
|
|
1055
|
+
// A non-text chunk (image, resource_link, …): no text channel exists,
|
|
1056
|
+
// so fold a visible placeholder instead of dropping it silently.
|
|
1057
|
+
foldDelta(`[未支持的内容类型 ${content.type}]`);
|
|
1058
|
+
triggerMirror();
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const text = content?.text ?? '';
|
|
1062
|
+
if (text !== '')
|
|
1063
|
+
foldDelta(text);
|
|
1064
|
+
}
|
|
1065
|
+
else if (kind === 'agent_thought_chunk') {
|
|
1066
|
+
const content = update['content'];
|
|
1067
|
+
const text = content?.text ?? '';
|
|
1068
|
+
if (text !== '') {
|
|
1069
|
+
const key = streamKey('think');
|
|
1070
|
+
reserveStream(key, 'think');
|
|
1071
|
+
const stream = streams.get(key);
|
|
1072
|
+
if (stream !== undefined) {
|
|
1073
|
+
stream.text += text;
|
|
1074
|
+
appendStreamSnapshot(stream, false, false);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
else if (kind === 'tool_call') {
|
|
1079
|
+
const id = typeof update['toolCallId'] === 'string' ? update['toolCallId'] : undefined;
|
|
1080
|
+
if (id === undefined || !seenToolCalls.has(id)) {
|
|
1081
|
+
if (id !== undefined) {
|
|
1082
|
+
seenToolCalls.add(id);
|
|
1083
|
+
let step = nextKimiSessionStep(childSession, turn);
|
|
1084
|
+
for (const reserved of reservedSteps)
|
|
1085
|
+
step = Math.max(step, reserved + 1);
|
|
1086
|
+
reservedSteps.push(step);
|
|
1087
|
+
toolSteps.set(id, step);
|
|
1088
|
+
childSession.append('step/start', { turn, step });
|
|
1089
|
+
persist();
|
|
1090
|
+
}
|
|
1091
|
+
// ACP has no text-item ids. A new tool call separates generation
|
|
1092
|
+
// segments; late file folds consume same-kind segments in order.
|
|
1093
|
+
generation++;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
else if (kind === 'plan') {
|
|
1097
|
+
// ACP plan updates are full-plan snapshots; the wire.jsonl fold never
|
|
1098
|
+
// carries them (kimi records text/think parts only), so the driver
|
|
1099
|
+
// itself folds them — a snapshot stream in the token granularity, one
|
|
1100
|
+
// settle-time fold otherwise.
|
|
1101
|
+
foldPlan(update);
|
|
1102
|
+
}
|
|
1103
|
+
// Every update (chunks, tool calls, plans) triggers a throttled mirror
|
|
1104
|
+
// pass; the wire.jsonl fold owns the transcript content.
|
|
1105
|
+
triggerMirror();
|
|
1106
|
+
};
|
|
1107
|
+
const accepted = (async () => {
|
|
1108
|
+
const rt = await this.ensureRuntime(spec, request.signal);
|
|
1109
|
+
runtime = rt;
|
|
1110
|
+
if (runAbort.signal.aborted) {
|
|
1111
|
+
await this.reclaim(String(childSession.id));
|
|
1112
|
+
throw new Error('subagent-kimi: run cancelled locally');
|
|
1113
|
+
}
|
|
1114
|
+
try {
|
|
1115
|
+
await this.configureSession(rt, spec);
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
// The accept failed: the runtime's session state is unknown, so do
|
|
1119
|
+
// not reuse it — reclaim and fail the round loudly. The turn never
|
|
1120
|
+
// opened parent-side, so no dangling turn/start. (An auth-shaped
|
|
1121
|
+
// failure here flows through settleRunResult's onError, which owns
|
|
1122
|
+
// the registry mark — reporting here too would double it.)
|
|
1123
|
+
if (!runAbort.signal.aborted)
|
|
1124
|
+
await this.reclaim(String(childSession.id));
|
|
1125
|
+
throw thrown(error);
|
|
1126
|
+
}
|
|
1127
|
+
// ACP session/load replays historical chunks and tools. Bind this round's
|
|
1128
|
+
// sink only after loading/configuration, so replay never becomes new output.
|
|
1129
|
+
rt.onSessionUpdate = onSessionUpdate;
|
|
1130
|
+
// The turn boundary opens before the prompt goes out (exec parity: the
|
|
1131
|
+
// exec path opens at spawn). session/prompt has no separate accept ack —
|
|
1132
|
+
// the request IS the turn. The prompt's user/message lands here too
|
|
1133
|
+
// (codex/claude live parity): immediately visible instead of waiting
|
|
1134
|
+
// for the wire flush + mirror pass — which would render the streamed
|
|
1135
|
+
// think/text ABOVE the question. The fold skips it (turn+text dedupe).
|
|
1136
|
+
childSession.append('turn/start', { turn });
|
|
1137
|
+
childSession.append('user/message', createUserMessage({
|
|
1138
|
+
content: [{ type: 'text', text: task }],
|
|
1139
|
+
source: { kind: 'user' },
|
|
1140
|
+
}), { surfaceOp: 'append' });
|
|
1141
|
+
turnOpened = true;
|
|
1142
|
+
})();
|
|
1143
|
+
const attempt = accepted.then(async () => {
|
|
1144
|
+
if (runAbort.signal.aborted)
|
|
1145
|
+
throw new Error('subagent-kimi: run cancelled locally');
|
|
1146
|
+
const rt = runtime;
|
|
1147
|
+
const processFailure = rt.child.done.then(outcome => Promise.reject(new Error('subagent-kimi: the live runtime exited mid-round '
|
|
1148
|
+
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`)), (error) => Promise.reject(thrown(error)));
|
|
1149
|
+
processFailure.catch(() => { });
|
|
1150
|
+
// One prompt at a time per member: a cancelled turn converges (its
|
|
1151
|
+
// prompt settles 'cancelled') before the next prompt goes out — the
|
|
1152
|
+
// stop-then-rephrase gesture cannot interleave two turns' chunks.
|
|
1153
|
+
const sendPrompt = () => {
|
|
1154
|
+
if (runAbort.signal.aborted)
|
|
1155
|
+
return Promise.reject(new Error('subagent-kimi: run cancelled locally'));
|
|
1156
|
+
promptInFlight = true;
|
|
1157
|
+
// Turn-length request: no wire timeout.
|
|
1158
|
+
return rt.peer.request('session/prompt', {
|
|
1159
|
+
sessionId: rt.sessionId,
|
|
1160
|
+
prompt: [{ type: 'text', text: task }],
|
|
1161
|
+
}, 0);
|
|
1162
|
+
};
|
|
1163
|
+
const promptPromise = rt.turnChain.catch(() => { }).then(sendPrompt);
|
|
1164
|
+
rt.turnChain = promptPromise;
|
|
1165
|
+
const promptResult = await Promise.race([promptPromise, processFailure]);
|
|
1166
|
+
const stopReason = acpStopReasonToHarness(String(promptResult?.stopReason ?? ''));
|
|
1167
|
+
// The exec path's silent-failure guard: an end_turn with no answer is an
|
|
1168
|
+
// error, never a 'completed' success with empty output.
|
|
1169
|
+
if (stopReason === 'completed' && collectOutput().length === 0) {
|
|
1170
|
+
throw new Error('subagent-kimi live: the turn completed but produced no answer');
|
|
1171
|
+
}
|
|
1172
|
+
return { output: collectOutput(), stopReason };
|
|
1173
|
+
});
|
|
1174
|
+
/**
|
|
1175
|
+
* Settle reconciliation: the kimi wire flushes asynchronously past the
|
|
1176
|
+
* prompt response (prompt lines early, the answer by turn end), so the
|
|
1177
|
+
* final mirror folds until consecutive reads see no growth — bounded, so
|
|
1178
|
+
* a stuck flush cannot pin the round beyond the quiescence window.
|
|
1179
|
+
*/
|
|
1180
|
+
const settleMirror = async () => {
|
|
1181
|
+
await mirrorQueue.catch(() => { });
|
|
1182
|
+
const sessionId = runtime?.sessionId === undefined ? undefined : bareKimiSessionId(runtime.sessionId);
|
|
1183
|
+
let lastTotal = -1;
|
|
1184
|
+
let stableReads = 0;
|
|
1185
|
+
const deadline = Date.now() + SETTLE_MIRROR_QUIESCE_MS;
|
|
1186
|
+
for (;;) {
|
|
1187
|
+
const delta = await mirrorKimiDelta(this.ctx, childSession, spec.homeDir, sessionId, mirrorOptions());
|
|
1188
|
+
noteMirrorUsage(delta);
|
|
1189
|
+
localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
|
|
1190
|
+
stableReads = delta.total === lastTotal ? stableReads + 1 : 0;
|
|
1191
|
+
lastTotal = delta.total;
|
|
1192
|
+
if (stableReads >= SETTLE_MIRROR_STABLE_READS)
|
|
1193
|
+
break;
|
|
1194
|
+
if (Date.now() >= deadline)
|
|
1195
|
+
break;
|
|
1196
|
+
await delay(SETTLE_MIRROR_POLL_MS);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
const result = settleRunResult({
|
|
1200
|
+
attempt: () => Promise.race([attempt, abortBranch]),
|
|
1201
|
+
collectOutput,
|
|
1202
|
+
cancelled: () => runAbort.signal.aborted,
|
|
1203
|
+
onError: (error, stopReason) => {
|
|
1204
|
+
const diagnostics = runtime?.diagnostics ?? '';
|
|
1205
|
+
const suffix = diagnostics === '' ? '' : `; ${diagnostics}`;
|
|
1206
|
+
this.ctx.logger.warn(`subagent-kimi: live round failed (${stopReason}): ${error.message}${suffix}`);
|
|
1207
|
+
this.reportAuthIfShaped(error, spec.homeDir);
|
|
1208
|
+
},
|
|
1209
|
+
signal: request.signal,
|
|
1210
|
+
onAbort,
|
|
1211
|
+
}).then(async (settled) => {
|
|
1212
|
+
roundSettled = true;
|
|
1213
|
+
liveFlush.dispose();
|
|
1214
|
+
promptInFlight = false;
|
|
1215
|
+
if (turnOpened) {
|
|
1216
|
+
// Reconcile the file fold INSIDE the turn window
|
|
1217
|
+
// (bounded quiescence — the price of the wire flush race; a mirror
|
|
1218
|
+
// failure never fails the round), so every streamed item's completion
|
|
1219
|
+
// fold lands at its reserved step before turn/end. Streams whose
|
|
1220
|
+
// completing wire line never folded (abort, or a flush outlasting the
|
|
1221
|
+
// quiescence window) then finalize in place: one forced snapshot each
|
|
1222
|
+
// — interrupted on a non-completed round, the LAST one carrying the
|
|
1223
|
+
// round's uncarried usage — then step/end.
|
|
1224
|
+
try {
|
|
1225
|
+
await settleMirror();
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
this.ctx.logger.warn(`subagent-kimi: live settle mirror failed: ${thrown(error).message}`);
|
|
1229
|
+
}
|
|
1230
|
+
if (streams.size > 0) {
|
|
1231
|
+
const remaining = [...streams.values()];
|
|
1232
|
+
for (const [position, stream] of remaining.entries()) {
|
|
1233
|
+
appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
|
|
1234
|
+
streamPublisher?.finish(stream.step);
|
|
1235
|
+
if (stream.opened)
|
|
1236
|
+
childSession.append('step/end', { turn, step: stream.step });
|
|
1237
|
+
}
|
|
1238
|
+
streams.clear();
|
|
1239
|
+
activeStream = undefined;
|
|
1240
|
+
persist();
|
|
1241
|
+
}
|
|
1242
|
+
for (const step of toolSteps.values())
|
|
1243
|
+
childSession.append('step/end', { turn, step });
|
|
1244
|
+
toolSteps.clear();
|
|
1245
|
+
if (settled.stopReason === 'completed') {
|
|
1246
|
+
childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
|
|
1247
|
+
}
|
|
1248
|
+
else if (settled.stopReason === 'aborted') {
|
|
1249
|
+
childSession.append('turn/end', { turn, reason: { kind: 'aborted', reason: { kind: 'parent' } } });
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
childSession.append('turn/end', {
|
|
1253
|
+
turn,
|
|
1254
|
+
reason: { kind: 'error', error: { message: 'the live round did not complete', code: 'UNKNOWN' } },
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
streamPublisher?.dispose();
|
|
1259
|
+
// Settlement clears the round's update sink.
|
|
1260
|
+
if (runtime !== undefined)
|
|
1261
|
+
runtime.onSessionUpdate = undefined;
|
|
1262
|
+
// A completed answer is not a durable turn until its final boundary
|
|
1263
|
+
// and any closing stream checkpoints have reached the child store.
|
|
1264
|
+
if (turnOpened) {
|
|
1265
|
+
persist();
|
|
1266
|
+
await persistQueue;
|
|
1267
|
+
}
|
|
1268
|
+
return settled;
|
|
1269
|
+
});
|
|
1270
|
+
// Re-arm the reaper and publish observations after in-turn reconciliation.
|
|
1271
|
+
void result.then(() => {
|
|
1272
|
+
// Every settled round reports its observed model through the
|
|
1273
|
+
// registry's observation channel (record merge + `settled` event) —
|
|
1274
|
+
// the live drive's half of the exec path's onRoundSettled. A mirror
|
|
1275
|
+
// failure never drops an observation an earlier pass already saw, and
|
|
1276
|
+
// a round that never opened owns no span to observe. Degrades
|
|
1277
|
+
// silently on a core predating recordRoundSettled.
|
|
1278
|
+
if (turnOpened) {
|
|
1279
|
+
const round = {
|
|
1280
|
+
...roundModel === undefined ? {} : { observedModel: roundModel },
|
|
1281
|
+
};
|
|
1282
|
+
const registry = localAgent;
|
|
1283
|
+
registry?.recordRoundSettled?.(childSession.id, round);
|
|
1284
|
+
}
|
|
1285
|
+
this.armIdleTimer(String(childSession.id));
|
|
1286
|
+
});
|
|
1287
|
+
// Publication gate: hold start() until the session is ready and the
|
|
1288
|
+
// boundary open (channel errors still throw for the provider's exec
|
|
1289
|
+
// fallback), but never hold it hostage — a cancel returns immediately.
|
|
1290
|
+
await Promise.race([accepted, abortBranch.catch(() => undefined)]);
|
|
1291
|
+
return subprocessRunHandle({
|
|
1292
|
+
id: childSession.id,
|
|
1293
|
+
result,
|
|
1294
|
+
signal: request.signal,
|
|
1295
|
+
onAbort,
|
|
1296
|
+
requestCancel,
|
|
1297
|
+
// The live teardown contract: NEVER kill the runtime here. The graceful
|
|
1298
|
+
// cancel already went out; wait (bounded) for the turn to converge so
|
|
1299
|
+
// the next round's prompt starts clean.
|
|
1300
|
+
teardown: async () => {
|
|
1301
|
+
if (roundSettled)
|
|
1302
|
+
return;
|
|
1303
|
+
const rt = runtime;
|
|
1304
|
+
if (rt === undefined)
|
|
1305
|
+
return;
|
|
1306
|
+
await Promise.race([rt.turnChain.catch(() => { }), delay(this.timeouts.convergeMs)]);
|
|
1307
|
+
},
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
/** The member handle each runtime carries (for its session declarations). */
|
|
1312
|
+
const runtimeMember = new WeakMap();
|