gestalt-mobile 0.35.4 → 0.36.0
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.md +60 -3
- package/dist/client/assets/index-9XMv2BvD.js +38 -0
- package/dist/client/assets/{index-CPQBJDsg.css → index-Bxv5Z2w4.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/client/features/chat/activity-summary.js +71 -0
- package/dist/server/client/features/chat/chat-projection.js +722 -0
- package/dist/server/client/features/chat/message-store.js +30 -0
- package/dist/server/server/app.js +1 -1
- package/dist/server/server/cli.js +8 -0
- package/dist/server/server/composition.contracts.js +191 -8
- package/dist/server/server/composition.js +266 -56
- package/dist/server/server/features/autopilot/application/policy.js +2 -2
- package/dist/server/server/features/autopilot/register-routes.js +2 -2
- package/dist/server/server/features/autopilot/toggle/endpoint.js +5 -1
- package/dist/server/server/features/catalog/get-bootstrap/use-case.js +5 -3
- package/dist/server/server/features/plans/application/parse-supervised-plan.js +33 -8
- package/dist/server/server/features/sessions/get-session/endpoint.js +1 -1
- package/dist/server/server/features/sessions/list-models/endpoint.js +16 -0
- package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +8 -3
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +1 -1
- package/dist/server/server/features/sessions/model/relay-session.js +2 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +6 -2
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +7 -2
- package/dist/server/server/features/sessions/register-routes.js +8 -3
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
- package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
- package/dist/server/server/features/sessions/start-session/request.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +21 -7
- package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
- package/dist/server/server/platform/catalog/provider-model-catalog.js +15 -0
- package/dist/server/server/platform/kimi/kimi-activity-facts.js +88 -0
- package/dist/server/server/platform/kimi/kimi-errors.js +30 -0
- package/dist/server/server/platform/kimi/kimi-model-catalog.js +63 -0
- package/dist/server/server/platform/kimi/kimi-normalizer.js +196 -0
- package/dist/server/server/platform/kimi/kimi-recent-session-lister.js +68 -0
- package/dist/server/server/platform/kimi/kimi-server-request.js +143 -0
- package/dist/server/server/platform/kimi/kimi-session-runtime.js +653 -0
- package/dist/server/server/platform/kimi/kimi-share-dir.js +65 -0
- package/dist/server/server/platform/kimi/kimi-skill-catalog.js +79 -0
- package/dist/server/server/platform/kimi/kimi-skills-materializer.js +41 -0
- package/dist/server/server/platform/kimi/kimi-web-client.js +63 -0
- package/dist/server/server/platform/kimi/kimi-web-server-manager.js +215 -0
- package/dist/server/server/platform/kimi/kimi-ws-client.js +154 -0
- package/dist/server/server/platform/persistence/migrate.js +2 -0
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +3 -2
- package/dist/server/server/platform/skills/cached-skill-catalog.js +0 -0
- package/dist/server/shared/contracts/llm-provider.js +6 -0
- package/package.json +4 -3
- package/dist/client/assets/index-SZM7lpix.js +0 -38
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { RelaySession, } from '../../features/sessions/model/relay-session.js';
|
|
8
|
+
import { normalizeSkillProfileName, } from '../../features/skills/model/skill-profile.js';
|
|
9
|
+
import { WriterAcquisitionError, } from '../../features/sessions/application/writer-acquisition.js';
|
|
10
|
+
import { isKimiWebError } from './kimi-errors.js';
|
|
11
|
+
import { KimiWsClient } from './kimi-ws-client.js';
|
|
12
|
+
import { approvalToServerRequest, decodeApprovalItems, decodeQuestionItems, kimiApprovalDecision, kimiQuestionAnswers, questionToServerRequest, } from './kimi-server-request.js';
|
|
13
|
+
const KIMI_SESSION_NOT_RUNNING = 'KIMI_SESSION_NOT_RUNNING';
|
|
14
|
+
const KIMI_UNSUPPORTED = 'KIMI_UNSUPPORTED';
|
|
15
|
+
/**
|
|
16
|
+
* Drives relay sessions through a gestalt-owned `kimi web` server. Implements
|
|
17
|
+
* the same runtime surface the composition consumes from CodexSessionRuntime
|
|
18
|
+
* for the core chat path (start/restore/turns/steering/interrupt/history/
|
|
19
|
+
* approvals/questions/release). Codex-only surfaces — autopilot executors,
|
|
20
|
+
* supervised-plan measurement, child-process reconciliation — throw
|
|
21
|
+
* `KIMI_UNSUPPORTED` and are never offered for kimi sessions upstream.
|
|
22
|
+
*/
|
|
23
|
+
export class KimiSessionRuntime {
|
|
24
|
+
input;
|
|
25
|
+
sessions = new Map();
|
|
26
|
+
connections = new Map();
|
|
27
|
+
writerAcquisitions = new Map();
|
|
28
|
+
historyReads = new Map();
|
|
29
|
+
constructor(input) {
|
|
30
|
+
this.input = input;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Per-session translation context for the event normalizer and activity
|
|
34
|
+
* facts decoder: kimi per-agent turn numbers resolve to prompt turn ids,
|
|
35
|
+
* and spawned subagent ids are tracked as child agents.
|
|
36
|
+
*/
|
|
37
|
+
eventContext(sessionId) {
|
|
38
|
+
const resource = this.sessions.get(sessionId);
|
|
39
|
+
if (!resource)
|
|
40
|
+
return { resolveTurnId: () => null, isChildAgent: () => false };
|
|
41
|
+
return {
|
|
42
|
+
resolveTurnId: (agentId, turnNumber) => resource.agentTurns.get(`${agentId}:${turnNumber}`) ?? null,
|
|
43
|
+
isChildAgent: (agentId) => resource.childAgents.has(agentId),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
async start(session, now) {
|
|
47
|
+
const handle = await this.ensureServer(session);
|
|
48
|
+
const created = (await handle.client.post('/api/v1/sessions', {
|
|
49
|
+
metadata: { cwd: session.workspacePath },
|
|
50
|
+
}));
|
|
51
|
+
if (!created.id || typeof created.id !== 'string')
|
|
52
|
+
throw new Error('KIMI_SESSION_CREATE_FAILED');
|
|
53
|
+
await this.applyProfile(handle, created.id, session);
|
|
54
|
+
await this.attachResource(session, created.id, handle);
|
|
55
|
+
return RelaySession.rehydrate(session).bindThread(created.id, now).snapshot;
|
|
56
|
+
}
|
|
57
|
+
stop(sessionId) {
|
|
58
|
+
const resource = this.sessions.get(sessionId);
|
|
59
|
+
if (!resource)
|
|
60
|
+
return;
|
|
61
|
+
resource.explicitShutdown = true;
|
|
62
|
+
this.detach(sessionId);
|
|
63
|
+
}
|
|
64
|
+
async release(sessionId) {
|
|
65
|
+
const resource = this.sessions.get(sessionId);
|
|
66
|
+
if (resource)
|
|
67
|
+
resource.explicitShutdown = true;
|
|
68
|
+
await this.detach(sessionId);
|
|
69
|
+
}
|
|
70
|
+
async recycle(session, now) {
|
|
71
|
+
this.stop(session.id);
|
|
72
|
+
return this.restore(session, now);
|
|
73
|
+
}
|
|
74
|
+
stopAll() {
|
|
75
|
+
for (const resource of this.sessions.values())
|
|
76
|
+
resource.explicitShutdown = true;
|
|
77
|
+
for (const sessionId of [...this.sessions.keys()])
|
|
78
|
+
this.detach(sessionId);
|
|
79
|
+
}
|
|
80
|
+
ownsWriter(sessionId) {
|
|
81
|
+
return this.sessions.get(sessionId)?.active === true;
|
|
82
|
+
}
|
|
83
|
+
async ensureWriter(session, now) {
|
|
84
|
+
if (this.ownsWriter(session.id))
|
|
85
|
+
return { session, replacementCreated: false };
|
|
86
|
+
const inflight = this.writerAcquisitions.get(session.id);
|
|
87
|
+
if (inflight)
|
|
88
|
+
return inflight;
|
|
89
|
+
const acquisition = this.acquireWriter(session, now);
|
|
90
|
+
this.writerAcquisitions.set(session.id, acquisition);
|
|
91
|
+
try {
|
|
92
|
+
return await acquisition;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (this.writerAcquisitions.get(session.id) === acquisition)
|
|
96
|
+
this.writerAcquisitions.delete(session.id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async startTurn(session, text, clientUserMessageId, now) {
|
|
100
|
+
const resource = this.requireResource(session);
|
|
101
|
+
const promptId = clientUserMessageId ?? randomUUID();
|
|
102
|
+
await this.submitPrompt(resource, promptId, text, session.model);
|
|
103
|
+
return RelaySession.rehydrate(session).startTurn(promptId, now).snapshot;
|
|
104
|
+
}
|
|
105
|
+
/** kimi queues prompts server-side while a turn runs; submitPrompt steers
|
|
106
|
+
* the queued prompt into the active turn, which is the steering equivalent. */
|
|
107
|
+
async queueTurnInput(session, _turnId, text, clientUserMessageId) {
|
|
108
|
+
const resource = this.requireResource(session);
|
|
109
|
+
await this.submitPrompt(resource, clientUserMessageId ?? randomUUID(), text, session.model);
|
|
110
|
+
}
|
|
111
|
+
async interruptTurn(session, turnId) {
|
|
112
|
+
const resource = this.requireResource(session);
|
|
113
|
+
const handle = this.input.servers.get(resource.profileKey);
|
|
114
|
+
if (!handle)
|
|
115
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
116
|
+
await handle.client.post(`/api/v1/sessions/${resource.threadId}/prompts/${turnId}:abort`);
|
|
117
|
+
}
|
|
118
|
+
async readHistory(session) {
|
|
119
|
+
if (!session.threadId)
|
|
120
|
+
throw new Error('KIMI_THREAD_ID_MISSING');
|
|
121
|
+
const owned = this.sessions.get(session.id);
|
|
122
|
+
if (owned)
|
|
123
|
+
return this.readHistoryFrom(owned);
|
|
124
|
+
const existing = this.historyReads.get(session.id);
|
|
125
|
+
if (existing)
|
|
126
|
+
return existing;
|
|
127
|
+
const read = this.readDetachedHistory(session);
|
|
128
|
+
this.historyReads.set(session.id, read);
|
|
129
|
+
try {
|
|
130
|
+
return await read;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (this.historyReads.get(session.id) === read)
|
|
134
|
+
this.historyReads.delete(session.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async resolveServerRequest(sessionId, requestId, result) {
|
|
138
|
+
const resource = this.sessions.get(sessionId);
|
|
139
|
+
const pending = resource?.pendingInteractions.get(requestId);
|
|
140
|
+
if (!resource || !pending || pending.settling)
|
|
141
|
+
return false;
|
|
142
|
+
pending.settling = true;
|
|
143
|
+
try {
|
|
144
|
+
await this.resolveInteraction(resource, pending, result);
|
|
145
|
+
resource.pendingInteractions.delete(requestId);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
pending.settling = false;
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
rejectServerRequest(sessionId, requestId) {
|
|
154
|
+
const resource = this.sessions.get(sessionId);
|
|
155
|
+
const pending = resource?.pendingInteractions.get(requestId);
|
|
156
|
+
if (!resource || !pending || pending.settling)
|
|
157
|
+
return false;
|
|
158
|
+
pending.settling = true;
|
|
159
|
+
void this.resolveInteraction(resource, pending, 'cancel').then(() => resource.pendingInteractions.delete(requestId), () => {
|
|
160
|
+
pending.settling = false;
|
|
161
|
+
});
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
abandonServerRequest(sessionId, requestId) {
|
|
165
|
+
return this.sessions.get(sessionId)?.pendingInteractions.delete(requestId) === true;
|
|
166
|
+
}
|
|
167
|
+
attentionWriterState(sessionId, requestId) {
|
|
168
|
+
const resource = this.sessions.get(sessionId);
|
|
169
|
+
if (!resource || !resource.active)
|
|
170
|
+
return 'unavailable';
|
|
171
|
+
const pending = resource.pendingInteractions.get(requestId);
|
|
172
|
+
return pending && !pending.settling ? 'available' : 'cleared';
|
|
173
|
+
}
|
|
174
|
+
async restore(session, now) {
|
|
175
|
+
return (await this.restoreWithOutcome(session, now)).session;
|
|
176
|
+
}
|
|
177
|
+
async restoreWithOutcome(session, now) {
|
|
178
|
+
try {
|
|
179
|
+
const restored = await this.attachExisting(session);
|
|
180
|
+
return { session: restored, historyUnavailable: false, replacementCreated: false };
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (!isKimiSessionMissing(error))
|
|
184
|
+
throw error;
|
|
185
|
+
// The kimi session disappeared server-side (pruned or share-dir loss):
|
|
186
|
+
// bind a replacement thread, mirroring the codex missing-rollout policy.
|
|
187
|
+
const replacement = await this.start(session, now);
|
|
188
|
+
return { session: replacement, historyUnavailable: true, replacementCreated: true };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// --- Codex-only surfaces: kimi sessions never reach these upstream. ---
|
|
192
|
+
async startExecutorTurn() {
|
|
193
|
+
throw new Error(KIMI_UNSUPPORTED);
|
|
194
|
+
}
|
|
195
|
+
async interruptExecutor() {
|
|
196
|
+
throw new Error(KIMI_UNSUPPORTED);
|
|
197
|
+
}
|
|
198
|
+
async listDirectChildren() {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
async inspectChildProcesses() {
|
|
202
|
+
return [];
|
|
203
|
+
}
|
|
204
|
+
consumeChildProcessResult() {
|
|
205
|
+
throw new Error(KIMI_UNSUPPORTED);
|
|
206
|
+
}
|
|
207
|
+
async terminateChildProcess() {
|
|
208
|
+
throw new Error(KIMI_UNSUPPORTED);
|
|
209
|
+
}
|
|
210
|
+
async readPlanMeasurement() {
|
|
211
|
+
throw new Error(KIMI_UNSUPPORTED);
|
|
212
|
+
}
|
|
213
|
+
authorizePlanMeasurement() {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
async syncThreadPlanName() { }
|
|
217
|
+
async watchPlanStatus() { }
|
|
218
|
+
// --- internals ---
|
|
219
|
+
async acquireWriter(session, now) {
|
|
220
|
+
try {
|
|
221
|
+
const restored = await this.restoreWithOutcome(session, now);
|
|
222
|
+
return { session: restored.session, replacementCreated: restored.replacementCreated };
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw classifyWriterAcquisitionFailure(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Servers are keyed by the canonical skill selection: sessions sharing one
|
|
230
|
+
* effective selection share one kimi web process. The empty (native)
|
|
231
|
+
* selection uses the stable `default` key; anything else uses a short hash
|
|
232
|
+
* of its path-sorted entries (hex fits the skill-profile name pattern).
|
|
233
|
+
*/
|
|
234
|
+
profileKey(session) {
|
|
235
|
+
const effective = session.effectiveSkillSelection;
|
|
236
|
+
const selection = effective?.skills ?? [];
|
|
237
|
+
if (effective?.selectedProfileName)
|
|
238
|
+
return normalizeSkillProfileName(effective.selectedProfileName);
|
|
239
|
+
if (!selection.length)
|
|
240
|
+
return 'default';
|
|
241
|
+
const canonical = JSON.stringify([...selection].sort((a, b) => a.path.localeCompare(b.path)));
|
|
242
|
+
return `sel-${createHash('sha256').update(canonical).digest('hex').slice(0, 12)}`;
|
|
243
|
+
}
|
|
244
|
+
async ensureServer(session) {
|
|
245
|
+
// A recent thread imported from a running Kimi server carries that server's
|
|
246
|
+
// profile key in `profile`, but no reconstructable effective skill selection.
|
|
247
|
+
// Reuse its exact owner; never collapse it onto the default server or restart
|
|
248
|
+
// it with an empty selection.
|
|
249
|
+
if (!session.effectiveSkillSelection) {
|
|
250
|
+
const existing = this.input.servers.get(normalizeSkillProfileName(session.profile));
|
|
251
|
+
if (existing)
|
|
252
|
+
return existing;
|
|
253
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
254
|
+
}
|
|
255
|
+
const skills = await this.input.skillsFor(session);
|
|
256
|
+
return this.input.servers.ensure(this.profileKey(session), skills ?? session.effectiveSkillSelection?.skills ?? []);
|
|
257
|
+
}
|
|
258
|
+
async applyProfile(handle, threadId, session) {
|
|
259
|
+
await handle.client.post(`/api/v1/sessions/${threadId}/profile`, {
|
|
260
|
+
agent_config: {
|
|
261
|
+
...(session.model ? { model: session.model } : {}),
|
|
262
|
+
permission_mode: kimiPermissionMode(session),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async attachResource(session, threadId, handle) {
|
|
267
|
+
await this.detach(session.id);
|
|
268
|
+
const resource = {
|
|
269
|
+
sessionId: session.id,
|
|
270
|
+
profileKey: handle.profileKey,
|
|
271
|
+
threadId,
|
|
272
|
+
active: true,
|
|
273
|
+
explicitShutdown: false,
|
|
274
|
+
cursor: 0,
|
|
275
|
+
pendingPrompts: [],
|
|
276
|
+
agentTurns: new Map(),
|
|
277
|
+
activePromptId: null,
|
|
278
|
+
childAgents: new Set(),
|
|
279
|
+
mainAgentId: null,
|
|
280
|
+
pendingInteractions: new Map(),
|
|
281
|
+
interactionSequence: 0,
|
|
282
|
+
pollInFlight: null,
|
|
283
|
+
};
|
|
284
|
+
this.sessions.set(session.id, resource);
|
|
285
|
+
await this.subscribe(resource, handle);
|
|
286
|
+
return resource;
|
|
287
|
+
}
|
|
288
|
+
/** Attaches to the durable kimi session recorded on the relay snapshot. */
|
|
289
|
+
async attachExisting(session) {
|
|
290
|
+
if (!session.threadId)
|
|
291
|
+
throw new Error('KIMI_THREAD_ID_MISSING');
|
|
292
|
+
const handle = await this.ensureServer(session);
|
|
293
|
+
await handle.client.get(`/api/v1/sessions/${session.threadId}`);
|
|
294
|
+
await this.applyProfile(handle, session.threadId, session);
|
|
295
|
+
await this.attachResource(session, session.threadId, handle);
|
|
296
|
+
return session;
|
|
297
|
+
}
|
|
298
|
+
async subscribe(resource, handle) {
|
|
299
|
+
const connection = await this.ensureConnection(handle);
|
|
300
|
+
connection.sessions.add(resource.sessionId);
|
|
301
|
+
await connection.client.subscribe([resource.threadId], {
|
|
302
|
+
[resource.threadId]: { seq: resource.cursor },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
async ensureConnection(handle) {
|
|
306
|
+
const existing = this.connections.get(handle.profileKey);
|
|
307
|
+
if (existing)
|
|
308
|
+
return existing;
|
|
309
|
+
const client = this.input.wsClientFor?.(handle) ?? new KimiWsClient(handle.baseUrl, handle.token);
|
|
310
|
+
const connection = {
|
|
311
|
+
client,
|
|
312
|
+
sessions: new Set(),
|
|
313
|
+
eventUnsub: client.onEvent((event) => this.dispatchEvent(handle.profileKey, event)),
|
|
314
|
+
closeUnsub: client.onClose(() => this.handleConnectionClose(handle.profileKey)),
|
|
315
|
+
};
|
|
316
|
+
this.connections.set(handle.profileKey, connection);
|
|
317
|
+
try {
|
|
318
|
+
await client.connect();
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
connection.eventUnsub();
|
|
322
|
+
connection.closeUnsub();
|
|
323
|
+
this.connections.delete(handle.profileKey);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
return connection;
|
|
327
|
+
}
|
|
328
|
+
handleConnectionClose(profileKey) {
|
|
329
|
+
const connection = this.connections.get(profileKey);
|
|
330
|
+
if (!connection)
|
|
331
|
+
return;
|
|
332
|
+
this.connections.delete(profileKey);
|
|
333
|
+
for (const sessionId of connection.sessions) {
|
|
334
|
+
const resource = this.sessions.get(sessionId);
|
|
335
|
+
if (resource)
|
|
336
|
+
resource.active = false;
|
|
337
|
+
}
|
|
338
|
+
for (const sessionId of connection.sessions)
|
|
339
|
+
this.input.onExit?.(sessionId);
|
|
340
|
+
}
|
|
341
|
+
dispatchEvent(profileKey, event) {
|
|
342
|
+
const sessionId = this.relaySessionId(profileKey, event.session_id);
|
|
343
|
+
const resource = sessionId ? this.sessions.get(sessionId) : undefined;
|
|
344
|
+
if (!sessionId || !resource)
|
|
345
|
+
return;
|
|
346
|
+
if (typeof event.seq === 'number' && event.seq > resource.cursor)
|
|
347
|
+
resource.cursor = event.seq;
|
|
348
|
+
this.consumeProtocolEvent(resource, event);
|
|
349
|
+
const origin = this.originFor(resource, event);
|
|
350
|
+
this.input.onNotification(sessionId, event, origin);
|
|
351
|
+
}
|
|
352
|
+
/** Maps a kimi session id back to the owning relay session on this connection. */
|
|
353
|
+
relaySessionId(profileKey, threadId) {
|
|
354
|
+
if (!threadId)
|
|
355
|
+
return null;
|
|
356
|
+
for (const resource of this.sessions.values()) {
|
|
357
|
+
if (resource.profileKey === profileKey && resource.threadId === threadId)
|
|
358
|
+
return resource.sessionId;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
originFor(resource, event) {
|
|
363
|
+
const agentId = agentIdOf(event.payload);
|
|
364
|
+
if (agentId && resource.childAgents.has(agentId)) {
|
|
365
|
+
const turnNumber = turnNumberOf(event.payload);
|
|
366
|
+
return {
|
|
367
|
+
kind: 'child',
|
|
368
|
+
...(agentId && turnNumber !== null ? { physicalTurnId: `${agentId}:${turnNumber}` } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { kind: 'root' };
|
|
372
|
+
}
|
|
373
|
+
/** Consumes protocol facts the runtime itself owns (turn/prompt bindings, interactions). */
|
|
374
|
+
consumeProtocolEvent(resource, event) {
|
|
375
|
+
const payload = record(event.payload);
|
|
376
|
+
if (!payload)
|
|
377
|
+
return;
|
|
378
|
+
switch (payload.type) {
|
|
379
|
+
case 'turn.started': {
|
|
380
|
+
const agentId = typeof payload.agentId === 'string' ? payload.agentId : null;
|
|
381
|
+
const turnNumber = safeTurnNumber(payload.turnId);
|
|
382
|
+
if (!agentId || turnNumber === null)
|
|
383
|
+
break;
|
|
384
|
+
if (resource.mainAgentId === null && resource.pendingPrompts.length) {
|
|
385
|
+
resource.mainAgentId = agentId;
|
|
386
|
+
}
|
|
387
|
+
if (agentId !== resource.mainAgentId) {
|
|
388
|
+
resource.childAgents.add(agentId);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
const promptId = resource.pendingPrompts.shift();
|
|
392
|
+
if (promptId) {
|
|
393
|
+
resource.agentTurns.set(`${agentId}:${turnNumber}`, promptId);
|
|
394
|
+
resource.activePromptId = promptId;
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'prompt.completed':
|
|
399
|
+
case 'prompt.aborted': {
|
|
400
|
+
const promptId = typeof payload.promptId === 'string' ? payload.promptId : null;
|
|
401
|
+
if (!promptId)
|
|
402
|
+
break;
|
|
403
|
+
if (resource.activePromptId === promptId)
|
|
404
|
+
resource.activePromptId = null;
|
|
405
|
+
for (const [binding, bound] of resource.agentTurns) {
|
|
406
|
+
if (bound === promptId)
|
|
407
|
+
resource.agentTurns.delete(binding);
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
case 'subagent.spawned':
|
|
412
|
+
if (typeof payload.subagentId === 'string')
|
|
413
|
+
resource.childAgents.add(payload.subagentId);
|
|
414
|
+
break;
|
|
415
|
+
case 'agent.status.updated':
|
|
416
|
+
if (payload.status === 'awaiting_approval')
|
|
417
|
+
void this.pollPending(resource, 'approvals');
|
|
418
|
+
if (payload.status === 'awaiting_question')
|
|
419
|
+
void this.pollPending(resource, 'questions');
|
|
420
|
+
break;
|
|
421
|
+
default:
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
async pollPending(resource, list) {
|
|
426
|
+
if (resource.pollInFlight)
|
|
427
|
+
return;
|
|
428
|
+
resource.pollInFlight = list;
|
|
429
|
+
const kind = list === 'approvals' ? 'approval' : 'question';
|
|
430
|
+
try {
|
|
431
|
+
const handle = this.input.servers.get(resource.profileKey);
|
|
432
|
+
if (!handle)
|
|
433
|
+
return;
|
|
434
|
+
const data = await handle.client.get(`/api/v1/sessions/${resource.threadId}/${list}`);
|
|
435
|
+
const items = list === 'approvals'
|
|
436
|
+
? decodeApprovalItems(data).map((item) => ({ kimiId: item.approval_id, item }))
|
|
437
|
+
: decodeQuestionItems(data).map((item) => ({ kimiId: item.question_id, item }));
|
|
438
|
+
for (const { kimiId, item } of items) {
|
|
439
|
+
const known = [...resource.pendingInteractions.values()].some((pending) => pending.kimiId === kimiId);
|
|
440
|
+
if (known)
|
|
441
|
+
continue;
|
|
442
|
+
const request = list === 'approvals'
|
|
443
|
+
? approvalToServerRequest(++resource.interactionSequence, item)
|
|
444
|
+
: questionToServerRequest(++resource.interactionSequence, item);
|
|
445
|
+
if (!request)
|
|
446
|
+
continue;
|
|
447
|
+
resource.pendingInteractions.set(String(request.id), { kind, kimiId, settling: false });
|
|
448
|
+
const accepted = await this.input.onServerRequest(resource.sessionId, request, {
|
|
449
|
+
kind: 'root',
|
|
450
|
+
});
|
|
451
|
+
if (!accepted)
|
|
452
|
+
resource.pendingInteractions.delete(String(request.id));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Polling is opportunistic; the next status change retries.
|
|
457
|
+
}
|
|
458
|
+
finally {
|
|
459
|
+
resource.pollInFlight = null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async resolveInteraction(resource, pending, result) {
|
|
463
|
+
const handle = this.input.servers.get(resource.profileKey);
|
|
464
|
+
if (!handle)
|
|
465
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
466
|
+
if (pending.kind === 'approval') {
|
|
467
|
+
const decision = kimiApprovalDecision(result);
|
|
468
|
+
if (!decision)
|
|
469
|
+
throw new Error('KIMI_INTERACTION_RESPONSE_INVALID');
|
|
470
|
+
await handle.client.post(`/api/v1/sessions/${resource.threadId}/approvals/${pending.kimiId}`, { decision });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const data = await handle.client.get(`/api/v1/sessions/${resource.threadId}/questions`);
|
|
474
|
+
const item = decodeQuestionItems(data).find((candidate) => candidate.question_id === pending.kimiId);
|
|
475
|
+
const answers = item ? kimiQuestionAnswers(item, result) : null;
|
|
476
|
+
if (!answers)
|
|
477
|
+
throw new Error('KIMI_INTERACTION_RESPONSE_INVALID');
|
|
478
|
+
try {
|
|
479
|
+
await handle.client.post(`/api/v1/sessions/${resource.threadId}/questions/${pending.kimiId}`, { answers });
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
// A dismissal reports 40909 as its success code; treat it as resolved.
|
|
483
|
+
if (!(isKimiWebError(error) && error.code === 40909))
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
requireResource(session) {
|
|
488
|
+
const resource = this.sessions.get(session.id);
|
|
489
|
+
if (!resource || !session.threadId)
|
|
490
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
491
|
+
return resource;
|
|
492
|
+
}
|
|
493
|
+
async submitPrompt(resource, promptId, text, model) {
|
|
494
|
+
const handle = this.input.servers.get(resource.profileKey);
|
|
495
|
+
if (!handle)
|
|
496
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
497
|
+
resource.pendingPrompts.push(promptId);
|
|
498
|
+
try {
|
|
499
|
+
await handle.client.post(`/api/v1/sessions/${resource.threadId}/prompts`, {
|
|
500
|
+
content: [{ type: 'text', text }],
|
|
501
|
+
prompt_id: promptId,
|
|
502
|
+
...(model ? { model } : {}),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
resource.pendingPrompts = resource.pendingPrompts.filter((candidate) => candidate !== promptId);
|
|
507
|
+
for (const [binding, bound] of resource.agentTurns) {
|
|
508
|
+
if (bound === promptId)
|
|
509
|
+
resource.agentTurns.delete(binding);
|
|
510
|
+
}
|
|
511
|
+
if (resource.activePromptId === promptId)
|
|
512
|
+
resource.activePromptId = null;
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
// kimi enqueues prompts submitted while a turn runs; steer pulls the
|
|
516
|
+
// queued prompt into the active turn instead of deferring it.
|
|
517
|
+
if (resource.activePromptId) {
|
|
518
|
+
await handle.client.post(`/api/v1/sessions/${resource.threadId}/prompts:steer`, {
|
|
519
|
+
prompt_ids: [promptId],
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async readHistoryFrom(resource) {
|
|
524
|
+
const handle = this.input.servers.get(resource.profileKey);
|
|
525
|
+
if (!handle)
|
|
526
|
+
throw new Error(KIMI_SESSION_NOT_RUNNING);
|
|
527
|
+
return this.fetchHistory(handle, resource.threadId);
|
|
528
|
+
}
|
|
529
|
+
async readDetachedHistory(session) {
|
|
530
|
+
const handle = await this.ensureServer(session);
|
|
531
|
+
return this.fetchHistory(handle, session.threadId);
|
|
532
|
+
}
|
|
533
|
+
async fetchHistory(handle, threadId) {
|
|
534
|
+
const [messages, status] = await Promise.all([
|
|
535
|
+
handle.client.get(`/api/v1/sessions/${threadId}/messages`),
|
|
536
|
+
handle.client.get(`/api/v1/sessions/${threadId}`),
|
|
537
|
+
]);
|
|
538
|
+
const items = Array.isArray(messages?.items)
|
|
539
|
+
? messages.items
|
|
540
|
+
: [];
|
|
541
|
+
return {
|
|
542
|
+
turns: groupMessagesIntoTurns(items),
|
|
543
|
+
activeTurnId: extractActivePromptId(status),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
async detach(sessionId) {
|
|
547
|
+
const resource = this.sessions.get(sessionId);
|
|
548
|
+
if (!resource)
|
|
549
|
+
return;
|
|
550
|
+
this.sessions.delete(sessionId);
|
|
551
|
+
const connection = this.connections.get(resource.profileKey);
|
|
552
|
+
if (connection) {
|
|
553
|
+
connection.sessions.delete(sessionId);
|
|
554
|
+
if (connection.sessions.size === 0) {
|
|
555
|
+
this.connections.delete(resource.profileKey);
|
|
556
|
+
connection.eventUnsub();
|
|
557
|
+
connection.closeUnsub();
|
|
558
|
+
connection.client.close();
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
void connection.client.unsubscribe([resource.threadId]).catch(() => { });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function kimiPermissionMode(session) {
|
|
567
|
+
const policy = session.executionPolicy?.approvalPolicy;
|
|
568
|
+
if (policy === 'never')
|
|
569
|
+
return 'yolo';
|
|
570
|
+
return 'manual';
|
|
571
|
+
}
|
|
572
|
+
function classifyWriterAcquisitionFailure(error) {
|
|
573
|
+
if (error instanceof WriterAcquisitionError)
|
|
574
|
+
return error;
|
|
575
|
+
if (isKimiWebError(error))
|
|
576
|
+
return new WriterAcquisitionError('runtimeUnavailable');
|
|
577
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
578
|
+
if (/ENOENT|spawn|kimi web exited/i.test(message)) {
|
|
579
|
+
return new WriterAcquisitionError('runtimeDependencyFailed');
|
|
580
|
+
}
|
|
581
|
+
return new WriterAcquisitionError('runtimeUnavailable');
|
|
582
|
+
}
|
|
583
|
+
function isKimiSessionMissing(error) {
|
|
584
|
+
return isKimiWebError(error) && (error.code === 40401 || error.code === 404);
|
|
585
|
+
}
|
|
586
|
+
function extractActivePromptId(status) {
|
|
587
|
+
const record = status && typeof status === 'object' ? status : null;
|
|
588
|
+
if (!record || record.main_turn_active !== true)
|
|
589
|
+
return null;
|
|
590
|
+
return typeof record.current_prompt_id === 'string' ? record.current_prompt_id : null;
|
|
591
|
+
}
|
|
592
|
+
/** Groups a kimi transcript into relay turns: each user message opens a turn. */
|
|
593
|
+
function groupMessagesIntoTurns(items) {
|
|
594
|
+
const turns = [];
|
|
595
|
+
let current = null;
|
|
596
|
+
for (const message of items.slice(0, 2_000)) {
|
|
597
|
+
const role = typeof message.role === 'string' ? message.role : null;
|
|
598
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
599
|
+
const text = content
|
|
600
|
+
.flatMap((part) => part && typeof part === 'object' && part.type === 'text'
|
|
601
|
+
? [String(part.text ?? '')]
|
|
602
|
+
: [])
|
|
603
|
+
.join('')
|
|
604
|
+
.slice(0, 64_000);
|
|
605
|
+
const id = typeof message.id === 'string' ? message.id : `kimi-message-${turns.length}`;
|
|
606
|
+
if (role === 'user') {
|
|
607
|
+
current = { items: [], startedAt: null, completedAt: null };
|
|
608
|
+
turns.push(current);
|
|
609
|
+
if (text)
|
|
610
|
+
current.items.push({ id, kind: 'user', text });
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (!current) {
|
|
614
|
+
current = { items: [], startedAt: null, completedAt: null };
|
|
615
|
+
turns.push(current);
|
|
616
|
+
}
|
|
617
|
+
if (role === 'assistant' && text) {
|
|
618
|
+
current.items.push({ id, kind: 'agent', text, phase: 'final_answer' });
|
|
619
|
+
}
|
|
620
|
+
else if (role === 'tool') {
|
|
621
|
+
for (const part of content) {
|
|
622
|
+
if (part &&
|
|
623
|
+
typeof part === 'object' &&
|
|
624
|
+
part.type === 'tool_use') {
|
|
625
|
+
const tool = part;
|
|
626
|
+
current.items.push({
|
|
627
|
+
id: typeof tool.tool_call_id === 'string' ? tool.tool_call_id : id,
|
|
628
|
+
kind: 'tool',
|
|
629
|
+
name: typeof tool.tool_name === 'string' ? tool.tool_name : 'tool',
|
|
630
|
+
status: 'completed',
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return turns;
|
|
637
|
+
}
|
|
638
|
+
function record(value) {
|
|
639
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
640
|
+
? value
|
|
641
|
+
: null;
|
|
642
|
+
}
|
|
643
|
+
function safeTurnNumber(value) {
|
|
644
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
645
|
+
}
|
|
646
|
+
function agentIdOf(payload) {
|
|
647
|
+
const value = record(payload);
|
|
648
|
+
return value && typeof value.agentId === 'string' ? value.agentId : null;
|
|
649
|
+
}
|
|
650
|
+
function turnNumberOf(payload) {
|
|
651
|
+
const value = record(payload);
|
|
652
|
+
return value ? safeTurnNumber(value.turnId) : null;
|
|
653
|
+
}
|