@tea-agent/loop-agent 0.23.1 → 0.24.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -12,16 +12,83 @@
12
12
  * Mutation routes go through the Console mutation gate (boot token + Origin).
13
13
  * Read routes (GET capabilities / sessions) are open to loopback.
14
14
  */
15
+ import { createHash } from "node:crypto";
15
16
  import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
16
- import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
17
- import { buildOperatorChatToolSchemas } from "./tool-adapter.js";
17
+ import { OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS, OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS, OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS, composeInstructionSkillsPrompt, loadOperatorChatInstructionSkills, } from "./instruction-skills.js";
18
+ import { buildModelCallableToolSchemas } from "./tool-adapter.js";
19
+ import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
20
+ import { THINKING_LEVELS } from "./pi-runtime.js";
21
+ import { ComposerDraftStore } from "./composer-draft-store.js";
22
+ import { walkRepoFiles } from "./repo-walk.js";
23
+ import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
24
+ import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
25
+ import { projectRuntimeContext } from "./runtime-context.js";
26
+ import { patchModelsConfig, readModelsConfig, readPackageInventory, readSkillPreferences, writeSkillPreferences, } from "./pi-console-config.js";
27
+ import { ChatInterviewAdapter, projectChatInterviewState } from "./interview-adapter.js";
18
28
  import { evaluateMutationGate, isMutationMethod, } from "../security.js";
29
+ import { dispatchOperatorAction } from "../operator-actions.js";
30
+ import { makeHumanGateCard } from "./human-gate-card.js";
31
+ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
32
+ import { projectTaskContext } from "./context-panel.js";
33
+ import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken } from "../human-gate-token.js";
19
34
  import { sendJson } from "../routes.js";
20
- import { openSseResponse, writeSseEvent, } from "../operation-sse.js";
35
+ import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
21
36
  function writeChatSse(res, event) {
37
+ // Persisted Chat events carry their own eventId (<sessionId>:<seq>); write
38
+ // it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
39
+ if ("seq" in event && event.eventId) {
40
+ res.write(`id: ${event.eventId}\n`);
41
+ }
22
42
  res.write(`event: ${event.kind}\n`);
23
43
  res.write(`data: ${JSON.stringify(event)}\n\n`);
24
44
  }
45
+ /** Map a runtime ChatTurnEvent to the persisted event-store partial.
46
+ *
47
+ * Returns undefined for heartbeat (not persisted — keep-alive only). */
48
+ function turnEventToStorePartial(event) {
49
+ switch (event.type) {
50
+ case "agent_start":
51
+ return { kind: "agent_start", data: {} };
52
+ case "message_update":
53
+ return { kind: "message_update", data: { text: String(event.text ?? "") } };
54
+ case "message_end":
55
+ return { kind: "message_end", data: { text: String(event.text ?? "") } };
56
+ case "usage":
57
+ return { kind: "usage", data: (event.usage ?? {}) };
58
+ case "tool_call":
59
+ return {
60
+ kind: "tool_call",
61
+ data: projectChatToolEventPayload({
62
+ kind: "tool_call",
63
+ toolCallId: String(event.toolCallId ?? ""),
64
+ toolName: String(event.toolName ?? ""),
65
+ value: event.args,
66
+ }),
67
+ };
68
+ case "tool_result":
69
+ return {
70
+ kind: "tool_result",
71
+ data: projectChatToolEventPayload({
72
+ kind: "tool_result",
73
+ toolCallId: String(event.toolCallId ?? ""),
74
+ toolName: String(event.toolName ?? ""),
75
+ value: event.result,
76
+ isError: Boolean(event.isError),
77
+ }),
78
+ };
79
+ case "agent_end":
80
+ return {
81
+ kind: "agent_end",
82
+ data: { willRetry: Boolean(event.willRetry) },
83
+ };
84
+ case "agent_settled":
85
+ return { kind: "agent_settled", data: {} };
86
+ case "error":
87
+ return { kind: "error", data: { message: String(event.message ?? "") } };
88
+ default:
89
+ return undefined;
90
+ }
91
+ }
25
92
  async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
26
93
  const chunks = [];
27
94
  let total = 0;
@@ -40,6 +107,36 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
40
107
  return {};
41
108
  return JSON.parse(raw);
42
109
  }
110
+ const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
111
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
112
+ const MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
113
+ const MAX_IMAGES = 4;
114
+ export function parseChatImages(value) {
115
+ if (value === undefined)
116
+ return [];
117
+ if (!Array.isArray(value) || value.length > MAX_IMAGES)
118
+ throw new Error(`images must be an array with at most ${MAX_IMAGES} items`);
119
+ let total = 0;
120
+ return value.map((item, index) => {
121
+ if (!item || typeof item !== "object")
122
+ throw new Error(`images[${index}] is invalid`);
123
+ const record = item;
124
+ if (record.type !== "image" || typeof record.mimeType !== "string" || !IMAGE_MIME_TYPES.has(record.mimeType))
125
+ throw new Error(`images[${index}] must be a supported raster image`);
126
+ if (typeof record.data !== "string" || !record.data || !/^[A-Za-z0-9+/]+={0,2}$/.test(record.data) || record.data.length % 4 !== 0)
127
+ throw new Error(`images[${index}] data must be valid base64`);
128
+ const bytes = Buffer.from(record.data, "base64");
129
+ const textProbe = bytes.subarray(0, Math.min(bytes.length, 64 * 1024)).toString("utf8");
130
+ if (scrubSecrets(textProbe).redactions.length > 0)
131
+ throw new Error(`images[${index}] contains secret-shaped content`);
132
+ if (bytes.length > MAX_IMAGE_BYTES)
133
+ throw new Error(`images[${index}] exceeds ${MAX_IMAGE_BYTES} bytes`);
134
+ total += bytes.length;
135
+ if (total > MAX_TOTAL_IMAGE_BYTES)
136
+ throw new Error(`images exceed ${MAX_TOTAL_IMAGE_BYTES} total bytes`);
137
+ return { type: "image", mimeType: record.mimeType, data: bytes.toString("base64") };
138
+ });
139
+ }
43
140
  function gateMutation(req, deps) {
44
141
  const failure = evaluateMutationGate(req, {
45
142
  bootToken: deps.bootToken.value,
@@ -58,7 +155,7 @@ function gateMutation(req, deps) {
58
155
  return { ok: true };
59
156
  }
60
157
  export async function handleChatCapabilities(_req, res, deps) {
61
- const toolSchemas = buildOperatorChatToolSchemas();
158
+ const toolSchemas = buildModelCallableToolSchemas();
62
159
  const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
63
160
  sendJson(res, 200, {
64
161
  ok: true,
@@ -78,9 +175,9 @@ export async function handleChatCapabilities(_req, res, deps) {
78
175
  skipped: skills.skipped,
79
176
  promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded).length,
80
177
  },
81
- // bash is ALLOWED since the 2026-07-25 widening (read/explore tools
82
- // enabled). File-WRITING tools (edit/write/apply_patch) stay excluded.
83
- hasBash: true,
178
+ // M0-A: bare bash is removed; safe-read/safe-grep enforce the repo and
179
+ // secret boundaries instead.
180
+ hasBash: false,
84
181
  });
85
182
  }
86
183
  export async function handleCreateChatSession(req, res, deps) {
@@ -106,24 +203,39 @@ export async function handleCreateChatSession(req, res, deps) {
106
203
  });
107
204
  return;
108
205
  }
206
+ if ("clientRequestId" in body || "requestIndex" in body) {
207
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "operation idempotency ownership cannot be copied" } });
208
+ return;
209
+ }
109
210
  const provider = typeof body.modelProvider === "string" ? body.modelProvider : undefined;
110
211
  const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
212
+ const snapshotInput = body.snapshot;
213
+ if (snapshotInput !== undefined && (!snapshotInput || typeof snapshotInput !== "object" || snapshotInput.schemaVersion !== 1 || typeof snapshotInput.summary !== "string" || !snapshotInput.summary)) {
214
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_SNAPSHOT", message: "valid OperatorContextSnapshotV1 is required" } });
215
+ return;
216
+ }
111
217
  try {
218
+ deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
219
+ const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
112
220
  const handle = await deps.runtime.createSession({
113
221
  ...(provider && modelId ? { model: { provider, modelId } } : {}),
222
+ ...(snapshot ? { systemPromptSuffix: `Read-only initial Operator context snapshot; this is not runtime rollback.\n${snapshot.summary}` } : {}),
114
223
  });
115
224
  await deps.store.create({
116
225
  sessionId: handle.sessionId,
226
+ sessionFile: handle.sessionFile,
117
227
  modelProvider: handle.model?.provider,
118
228
  modelId: handle.model?.modelId,
119
229
  });
230
+ if (snapshot)
231
+ deps.events.append(handle.sessionId, `${handle.sessionId}:snapshot`, { kind: "compact", data: snapshot });
120
232
  sendJson(res, 201, {
121
233
  ok: true,
122
234
  sessionId: handle.sessionId,
123
235
  sessionFile: handle.sessionFile,
124
236
  model: handle.model,
125
237
  activeTools: handle.activeTools,
126
- hasBash: true,
238
+ hasBash: false,
127
239
  });
128
240
  }
129
241
  catch (error) {
@@ -134,6 +246,81 @@ export async function handleCreateChatSession(req, res, deps) {
134
246
  });
135
247
  }
136
248
  }
249
+ export async function handleForkChatSession(req, res, deps, sourceSessionId) {
250
+ const gate = gateMutation(req, deps);
251
+ if (!gate.ok) {
252
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
253
+ return;
254
+ }
255
+ if (deps.events.getActiveTurn(sourceSessionId)) {
256
+ sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot fork an active turn" } });
257
+ return;
258
+ }
259
+ const source = await deps.store.get(sourceSessionId);
260
+ if (!source) {
261
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sourceSessionId}` } });
262
+ return;
263
+ }
264
+ try {
265
+ const handle = await deps.runtime.forkSession({ sourceSessionFile: source.sessionFile ?? "", ...(source.modelProvider && source.modelId ? { model: { provider: source.modelProvider, modelId: source.modelId } } : {}) });
266
+ await deps.store.forkFrom({ sourceSessionId, targetSessionId: handle.sessionId, targetSessionFile: handle.sessionFile });
267
+ const copied = deps.events.forkContextRefs({ sourceSessionId, targetSessionId: handle.sessionId, targetTurnId: `${handle.sessionId}:fork` });
268
+ await deps.operationLinker.recoverSession(handle.sessionId);
269
+ sendJson(res, 201, { ok: true, sessionId: handle.sessionId, forkedFrom: sourceSessionId, copiedContextRefs: copied.copiedCount, warnings: ["已发生的 task/DAG/Worker mutation 不会撤销或重放。", "operation idempotency / clientRequestId ownership 不会复制到新会话。"] });
270
+ }
271
+ catch (error) {
272
+ sendJson(res, 409, { ok: false, error: { code: "SESSION_FORK_FAILED", message: error instanceof Error ? error.message : String(error) } });
273
+ }
274
+ }
275
+ export async function handleBranchChatSession(req, res, deps, sessionId) {
276
+ const gate = gateMutation(req, deps);
277
+ if (!gate.ok) {
278
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
279
+ return;
280
+ }
281
+ if (deps.events.getActiveTurn(sessionId)) {
282
+ sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot branch an active turn" } });
283
+ return;
284
+ }
285
+ const record = await deps.store.get(sessionId);
286
+ if (!record) {
287
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
288
+ return;
289
+ }
290
+ try {
291
+ if (!deps.runtime.hasSession(sessionId)) {
292
+ const reopened = await deps.runtime.reopenSession({ sessionId, sessionFile: record.sessionFile });
293
+ if (!reopened.ok)
294
+ throw new Error(reopened.message);
295
+ }
296
+ const body = await readJsonBody(req);
297
+ if ("clientRequestId" in body || "requestIndex" in body)
298
+ throw new Error("operation idempotency ownership cannot be copied");
299
+ const current = deps.runtime.getBranchContext(sessionId);
300
+ const branchFromEntryId = typeof body.branchFromEntryId === "string" ? body.branchFromEntryId : current.leafId;
301
+ if (!branchFromEntryId)
302
+ throw new Error("branchFromEntryId is required");
303
+ const result = await deps.runtime.branchSession({ sessionId, branchFromEntryId, ...(typeof body.summary === "string" && body.summary ? { summary: body.summary } : {}) });
304
+ sendJson(res, 200, { ok: true, ...result, note: "仅影响后续 LLM 上下文,未持久化第二历史" });
305
+ }
306
+ catch (error) {
307
+ sendJson(res, 400, { ok: false, error: { code: "SESSION_BRANCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
308
+ }
309
+ }
310
+ export async function handleSwitchMainlineChatSession(req, res, deps, sessionId) {
311
+ const gate = gateMutation(req, deps);
312
+ if (!gate.ok) {
313
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
314
+ return;
315
+ }
316
+ try {
317
+ deps.runtime.switchToMainline(sessionId);
318
+ sendJson(res, 200, { ok: true, sessionId, active: "mainline" });
319
+ }
320
+ catch (error) {
321
+ sendJson(res, 409, { ok: false, error: { code: "MAINLINE_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
322
+ }
323
+ }
137
324
  export async function handleGetChatSession(_req, res, deps, sessionId) {
138
325
  const record = await deps.store.get(sessionId);
139
326
  if (!record) {
@@ -143,7 +330,93 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
143
330
  });
144
331
  return;
145
332
  }
146
- sendJson(res, 200, { ok: true, session: record, hasBash: true });
333
+ await deps.operationLinker.recoverSession(sessionId);
334
+ const composerDraft = await new ComposerDraftStore(deps.appData).get(sessionId);
335
+ const snapshot = deps.events.snapshot(sessionId);
336
+ const lastEvent = snapshot.at(-1);
337
+ sendJson(res, 200, {
338
+ ok: true,
339
+ session: {
340
+ ...record,
341
+ composerDraft,
342
+ activeTurnId: deps.events.getActiveTurn(sessionId)?.turnId,
343
+ },
344
+ hasBash: false,
345
+ lastEventId: lastEvent?.eventId ?? null,
346
+ lastEventSeq: lastEvent?.seq ?? 0,
347
+ });
348
+ }
349
+ export async function handleListChatSessions(_req, res, deps) {
350
+ const sessions = await deps.store.list({ includeArchived: true });
351
+ sendJson(res, 200, {
352
+ ok: true,
353
+ schemaVersion: 2,
354
+ sessions: sessions.map((session) => ({
355
+ ...session,
356
+ activeTurnId: deps.events.getActiveTurn(session.sessionId)?.turnId,
357
+ })),
358
+ hasBash: false,
359
+ });
360
+ }
361
+ export async function handlePatchChatSession(req, res, deps, sessionId) {
362
+ const gate = gateMutation(req, deps);
363
+ if (!gate.ok) {
364
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
365
+ return;
366
+ }
367
+ try {
368
+ const body = await readJsonBody(req);
369
+ let record;
370
+ if (typeof body.title === "string")
371
+ record = await deps.store.rename(sessionId, body.title);
372
+ else if (body.state === "active" || body.state === "archived") {
373
+ record = await deps.store.setState(sessionId, body.state);
374
+ }
375
+ else {
376
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "title or state(active|archived) is required" } });
377
+ return;
378
+ }
379
+ sendJson(res, 200, { ok: true, session: record });
380
+ }
381
+ catch (error) {
382
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: error instanceof Error ? error.message : String(error) } });
383
+ }
384
+ }
385
+ export async function handleReopenChatSession(req, res, deps, sessionId) {
386
+ const gate = gateMutation(req, deps);
387
+ if (!gate.ok) {
388
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
389
+ return;
390
+ }
391
+ const record = await deps.store.get(sessionId);
392
+ if (!record) {
393
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
394
+ return;
395
+ }
396
+ if (deps.runtime.hasSession(sessionId)) {
397
+ sendJson(res, 200, { ok: true, sessionId, reopened: false, alreadyActive: true, hasBash: false });
398
+ return;
399
+ }
400
+ const result = await deps.runtime.reopenSession({
401
+ sessionId,
402
+ sessionFile: record.sessionFile,
403
+ ...(record.modelProvider && record.modelId
404
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
405
+ : {}),
406
+ });
407
+ if (!result.ok) {
408
+ sendJson(res, 409, { ok: false, error: { code: result.code, message: result.message } });
409
+ return;
410
+ }
411
+ sendJson(res, 200, {
412
+ ok: true,
413
+ sessionId,
414
+ reopened: true,
415
+ sessionFile: result.handle.sessionFile,
416
+ model: result.handle.model,
417
+ activeTools: result.handle.activeTools,
418
+ hasBash: false,
419
+ });
147
420
  }
148
421
  export async function handleChatPrompt(req, res, deps, sessionId) {
149
422
  const gate = gateMutation(req, deps);
@@ -169,6 +442,14 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
169
442
  return;
170
443
  }
171
444
  const text = typeof body.text === "string" ? body.text.trim() : "";
445
+ let images;
446
+ try {
447
+ images = parseChatImages(body.images);
448
+ }
449
+ catch (error) {
450
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_ATTACHMENT", message: error instanceof Error ? error.message : String(error) } });
451
+ return;
452
+ }
172
453
  if (!text) {
173
454
  sendJson(res, 400, {
174
455
  ok: false,
@@ -177,11 +458,26 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
177
458
  return;
178
459
  }
179
460
  if (!deps.runtime.hasSession(sessionId)) {
180
- sendJson(res, 404, {
181
- ok: false,
182
- error: { code: "NOT_FOUND", message: `chat session not active: ${sessionId}` },
461
+ // M1-S04/S05: lazily reopen a persisted session that is no longer in
462
+ // memory (Console restart / browser refresh). Fail-closed when there is
463
+ // no durable record or no Pi session file we never silently create a
464
+ // fresh session under the same id, which would lose prior context.
465
+ const record = await deps.store.get(sessionId);
466
+ if (!record) {
467
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
468
+ return;
469
+ }
470
+ const reopen = await deps.runtime.reopenSession({
471
+ sessionId,
472
+ sessionFile: record.sessionFile,
473
+ ...(record.modelProvider && record.modelId
474
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
475
+ : {}),
183
476
  });
184
- return;
477
+ if (!reopen.ok) {
478
+ sendJson(res, 409, { ok: false, error: { code: reopen.code, message: reopen.message } });
479
+ return;
480
+ }
185
481
  }
186
482
  const actionContext = deps.runtime.actionContext;
187
483
  if (!actionContext) {
@@ -194,86 +490,112 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
194
490
  });
195
491
  return;
196
492
  }
197
- // Stream events via SSE.
493
+ // Acquire the per-session active-turn lease (T01/T16). The compatibility
494
+ // prompt SSE route uses the same durable Turn resource as POST /turns.
495
+ const createdTurn = deps.events.createTurn(sessionId);
496
+ if (!createdTurn.ok) {
497
+ sendJson(res, 409, {
498
+ ok: false,
499
+ error: {
500
+ code: "TURN_ACTIVE",
501
+ message: `chat turn already active: ${createdTurn.activeTurn.turnId}`,
502
+ },
503
+ });
504
+ return;
505
+ }
506
+ const turnId = createdTurn.turn.turnId;
507
+ deps.events.setTurnState(sessionId, turnId, "running");
508
+ // Stream events via SSE. Each event is FIRST persisted to the chat event
509
+ // ring (so a reconnect via GET /events can resume by Last-Event-ID), THEN
510
+ // framed with its eventId as the SSE `id:` line.
198
511
  openSseResponse(res);
199
- // Emit an immediate agent_start so the client shows a streaming state
200
- // before the SDK emits its own agent_start (which we drop below to avoid
201
- // a duplicate frame — P1.4).
202
- writeChatSse(res, { kind: "agent_start", sessionId });
203
512
  const ac = new AbortController();
204
513
  let closed = false;
205
- req.on("close", () => {
514
+ let abortedByClient = false;
515
+ // Prefer response close: after the request body is fully read, browser abort
516
+ // of an SSE response typically emits res.close, not req.close.
517
+ res.once("close", () => {
518
+ if (res.writableEnded)
519
+ return;
206
520
  closed = true;
521
+ abortedByClient = true;
207
522
  ac.abort();
208
523
  });
209
524
  // Lazily import to avoid circular module load.
210
525
  const { runChatTurn } = await import("./session-store.js");
211
526
  try {
212
- await runChatTurn({
527
+ const outcome = await runChatTurn({
213
528
  runtime: deps.runtime,
214
529
  store: deps.store,
215
530
  actionContext,
216
531
  sessionId,
217
532
  text,
533
+ images,
218
534
  signal: ac.signal,
535
+ onOperationAccepted: (accepted) => deps.operationLinker.link({
536
+ sessionId,
537
+ turnId,
538
+ toolCallId: accepted.toolCallId,
539
+ operationId: accepted.operationId,
540
+ }),
219
541
  onEvent: (event) => {
220
542
  if (closed)
221
543
  return;
222
- // agent_start was already emitted above for immediate client feedback;
223
- // drop the SDK's duplicate to avoid a double frame (P1.4).
224
- if (event.type === "agent_start")
225
- return;
544
+ if (event.type === "tool_result" && event.toolName === "prepareDagConfirmation" && !event.isError) {
545
+ const result = event.result;
546
+ const confirmationId = result?.confirmation?.confirmationId;
547
+ if (confirmationId)
548
+ void ctxConfirmationCard(deps, sessionId, turnId, confirmationId);
549
+ }
550
+ if (event.type === "tool_result" && event.toolName === "prepareMutationGate" && !event.isError) {
551
+ const result = event.result;
552
+ if (result?.receiptId)
553
+ void ctxMutationGateCard(deps, sessionId, result.receiptId);
554
+ }
226
555
  // heartbeat is a keep-alive: write it as an SSE comment frame (":\n\n")
227
556
  // which proxies/browsers treat as traffic but clients ignore as data.
557
+ // It is NOT persisted (no real state change).
228
558
  if (event.type === "heartbeat") {
229
559
  if (!res.writableEnded)
230
560
  res.write(": heartbeat\n\n");
231
561
  return;
232
562
  }
233
- const sse = event.type === "tool_call"
234
- ? {
235
- kind: "tool_call",
236
- sessionId,
237
- toolCallId: event.toolCallId,
238
- toolName: event.toolName,
239
- args: event.args,
240
- }
241
- : event.type === "tool_result"
242
- ? {
243
- kind: "tool_result",
244
- sessionId,
245
- toolCallId: event.toolCallId,
246
- toolName: event.toolName,
247
- result: event.result,
248
- isError: event.isError,
249
- }
250
- : event.type === "error"
251
- ? { kind: "error", sessionId, message: event.message }
252
- : event.type === "agent_end"
253
- ? { kind: "agent_end", sessionId, willRetry: event.willRetry }
254
- : event.type === "agent_settled"
255
- ? { kind: "agent_settled", sessionId }
256
- : {
257
- kind: event.type,
258
- sessionId,
259
- text: "text" in event ? event.text : "",
260
- };
261
- writeChatSse(res, sse);
563
+ const partial = turnEventToStorePartial(event);
564
+ if (!partial)
565
+ return;
566
+ const persisted = deps.events.append(sessionId, turnId, partial);
567
+ if (!res.writableEnded)
568
+ writeChatSse(res, persisted);
262
569
  },
263
570
  });
571
+ if (abortedByClient || ac.signal.aborted) {
572
+ deps.events.setTurnState(sessionId, turnId, "aborted", {
573
+ code: "CLIENT_ABORTED",
574
+ message: "client disconnected before turn settled",
575
+ });
576
+ }
577
+ else {
578
+ deps.events.setTurnState(sessionId, turnId, outcome.ok ? "settled" : "failed", outcome.error);
579
+ }
264
580
  }
265
581
  catch (error) {
266
582
  // runChatTurn should not reject (it catches runtime.prompt failures
267
583
  // internally and returns {ok:false}), but a pre-prompt persistence failure
268
584
  // (e.g. store.appendMessage for the user message) CAN throw after the SSE
269
- // stream is already open. Surface it as an SSE error event so the client
270
- // sees the failure instead of a hanging connection.
585
+ // stream is already open. Persist + emit an error event so a reconnecting
586
+ // client also sees the failure (not just the original requester).
587
+ const message = error instanceof Error ? error.message : String(error);
588
+ deps.events.setTurnState(sessionId, turnId, abortedByClient || ac.signal.aborted ? "aborted" : "failed", {
589
+ code: abortedByClient || ac.signal.aborted ? "CLIENT_ABORTED" : "CHAT_TURN_FAILED",
590
+ message,
591
+ });
271
592
  if (!closed) {
272
- writeChatSse(res, {
593
+ const persisted = deps.events.append(sessionId, turnId, {
273
594
  kind: "error",
274
- sessionId,
275
- message: error instanceof Error ? error.message : String(error),
595
+ data: { message },
276
596
  });
597
+ if (!res.writableEnded)
598
+ writeChatSse(res, persisted);
277
599
  }
278
600
  }
279
601
  finally {
@@ -285,6 +607,196 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
285
607
  }
286
608
  }
287
609
  }
610
+ /**
611
+ * Resource-oriented turn start (M1-T02/T15): acknowledge quickly, while the
612
+ * Pi prompt continues independently of this HTTP response and publishes to the
613
+ * persisted event ring consumed by GET /events.
614
+ */
615
+ export async function handleCreateChatTurn(req, res, deps, sessionId) {
616
+ const gate = gateMutation(req, deps);
617
+ if (!gate.ok) {
618
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
619
+ return;
620
+ }
621
+ let body;
622
+ try {
623
+ body = await readJsonBody(req);
624
+ }
625
+ catch (error) {
626
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
627
+ return;
628
+ }
629
+ const text = typeof body.text === "string" ? body.text.trim() : "";
630
+ let images;
631
+ try {
632
+ images = parseChatImages(body.images);
633
+ }
634
+ catch (error) {
635
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_ATTACHMENT", message: error instanceof Error ? error.message : String(error) } });
636
+ return;
637
+ }
638
+ if (!text) {
639
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "text is required" } });
640
+ return;
641
+ }
642
+ if (!deps.runtime.hasSession(sessionId)) {
643
+ const record = await deps.store.get(sessionId);
644
+ if (!record) {
645
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
646
+ return;
647
+ }
648
+ const reopen = await deps.runtime.reopenSession({
649
+ sessionId,
650
+ sessionFile: record.sessionFile,
651
+ ...(record.modelProvider && record.modelId
652
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
653
+ : {}),
654
+ });
655
+ if (!reopen.ok) {
656
+ sendJson(res, 409, { ok: false, error: { code: reopen.code, message: reopen.message } });
657
+ return;
658
+ }
659
+ }
660
+ const actionContext = deps.runtime.actionContext;
661
+ if (!actionContext) {
662
+ sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
663
+ return;
664
+ }
665
+ const created = deps.events.createTurn(sessionId);
666
+ if (!created.ok) {
667
+ sendJson(res, 409, {
668
+ ok: false,
669
+ error: { code: "TURN_ACTIVE", message: `chat turn already active: ${created.activeTurn.turnId}` },
670
+ });
671
+ return;
672
+ }
673
+ const turn = created.turn;
674
+ deps.events.setTurnState(sessionId, turn.turnId, "running");
675
+ sendJson(res, 202, { ok: true, turnId: turn.turnId, ordinal: turn.ordinal, state: "running" });
676
+ // Deliberately detached from the request lifecycle: closing the POST
677
+ // response cannot abort the prompt; clients follow GET /events instead.
678
+ void (async () => {
679
+ const { runChatTurn } = await import("./session-store.js");
680
+ try {
681
+ const outcome = await runChatTurn({
682
+ runtime: deps.runtime,
683
+ store: deps.store,
684
+ actionContext,
685
+ sessionId,
686
+ text,
687
+ images,
688
+ onOperationAccepted: (accepted) => deps.operationLinker.link({
689
+ sessionId,
690
+ turnId: turn.turnId,
691
+ toolCallId: accepted.toolCallId,
692
+ operationId: accepted.operationId,
693
+ }),
694
+ onEvent: (event) => {
695
+ if (event.type === "heartbeat")
696
+ return;
697
+ const partial = turnEventToStorePartial(event);
698
+ if (partial)
699
+ deps.events.append(sessionId, turn.turnId, partial);
700
+ },
701
+ });
702
+ deps.events.setTurnState(sessionId, turn.turnId, outcome.ok ? "settled" : "failed", outcome.error);
703
+ }
704
+ catch (error) {
705
+ const message = error instanceof Error ? error.message : String(error);
706
+ deps.events.append(sessionId, turn.turnId, { kind: "error", data: { message } });
707
+ deps.events.setTurnState(sessionId, turn.turnId, "failed", {
708
+ code: "CHAT_TURN_FAILED",
709
+ message,
710
+ });
711
+ }
712
+ })();
713
+ }
714
+ export async function handleGetChatTurn(_req, res, deps, sessionId, turnId) {
715
+ const turn = deps.events.getTurn(sessionId, turnId);
716
+ if (!turn) {
717
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat turn not found: ${turnId}` } });
718
+ return;
719
+ }
720
+ sendJson(res, 200, { ok: true, turn });
721
+ }
722
+ export async function handleChatEventsStream(req, res, deps, sessionId) {
723
+ // Read routes (capabilities/sessions GET) are open to loopback; the events
724
+ // stream is a READ and does NOT require the mutation gate, but we still need
725
+ // the boot cookie so only the Console browser can poll a live session.
726
+ const record = await deps.store.get(sessionId);
727
+ if (!record) {
728
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
729
+ return;
730
+ }
731
+ await deps.operationLinker.recoverSession(sessionId);
732
+ openSseResponse(res);
733
+ // Resume from the client's last seen eventId (Last-Event-ID header, T04).
734
+ const lastEventIdHeader = req.headers["last-event-id"];
735
+ const afterSeq = parseLastEventId(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
736
+ const replay = deps.events.listFrom(sessionId, afterSeq);
737
+ let lastSeq = afterSeq;
738
+ if ("error" in replay) {
739
+ // Cursor aged out of the bounded ring (T07 fence): tell the client to
740
+ // reconcile from a fresh snapshot rather than render a gap as if current.
741
+ writeChatSse(res, {
742
+ kind: "reconcile",
743
+ sessionId,
744
+ reason: "cursor_expired",
745
+ minSeq: replay.minSeq,
746
+ latestTurnId: deps.events.latestTurnId(sessionId),
747
+ });
748
+ // Fall back to full retained snapshot so the client can re-render.
749
+ for (const ev of deps.events.snapshot(sessionId)) {
750
+ writeChatSse(res, ev);
751
+ lastSeq = Math.max(lastSeq, ev.seq);
752
+ }
753
+ }
754
+ else {
755
+ for (const ev of replay.events) {
756
+ writeChatSse(res, ev);
757
+ lastSeq = Math.max(lastSeq, ev.seq);
758
+ }
759
+ }
760
+ let closed = false;
761
+ res.once("close", () => {
762
+ closed = true;
763
+ });
764
+ // Follow live appends for a short window so a reconnect that lands while a
765
+ // turn is still streaming continues to receive new events (T05). If nothing
766
+ // arrives within the follow window we close the stream idempotently; the
767
+ // client re-opens GET /events and resumes from its tracked Last-Event-ID.
768
+ const FOLLOW_WINDOW_MS = 25_000;
769
+ const IDLE_CLOSE_MS = 12_000;
770
+ const unsub = deps.events.subscribe(sessionId, (ev) => {
771
+ if (closed || res.writableEnded)
772
+ return;
773
+ if (ev.seq <= lastSeq)
774
+ return; // already sent during replay
775
+ writeChatSse(res, ev);
776
+ lastSeq = ev.seq;
777
+ });
778
+ const startedAt = Date.now();
779
+ const tick = setInterval(() => {
780
+ if (closed || res.writableEnded) {
781
+ clearInterval(tick);
782
+ return;
783
+ }
784
+ const elapsed = Date.now() - startedAt;
785
+ // Emit a keep-alive comment so proxies/browsers don't drop the idle stream.
786
+ res.write(": keepalive\n\n");
787
+ if (elapsed >= FOLLOW_WINDOW_MS) {
788
+ clearInterval(tick);
789
+ unsub();
790
+ if (!res.writableEnded)
791
+ res.end();
792
+ }
793
+ }, IDLE_CLOSE_MS);
794
+ // If the client disconnects before the window elapses, clean up.
795
+ res.once("close", () => {
796
+ clearInterval(tick);
797
+ unsub();
798
+ });
799
+ }
288
800
  export async function handleDeleteChatSession(req, res, deps, sessionId) {
289
801
  const gate = gateMutation(req, deps);
290
802
  if (!gate.ok) {
@@ -295,7 +807,625 @@ export async function handleDeleteChatSession(req, res, deps, sessionId) {
295
807
  return;
296
808
  }
297
809
  deps.runtime.dispose(sessionId);
298
- sendJson(res, 200, { ok: true, sessionId, disposed: true });
810
+ deps.operationLinker.clearSession(sessionId);
811
+ deps.events.clear(sessionId);
812
+ const removed = await deps.store.remove(sessionId);
813
+ await new ComposerDraftStore(deps.appData).remove(sessionId);
814
+ if (!removed) {
815
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
816
+ return;
817
+ }
818
+ sendJson(res, 200, { ok: true, sessionId, disposed: true, removed: true });
819
+ }
820
+ export async function handleGetTaskContext(_res, res, deps, sessionId) {
821
+ if (!await requireChatSession(res, deps, sessionId))
822
+ return;
823
+ const events = deps.events.snapshot(sessionId);
824
+ const context = projectTaskContext(events);
825
+ const latest = events.at(-1);
826
+ sendJson(res, 200, {
827
+ ok: true,
828
+ context,
829
+ lastSeq: latest?.seq ?? 0,
830
+ lastEventId: latest?.eventId,
831
+ });
832
+ }
833
+ async function handleComposerDraft(req, res, deps, sessionId) {
834
+ const gate = gateMutation(req, deps);
835
+ if (!gate.ok) {
836
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
837
+ return;
838
+ }
839
+ if (!await requireChatSession(res, deps, sessionId))
840
+ return;
841
+ try {
842
+ const body = await readJsonBody(req, 70 * 1024);
843
+ const text = typeof body.text === "string" ? body.text : "";
844
+ await new ComposerDraftStore(deps.appData).save(sessionId, text);
845
+ sendJson(res, 200, { ok: true });
846
+ }
847
+ catch (error) {
848
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
849
+ }
850
+ }
851
+ async function handleRepoBrowser(res, deps, kind, rawPath) {
852
+ try {
853
+ const data = kind === "tree"
854
+ ? await listRepoDirectory(deps.appData.repoRoot, rawPath)
855
+ : await readRepoPreview(deps.appData.repoRoot, rawPath);
856
+ sendJson(res, 200, { ok: true, data });
857
+ }
858
+ catch (error) {
859
+ const failure = error instanceof RepoBrowserError
860
+ ? error
861
+ : new RepoBrowserError(500, "READ_FAILED", error instanceof Error ? error.message : String(error));
862
+ sendJson(res, failure.status, { ok: false, error: { code: failure.code, message: failure.message } });
863
+ }
864
+ }
865
+ async function handlePiConfig(req, res, deps, resource) {
866
+ try {
867
+ if (resource === "models") {
868
+ const agentDir = await deps.runtime.getAgentDir();
869
+ if (req.method === "GET")
870
+ sendJson(res, 200, { ok: true, data: await readModelsConfig(agentDir) });
871
+ else {
872
+ const gate = gateMutation(req, deps);
873
+ if (!gate.ok) {
874
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
875
+ return;
876
+ }
877
+ const body = await readJsonBody(req);
878
+ sendJson(res, 200, { ok: true, data: await patchModelsConfig(agentDir, body) });
879
+ }
880
+ return;
881
+ }
882
+ if (resource === "packages") {
883
+ sendJson(res, 200, { ok: true, data: { packages: await readPackageInventory(await deps.runtime.getAgentDir(), deps.appData.repoRoot), mutationSupported: false } });
884
+ return;
885
+ }
886
+ if (req.method === "GET") {
887
+ const disabledNames = await readSkillPreferences(deps.appData);
888
+ const loaded = await loadOperatorChatInstructionSkills(deps.skillsDir);
889
+ sendJson(res, 200, { ok: true, data: {
890
+ allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((name) => {
891
+ const skill = loaded.loaded.find((item) => item.name === name);
892
+ return {
893
+ name,
894
+ description: skill?.description ?? "",
895
+ contentHash: skill
896
+ ? createHash("sha256").update(skill.bodyText).digest("hex")
897
+ : undefined,
898
+ };
899
+ }),
900
+ conditional: OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS,
901
+ denied: OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS,
902
+ disabledNames,
903
+ } });
904
+ }
905
+ else {
906
+ const gate = gateMutation(req, deps);
907
+ if (!gate.ok) {
908
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
909
+ return;
910
+ }
911
+ const body = await readJsonBody(req);
912
+ const disabledNames = await writeSkillPreferences(deps.appData, Array.isArray(body.disabledNames) ? body.disabledNames.filter((name) => typeof name === "string") : []);
913
+ deps.runtime.setDisabledInstructionSkills(disabledNames);
914
+ sendJson(res, 200, { ok: true, data: { disabledNames, appliesTo: "new-sessions" } });
915
+ }
916
+ }
917
+ catch (error) {
918
+ const failure = error;
919
+ sendJson(res, failure.status ?? 400, { ok: false, error: { code: failure.code ?? "INVALID_INPUT", message: failure.message } });
920
+ }
921
+ }
922
+ async function handleChatFiles(res, deps, sessionId, query) {
923
+ if (!await requireChatSession(res, deps, sessionId))
924
+ return;
925
+ const q = query.trim().toLowerCase();
926
+ const files = (await walkRepoFiles(deps.appData.repoRoot, { skipSensitive: true, maxFiles: 5000 }))
927
+ .map((entry) => entry.repoRelative).filter((file) => !isSensitivePath(file) && (!q || file.toLowerCase().includes(q)))
928
+ .sort((a, b) => a.localeCompare(b)).slice(0, 50).map((path) => ({ path }));
929
+ sendJson(res, 200, { ok: true, files });
930
+ }
931
+ async function handleRuntimeContext(res, deps, sessionId) {
932
+ const record = await deps.store.get(sessionId);
933
+ if (!record) {
934
+ sendJson(res, 404, { ok: false });
935
+ return;
936
+ }
937
+ const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
938
+ const selection = deps.runtime.getRuntimeSelection(sessionId);
939
+ sendJson(res, 200, { ok: true, context: projectRuntimeContext({ systemPrompt: "Operator Chat isolated system prompt", skills: skills.loaded, model: selection.model ?? (record.modelProvider && record.modelId ? { provider: record.modelProvider, modelId: record.modelId } : undefined), thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel, activeTools: selection.activeTools }) });
940
+ }
941
+ async function handleModels(req, res, deps, sessionId) {
942
+ if (!await requireChatSession(res, deps, sessionId))
943
+ return;
944
+ try {
945
+ if ((req.method ?? "GET").toUpperCase() === "GET") {
946
+ sendJson(res, 200, { ok: true, models: await deps.runtime.listModels(sessionId), thinkingLevels: THINKING_LEVELS });
947
+ return;
948
+ }
949
+ const gate = gateMutation(req, deps);
950
+ if (!gate.ok) {
951
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
952
+ return;
953
+ }
954
+ const body = await readJsonBody(req);
955
+ let updated;
956
+ if (typeof body.provider === "string" && typeof body.modelId === "string") {
957
+ const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
958
+ updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
959
+ }
960
+ else if (typeof body.thinkingLevel === "string" && THINKING_LEVELS.includes(body.thinkingLevel)) {
961
+ const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
962
+ updated = await deps.store.setRuntimeSelection(sessionId, { thinkingLevel });
963
+ }
964
+ else {
965
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
966
+ return;
967
+ }
968
+ sendJson(res, 200, { ok: true, session: updated });
969
+ }
970
+ catch (error) {
971
+ sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
972
+ }
973
+ }
974
+ async function requireChatSession(res, deps, sessionId) {
975
+ if (await deps.store.get(sessionId))
976
+ return true;
977
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
978
+ return false;
979
+ }
980
+ async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
981
+ const gate = gateMutation(req, deps);
982
+ if (!gate.ok) {
983
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
984
+ return;
985
+ }
986
+ if (!await requireChatSession(res, deps, sessionId))
987
+ return;
988
+ try {
989
+ const body = await readJsonBody(req);
990
+ const operationId = typeof body.operationId === "string" ? body.operationId.trim() : "";
991
+ if (!operationId) {
992
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "operationId is required" } });
993
+ return;
994
+ }
995
+ const operations = deps.runtime.actionContext?.operations;
996
+ if (!operations || !await operations.get(operationId)) {
997
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `operation not found: ${operationId}` } });
998
+ return;
999
+ }
1000
+ await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "workspace", toolCallId: `workspace-${operationId}`, operationId });
1001
+ sendJson(res, 200, { ok: true });
1002
+ }
1003
+ catch (error) {
1004
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: error instanceof Error ? error.message : String(error) } });
1005
+ }
1006
+ }
1007
+ async function handleStartInterview(req, res, deps, sessionId) {
1008
+ const gate = gateMutation(req, deps);
1009
+ if (!gate.ok) {
1010
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1011
+ return;
1012
+ }
1013
+ if (!await requireChatSession(res, deps, sessionId))
1014
+ return;
1015
+ try {
1016
+ const body = await readJsonBody(req);
1017
+ const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
1018
+ if (!taskId) {
1019
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "taskId is required" } });
1020
+ return;
1021
+ }
1022
+ const adapter = new ChatInterviewAdapter(deps.appData, deps.events);
1023
+ const state = await adapter.start({ operatorSessionId: sessionId, taskId, ...(typeof body.title === "string" ? { title: body.title } : {}), ...(body.initialDraft && typeof body.initialDraft === "object" ? { initialDraft: body.initialDraft } : {}) });
1024
+ sendJson(res, 201, { ok: true, ...projectChatInterviewState(state) });
1025
+ }
1026
+ catch (error) {
1027
+ sendJson(res, 400, { ok: false, error: { code: "INTERVIEW_START_FAILED", message: error instanceof Error ? error.message : String(error) } });
1028
+ }
1029
+ }
1030
+ async function handleAnswerInterview(req, res, deps, sessionId, interviewSessionId) {
1031
+ const gate = gateMutation(req, deps);
1032
+ if (!gate.ok) {
1033
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1034
+ return;
1035
+ }
1036
+ if (!await requireChatSession(res, deps, sessionId))
1037
+ return;
1038
+ try {
1039
+ const body = await readJsonBody(req);
1040
+ const questionId = typeof body.questionId === "string" ? body.questionId : "";
1041
+ const response = typeof body.response === "string" ? body.response : "";
1042
+ if (!questionId || !response) {
1043
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "questionId and response are required" } });
1044
+ return;
1045
+ }
1046
+ const state = await new ChatInterviewAdapter(deps.appData, deps.events).answer({ operatorSessionId: sessionId, interviewSessionId, questionId, response, ...(typeof body.text === "string" ? { text: body.text } : {}) });
1047
+ sendJson(res, 200, { ok: true, ...projectChatInterviewState(state) });
1048
+ }
1049
+ catch (error) {
1050
+ sendJson(res, 400, { ok: false, error: { code: "INTERVIEW_ANSWER_FAILED", message: error instanceof Error ? error.message : String(error) } });
1051
+ }
1052
+ }
1053
+ async function handleGetInterviewFact(res, deps, sessionId, interviewSessionId, fact) {
1054
+ if (!await requireChatSession(res, deps, sessionId))
1055
+ return;
1056
+ const state = await new ChatInterviewAdapter(deps.appData, deps.events).get(interviewSessionId);
1057
+ if (!state || state.interview.operatorSessionId !== sessionId) {
1058
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `interview session not found: ${interviewSessionId}` } });
1059
+ return;
1060
+ }
1061
+ const projected = projectChatInterviewState(state);
1062
+ sendJson(res, 200, { ok: true, [fact]: projected[fact] });
1063
+ }
1064
+ async function ctxConfirmationCard(deps, sessionId, turnId, confirmationId) {
1065
+ const confirmation = await deps.runtime.actionContext?.confirmations.get(confirmationId);
1066
+ if (!confirmation)
1067
+ return;
1068
+ const taskId = String(confirmation.taskContractBinding.taskId ?? "");
1069
+ appendHumanGateCard(deps, sessionId, makeHumanGateCard({
1070
+ cardId: `dag-${confirmationId}`,
1071
+ gateType: "dag-run",
1072
+ state: confirmation.state === "confirmed" ? "confirmed" : "prepared",
1073
+ taskId,
1074
+ confirmationId,
1075
+ expiresAt: confirmation.expiresAt,
1076
+ inspectHref: `/inspect/?taskId=${encodeURIComponent(taskId)}#/`,
1077
+ humanGateToken: confirmation.humanGateToken,
1078
+ displayOnlyChallenges: ["source", "writer-scope", "verification", "human-gates"],
1079
+ contractRevision: confirmation.taskContractBinding.revision,
1080
+ }));
1081
+ void turnId;
1082
+ }
1083
+ async function ctxMutationGateCard(deps, sessionId, receiptId) {
1084
+ const { MutationGateReceiptStore } = await import("../mutation-gate-receipt-store.js");
1085
+ const store = new MutationGateReceiptStore(deps.appData);
1086
+ const receipt = await store.get(receiptId);
1087
+ if (!receipt)
1088
+ return;
1089
+ appendHumanGateCard(deps, sessionId, makeHumanGateCard({
1090
+ cardId: `mutation-${receipt.receiptId}`,
1091
+ gateType: "mutation",
1092
+ state: "prepared",
1093
+ taskId: receipt.action,
1094
+ receiptId: receipt.receiptId,
1095
+ expiresAt: receipt.expiresAt,
1096
+ humanGateToken: receipt.humanGateToken,
1097
+ inspectHref: `/inspect/#/`,
1098
+ }));
1099
+ }
1100
+ function actionResultBody(result) {
1101
+ const body = result.body;
1102
+ return (body.result && typeof body.result === "object" ? body.result : body);
1103
+ }
1104
+ function appendHumanGateCard(deps, sessionId, card) {
1105
+ deps.events.append(sessionId, deps.events.latestTurnId(sessionId) ?? "human-gate", {
1106
+ kind: "human-gate-card",
1107
+ data: card,
1108
+ });
1109
+ }
1110
+ async function liveContractBinding(ctx, taskId) {
1111
+ const draft = await ctx.drafts.get(taskId);
1112
+ if (!draft)
1113
+ throw new Error(`draft not found: ${taskId}`);
1114
+ const shown = await dispatchOperatorAction(ctx, { action: "contractShow", actionParams: { taskId } });
1115
+ if (shown.kind === "error")
1116
+ throw new Error("contractShow failed");
1117
+ const result = actionResultBody(shown);
1118
+ const state = result.state;
1119
+ const revision = state?.ref?.revision ?? 0;
1120
+ const expectedObservedHash = state?.observedCanonicalHash ?? "0".repeat(64);
1121
+ return { taskId, revision, canonicalHash: state?.ref?.canonicalHash ?? expectedObservedHash, draftSha256: draft.draftSha256, expectedObservedHash };
1122
+ }
1123
+ async function handleContractApplyHumanGate(req, res, deps, sessionId, step) {
1124
+ const gate = gateMutation(req, deps);
1125
+ if (!gate.ok) {
1126
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1127
+ return;
1128
+ }
1129
+ if (!await requireChatSession(res, deps, sessionId))
1130
+ return;
1131
+ const ctx = deps.runtime.actionContext;
1132
+ if (!ctx) {
1133
+ sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
1134
+ return;
1135
+ }
1136
+ try {
1137
+ const body = await readJsonBody(req);
1138
+ const receipts = new ContractApplyReceiptStore(deps.appData);
1139
+ if (step === "prepare") {
1140
+ const taskId = typeof body.taskId === "string" ? body.taskId.trim() : "";
1141
+ if (!taskId)
1142
+ throw new Error("taskId is required");
1143
+ const binding = await liveContractBinding(ctx, taskId);
1144
+ const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
1145
+ const receiptId = `apply_${crypto.randomUUID()}`;
1146
+ const humanGateToken = issueHumanGateToken({ confirmationId: receiptId, payloadHash: contractApplyPayloadHash(binding), expiresAt }, deps.bootToken.confirmationToken);
1147
+ const receipt = await receipts.prepare({ receiptId, operatorSessionId: sessionId, ...binding, expiresAt, humanGateToken });
1148
+ const card = makeHumanGateCard({ cardId: `contract-${receipt.receiptId}`, gateType: "contract-apply", state: "prepared", taskId, receiptId: receipt.receiptId, expiresAt, humanGateToken, contractRevision: binding.revision, contractDiffSummary: `revision ${binding.revision}; draft ${binding.draftSha256.slice(0, 12)}; observed ${binding.expectedObservedHash.slice(0, 12)}`, inspectHref: `/inspect/?taskId=${encodeURIComponent(taskId)}#/` });
1149
+ appendHumanGateCard(deps, sessionId, card);
1150
+ sendJson(res, 201, { ok: true, card });
1151
+ return;
1152
+ }
1153
+ const receiptId = typeof body.receiptId === "string" ? body.receiptId : "";
1154
+ const receipt = await receipts.get(receiptId);
1155
+ if (!receipt || receipt.operatorSessionId !== sessionId) {
1156
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: "contract apply receipt not found" } });
1157
+ return;
1158
+ }
1159
+ const current = await liveContractBinding(ctx, receipt.taskId);
1160
+ const check = verifyHumanGateToken(body.humanGateToken, { confirmationId: receipt.receiptId, payloadHash: contractApplyPayloadHash(current) }, deps.bootToken.confirmationToken);
1161
+ if (!check.ok) {
1162
+ sendJson(res, 403, { ok: false, error: { code: check.code, message: check.message } });
1163
+ return;
1164
+ }
1165
+ const reserved = await receipts.tryReserve(receiptId);
1166
+ if (!reserved.ok) {
1167
+ sendJson(res, reserved.code === "NOT_FOUND" ? 404 : reserved.code === "EXPIRED" ? 410 : 409, {
1168
+ ok: false,
1169
+ error: { code: reserved.code, message: reserved.message },
1170
+ });
1171
+ return;
1172
+ }
1173
+ if (!reserved.reserved) {
1174
+ const operationId = reserved.receipt.operationId;
1175
+ const card = makeHumanGateCard({
1176
+ cardId: `contract-${receiptId}`,
1177
+ gateType: "contract-apply",
1178
+ state: "dispatched",
1179
+ taskId: receipt.taskId,
1180
+ receiptId,
1181
+ expiresAt: receipt.expiresAt,
1182
+ contractRevision: receipt.revision,
1183
+ operationId,
1184
+ inspectHref: operationId
1185
+ ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/`
1186
+ : `/inspect/?taskId=${encodeURIComponent(receipt.taskId)}#/`,
1187
+ });
1188
+ appendHumanGateCard(deps, sessionId, card);
1189
+ sendJson(res, 202, {
1190
+ ok: true,
1191
+ card,
1192
+ result: operationId
1193
+ ? { operationId, state: "accepted", action: "contractApply", clientRequestId: `chat-apply-${receiptId}` }
1194
+ : { state: reserved.reason },
1195
+ });
1196
+ return;
1197
+ }
1198
+ const clientRequestId = typeof body.clientRequestId === "string" && body.clientRequestId.trim()
1199
+ ? body.clientRequestId.trim()
1200
+ : `chat-apply-${receiptId}`;
1201
+ const result = await dispatchOperatorAction(ctx, {
1202
+ action: "contractApply",
1203
+ actionParams: {
1204
+ taskId: receipt.taskId,
1205
+ expectedRevision: receipt.revision,
1206
+ expectedObservedHash: receipt.expectedObservedHash,
1207
+ },
1208
+ clientRequestId,
1209
+ });
1210
+ if (result.kind === "error") {
1211
+ await receipts.releaseToPrepared(receiptId);
1212
+ sendJson(res, result.status, result.body);
1213
+ return;
1214
+ }
1215
+ const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
1216
+ await receipts.markDispatched(receiptId, operationId);
1217
+ const card = makeHumanGateCard({ cardId: `contract-${receiptId}`, gateType: "contract-apply", state: "dispatched", taskId: receipt.taskId, receiptId, expiresAt: receipt.expiresAt, contractRevision: receipt.revision, operationId, inspectHref: operationId ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/` : `/inspect/?taskId=${encodeURIComponent(receipt.taskId)}#/` });
1218
+ appendHumanGateCard(deps, sessionId, card);
1219
+ if (operationId)
1220
+ await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "human-gate", toolCallId: "human-contract-apply", operationId });
1221
+ sendJson(res, result.status, { ok: true, card, result: actionResultBody(result) });
1222
+ }
1223
+ catch (error) {
1224
+ sendJson(res, 400, { ok: false, error: { code: "HUMAN_GATE_FAILED", message: error instanceof Error ? error.message : String(error) } });
1225
+ }
1226
+ }
1227
+ async function handleDagHumanGate(req, res, deps, sessionId, step) {
1228
+ const gate = gateMutation(req, deps);
1229
+ if (!gate.ok) {
1230
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1231
+ return;
1232
+ }
1233
+ if (!await requireChatSession(res, deps, sessionId))
1234
+ return;
1235
+ const ctx = deps.runtime.actionContext;
1236
+ if (!ctx) {
1237
+ sendJson(res, 503, { ok: false, error: { code: "CHAT_NO_ACTION_CONTEXT", message: "Chat runtime has no operator action context wired" } });
1238
+ return;
1239
+ }
1240
+ try {
1241
+ const body = await readJsonBody(req);
1242
+ const action = step === "prepare" ? "prepareDagConfirmation" : step === "confirm" ? "confirmDagConfirmation" : "runDag";
1243
+ const result = await dispatchOperatorAction(ctx, {
1244
+ action,
1245
+ actionParams: body,
1246
+ clientRequestId: typeof body.clientRequestId === "string" ? body.clientRequestId : `chat-${sessionId}-${Date.now()}`,
1247
+ });
1248
+ if (result.kind === "error") {
1249
+ sendJson(res, result.status, result.body);
1250
+ return;
1251
+ }
1252
+ const projected = actionResultBody(result);
1253
+ const confirmation = (projected.confirmation ?? await ctx.confirmations.get(String(body.confirmationId ?? "")));
1254
+ const confirmationId = String(confirmation?.confirmationId ?? body.confirmationId ?? "");
1255
+ const taskBinding = confirmation?.taskContractBinding;
1256
+ const taskId = String(taskBinding?.taskId ?? body.taskId ?? "");
1257
+ const state = step === "prepare" ? "prepared" : step === "confirm" ? "confirmed" : "dispatched";
1258
+ const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
1259
+ const dagText = typeof body.dagText === "string" ? body.dagText : typeof body.dagJson === "string" ? body.dagJson : "";
1260
+ let dagSpine;
1261
+ try {
1262
+ const parsed = JSON.parse(dagText);
1263
+ dagSpine = parsed.nodes?.map((node) => String(node.id ?? "")).filter(Boolean);
1264
+ }
1265
+ catch { /* display summary is optional */ }
1266
+ const card = makeHumanGateCard({
1267
+ cardId: `dag-${confirmationId}`,
1268
+ gateType: "dag-run",
1269
+ state,
1270
+ taskId,
1271
+ confirmationId,
1272
+ expiresAt: String(confirmation?.expiresAt ?? new Date().toISOString()),
1273
+ inspectHref: operationId ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/` : `/inspect/?taskId=${encodeURIComponent(taskId)}#/`,
1274
+ ...(confirmation?.humanGateToken ? { humanGateToken: confirmation.humanGateToken } : {}),
1275
+ displayOnlyChallenges: Array.isArray(projected.requiredChallenges) ? projected.requiredChallenges.map(String) : undefined,
1276
+ contractRevision: typeof taskBinding?.revision === "number" ? taskBinding.revision : undefined,
1277
+ dagSpine,
1278
+ operationId,
1279
+ });
1280
+ appendHumanGateCard(deps, sessionId, card);
1281
+ if (operationId)
1282
+ await deps.operationLinker.link({ sessionId, turnId: deps.events.latestTurnId(sessionId) ?? "human-gate", toolCallId: `human-${step}`, operationId });
1283
+ sendJson(res, result.status, { ok: true, card, result: projected });
1284
+ }
1285
+ catch (error) {
1286
+ sendJson(res, 400, { ok: false, error: { code: "HUMAN_GATE_FAILED", message: error instanceof Error ? error.message : String(error) } });
1287
+ }
1288
+ }
1289
+ async function handleMutationHumanGate(req, res, deps, sessionId) {
1290
+ const gate = gateMutation(req, deps);
1291
+ if (!gate.ok) {
1292
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1293
+ return;
1294
+ }
1295
+ if (!(await requireChatSession(res, deps, sessionId)))
1296
+ return;
1297
+ const ctx = deps.runtime.actionContext;
1298
+ if (!ctx) {
1299
+ sendJson(res, 503, {
1300
+ ok: false,
1301
+ error: {
1302
+ code: "CHAT_NO_ACTION_CONTEXT",
1303
+ message: "Chat runtime has no operator action context wired",
1304
+ },
1305
+ });
1306
+ return;
1307
+ }
1308
+ try {
1309
+ const body = await readJsonBody(req);
1310
+ const receiptId = typeof body.receiptId === "string" ? body.receiptId : "";
1311
+ const { MutationGateReceiptStore } = await import("../mutation-gate-receipt-store.js");
1312
+ const store = new MutationGateReceiptStore(deps.appData);
1313
+ const receipt = await store.get(receiptId);
1314
+ if (!receipt || receipt.operatorSessionId !== ctx.operatorSessionId) {
1315
+ sendJson(res, 404, {
1316
+ ok: false,
1317
+ error: { code: "NOT_FOUND", message: "mutation gate receipt not found" },
1318
+ });
1319
+ return;
1320
+ }
1321
+ const result = await dispatchOperatorAction(ctx, {
1322
+ action: receipt.action,
1323
+ actionParams: {
1324
+ ...receipt.actionParams,
1325
+ confirmationId: receipt.receiptId,
1326
+ humanGateToken: body.humanGateToken ?? receipt.humanGateToken,
1327
+ },
1328
+ clientRequestId: `chat-mutation-${receipt.receiptId}`,
1329
+ });
1330
+ if (result.kind === "error") {
1331
+ sendJson(res, result.status, result.body);
1332
+ return;
1333
+ }
1334
+ const operationId = result.kind === "accepted" ? result.body.operationId : undefined;
1335
+ const card = makeHumanGateCard({
1336
+ cardId: `mutation-${receiptId}`,
1337
+ gateType: "mutation",
1338
+ state: "dispatched",
1339
+ taskId: receipt.action,
1340
+ receiptId,
1341
+ expiresAt: receipt.expiresAt,
1342
+ operationId,
1343
+ inspectHref: operationId
1344
+ ? `/inspect/?operationId=${encodeURIComponent(operationId)}#/`
1345
+ : `/inspect/#/`,
1346
+ });
1347
+ appendHumanGateCard(deps, sessionId, card);
1348
+ if (operationId) {
1349
+ await deps.operationLinker.link({
1350
+ sessionId,
1351
+ turnId: deps.events.latestTurnId(sessionId) ?? "human-gate",
1352
+ toolCallId: "human-mutation-apply",
1353
+ operationId,
1354
+ });
1355
+ }
1356
+ sendJson(res, result.status, { ok: true, card, result: actionResultBody(result) });
1357
+ }
1358
+ catch (error) {
1359
+ sendJson(res, 400, {
1360
+ ok: false,
1361
+ error: {
1362
+ code: "HUMAN_GATE_FAILED",
1363
+ message: error instanceof Error ? error.message : String(error),
1364
+ },
1365
+ });
1366
+ }
1367
+ }
1368
+ async function handleConfirmTaskKind(req, res, deps, sessionId, interviewSessionId) {
1369
+ const gate = gateMutation(req, deps);
1370
+ if (!gate.ok) {
1371
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1372
+ return;
1373
+ }
1374
+ if (!await requireChatSession(res, deps, sessionId))
1375
+ return;
1376
+ try {
1377
+ const body = await readJsonBody(req);
1378
+ if (typeof body.taskKind !== "string" || !body.taskKind.trim()) {
1379
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "taskKind is required" } });
1380
+ return;
1381
+ }
1382
+ const state = await new ChatInterviewAdapter(deps.appData, deps.events).confirmTaskKind({ operatorSessionId: sessionId, interviewSessionId, taskKind: body.taskKind });
1383
+ sendJson(res, 200, { ok: true, ...projectChatInterviewState(state) });
1384
+ }
1385
+ catch (error) {
1386
+ sendJson(res, 400, { ok: false, error: { code: "TASK_KIND_CONFIRM_FAILED", message: error instanceof Error ? error.message : String(error) } });
1387
+ }
1388
+ }
1389
+ export async function handleChatCompact(req, res, deps, sessionId) {
1390
+ const gate = gateMutation(req, deps);
1391
+ if (!gate.ok) {
1392
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1393
+ return;
1394
+ }
1395
+ if (deps.events.getActiveTurn(sessionId)) {
1396
+ sendJson(res, 409, { ok: false, error: { code: "TURN_ACTIVE", message: "cannot compact an active turn" } });
1397
+ return;
1398
+ }
1399
+ const record = await deps.store.get(sessionId);
1400
+ if (!record) {
1401
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: `chat session not found: ${sessionId}` } });
1402
+ return;
1403
+ }
1404
+ if (!deps.runtime.hasSession(sessionId)) {
1405
+ const reopened = await deps.runtime.reopenSession({ sessionId, sessionFile: record.sessionFile, ...(record.modelProvider && record.modelId ? { model: { provider: record.modelProvider, modelId: record.modelId } } : {}) });
1406
+ if (!reopened.ok) {
1407
+ sendJson(res, 409, { ok: false, error: { code: reopened.code, message: reopened.message } });
1408
+ return;
1409
+ }
1410
+ }
1411
+ try {
1412
+ const snapshot = await deps.runtime.compact(sessionId);
1413
+ const event = deps.events.append(sessionId, deps.events.latestTurnId(sessionId) ?? `${sessionId}:compact`, { kind: "compact", data: snapshot });
1414
+ sendJson(res, 200, { ok: true, snapshot, eventId: event.eventId });
1415
+ }
1416
+ catch (error) {
1417
+ sendJson(res, 409, { ok: false, error: { code: "COMPACTION_FAILED", message: error instanceof Error ? error.message : String(error) } });
1418
+ }
1419
+ }
1420
+ export async function handleChatMessages(res, deps, sessionId, url) {
1421
+ try {
1422
+ const limit = Number(url.searchParams.get("limit") ?? 30);
1423
+ const page = await deps.store.listMessages(sessionId, { limit: Number.isFinite(limit) ? limit : 30, beforeId: url.searchParams.get("beforeId") ?? undefined });
1424
+ sendJson(res, 200, { ok: true, ...page });
1425
+ }
1426
+ catch (error) {
1427
+ sendJson(res, 404, { ok: false, error: { code: "NOT_FOUND", message: error instanceof Error ? error.message : String(error) } });
1428
+ }
299
1429
  }
300
1430
  /**
301
1431
  * Dispatch a chat HTTP request. Returns true if handled.
@@ -304,26 +1434,181 @@ export async function handleDeleteChatSession(req, res, deps, sessionId) {
304
1434
  export async function handleChatRequest(req, res, deps, pathname) {
305
1435
  if (!deps)
306
1436
  return false;
307
- if (!pathname.startsWith("/api/operator/v1/chat/"))
1437
+ if (!pathname.startsWith("/api/operator/v1/chat/") && !pathname.startsWith("/api/operator/v1/pi/"))
308
1438
  return false;
309
1439
  const method = (req.method ?? "GET").toUpperCase();
310
1440
  const url = new URL(req.url ?? "/", "http://localhost");
1441
+ if (pathname === "/api/operator/v1/pi/models-config/test" && method === "POST") {
1442
+ const gate = gateMutation(req, deps);
1443
+ if (!gate.ok) {
1444
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1445
+ return true;
1446
+ }
1447
+ try {
1448
+ const body = await readJsonBody(req);
1449
+ if (typeof body.provider !== "string" || typeof body.modelId !== "string")
1450
+ throw new Error("provider and modelId are required");
1451
+ sendJson(res, 200, { ok: true, data: await deps.runtime.testConfiguredModel({ provider: body.provider, modelId: body.modelId }) });
1452
+ }
1453
+ catch (error) {
1454
+ sendJson(res, 400, { ok: false, error: { code: "MODEL_TEST_FAILED", message: error instanceof Error ? error.message : String(error) } });
1455
+ }
1456
+ return true;
1457
+ }
1458
+ if (pathname === "/api/operator/v1/pi/models-config" && (method === "GET" || method === "PATCH")) {
1459
+ await handlePiConfig(req, res, deps, "models");
1460
+ return true;
1461
+ }
1462
+ if (pathname === "/api/operator/v1/pi/packages" && method === "GET") {
1463
+ await handlePiConfig(req, res, deps, "packages");
1464
+ return true;
1465
+ }
1466
+ if (pathname === "/api/operator/v1/chat/instruction-skills" && method === "GET") {
1467
+ await handlePiConfig(req, res, deps, "skills");
1468
+ return true;
1469
+ }
1470
+ if (pathname === "/api/operator/v1/chat/instruction-skills/preferences" && method === "PUT") {
1471
+ await handlePiConfig(req, res, deps, "skills");
1472
+ return true;
1473
+ }
311
1474
  if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
312
1475
  await handleChatCapabilities(req, res, deps);
313
1476
  return true;
314
1477
  }
1478
+ if (method === "GET" && pathname === "/api/operator/v1/chat/repo/tree") {
1479
+ await handleRepoBrowser(res, deps, "tree", url.searchParams.get("path") ?? "");
1480
+ return true;
1481
+ }
1482
+ if (method === "GET" && pathname === "/api/operator/v1/chat/repo/file") {
1483
+ const filePath = url.searchParams.get("path");
1484
+ if (!filePath)
1485
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_PATH", message: "path is required" } });
1486
+ else
1487
+ await handleRepoBrowser(res, deps, "file", filePath);
1488
+ return true;
1489
+ }
1490
+ if (method === "GET" && pathname === "/api/operator/v1/chat/sessions") {
1491
+ await handleListChatSessions(req, res, deps);
1492
+ return true;
1493
+ }
1494
+ const interviewStart = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews$/);
1495
+ if (method === "POST" && interviewStart) {
1496
+ await handleStartInterview(req, res, deps, decodeURIComponent(interviewStart[1]));
1497
+ return true;
1498
+ }
1499
+ const interviewAnswer = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/answers$/);
1500
+ if (method === "POST" && interviewAnswer) {
1501
+ await handleAnswerInterview(req, res, deps, decodeURIComponent(interviewAnswer[1]), decodeURIComponent(interviewAnswer[2]));
1502
+ return true;
1503
+ }
1504
+ const interviewFact = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/(draft|assessment)$/);
1505
+ if (method === "GET" && interviewFact) {
1506
+ await handleGetInterviewFact(res, deps, decodeURIComponent(interviewFact[1]), decodeURIComponent(interviewFact[2]), interviewFact[3]);
1507
+ return true;
1508
+ }
1509
+ const taskKindConfirm = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/interviews\/([^/]+)\/task-kind\/confirm$/);
1510
+ if (method === "POST" && taskKindConfirm) {
1511
+ await handleConfirmTaskKind(req, res, deps, decodeURIComponent(taskKindConfirm[1]), decodeURIComponent(taskKindConfirm[2]));
1512
+ return true;
1513
+ }
1514
+ const contractHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/contract-apply(?:\/(prepare))?$/);
1515
+ if (method === "POST" && contractHumanGate) {
1516
+ await handleContractApplyHumanGate(req, res, deps, decodeURIComponent(contractHumanGate[1]), contractHumanGate[2] ? "prepare" : "apply");
1517
+ return true;
1518
+ }
1519
+ const dagHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/dag-(prepare|confirm|run)$/);
1520
+ if (method === "POST" && dagHumanGate) {
1521
+ await handleDagHumanGate(req, res, deps, decodeURIComponent(dagHumanGate[1]), dagHumanGate[2]);
1522
+ return true;
1523
+ }
1524
+ const mutationHumanGate = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/human-gate\/mutation-apply$/);
1525
+ if (method === "POST" && mutationHumanGate) {
1526
+ await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
1527
+ return true;
1528
+ }
1529
+ const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
1530
+ if (m4Route) {
1531
+ const id = decodeURIComponent(m4Route[1]);
1532
+ const route = m4Route[2];
1533
+ if (route === "draft" && method === "PUT") {
1534
+ await handleComposerDraft(req, res, deps, id);
1535
+ return true;
1536
+ }
1537
+ if (route === "files" && method === "GET") {
1538
+ await handleChatFiles(res, deps, id, url.searchParams.get("q") ?? "");
1539
+ return true;
1540
+ }
1541
+ if (route === "runtime-context" && method === "GET") {
1542
+ await handleRuntimeContext(res, deps, id);
1543
+ return true;
1544
+ }
1545
+ if (route === "models" && method === "GET") {
1546
+ await handleModels(req, res, deps, id);
1547
+ return true;
1548
+ }
1549
+ if (route === "model" && method === "POST") {
1550
+ await handleModels(req, res, deps, id);
1551
+ return true;
1552
+ }
1553
+ }
1554
+ const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
1555
+ if (method === "POST" && sessionMainline) {
1556
+ await handleSwitchMainlineChatSession(req, res, deps, decodeURIComponent(sessionMainline[1]));
1557
+ return true;
1558
+ }
1559
+ const sessionFork = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/fork$/);
1560
+ if (method === "POST" && sessionFork) {
1561
+ await handleForkChatSession(req, res, deps, decodeURIComponent(sessionFork[1]));
1562
+ return true;
1563
+ }
1564
+ const sessionBranch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch$/);
1565
+ if (method === "POST" && sessionBranch) {
1566
+ await handleBranchChatSession(req, res, deps, decodeURIComponent(sessionBranch[1]));
1567
+ return true;
1568
+ }
1569
+ const sessionReopen = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/reopen$/);
1570
+ if (method === "POST" && sessionReopen) {
1571
+ await handleReopenChatSession(req, res, deps, decodeURIComponent(sessionReopen[1]));
1572
+ return true;
1573
+ }
1574
+ const sessionTurn = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns$/);
1575
+ if (method === "POST" && sessionTurn) {
1576
+ await handleCreateChatTurn(req, res, deps, decodeURIComponent(sessionTurn[1]));
1577
+ return true;
1578
+ }
1579
+ const sessionTurnDetail = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)$/);
1580
+ if (method === "GET" && sessionTurnDetail) {
1581
+ await handleGetChatTurn(req, res, deps, decodeURIComponent(sessionTurnDetail[1]), decodeURIComponent(sessionTurnDetail[2]));
1582
+ return true;
1583
+ }
1584
+ const operationLink = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/operation-links$/);
1585
+ if (method === "POST" && operationLink) {
1586
+ await handleLinkWorkspaceOperation(req, res, deps, decodeURIComponent(operationLink[1]));
1587
+ return true;
1588
+ }
1589
+ const sessionCompact = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/compact$/);
1590
+ if (method === "POST" && sessionCompact) {
1591
+ await handleChatCompact(req, res, deps, decodeURIComponent(sessionCompact[1]));
1592
+ return true;
1593
+ }
1594
+ const sessionMessages = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/messages$/);
1595
+ if (method === "GET" && sessionMessages) {
1596
+ await handleChatMessages(res, deps, decodeURIComponent(sessionMessages[1]), url);
1597
+ return true;
1598
+ }
315
1599
  const sessionPrompt = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/prompt$/);
316
1600
  if (method === "POST" && sessionPrompt) {
317
1601
  await handleChatPrompt(req, res, deps, decodeURIComponent(sessionPrompt[1]));
318
1602
  return true;
319
1603
  }
1604
+ const sessionContext = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/context$/);
1605
+ if (method === "GET" && sessionContext) {
1606
+ await handleGetTaskContext(req, res, deps, decodeURIComponent(sessionContext[1]));
1607
+ return true;
1608
+ }
320
1609
  const sessionEvents = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/events$/);
321
1610
  if (method === "GET" && sessionEvents) {
322
- // MVP: events are delivered inline during prompt SSE. A separate GET
323
- // events stream is reserved for degraded-state recovery (V13); for now
324
- // we return the persisted session snapshot so a reconnecting client can
325
- // re-render history.
326
- await handleGetChatSession(req, res, deps, decodeURIComponent(sessionEvents[1]));
1611
+ await handleChatEventsStream(req, res, deps, decodeURIComponent(sessionEvents[1]));
327
1612
  return true;
328
1613
  }
329
1614
  const sessionMatch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)$/);
@@ -333,6 +1618,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
333
1618
  await handleGetChatSession(req, res, deps, id);
334
1619
  return true;
335
1620
  }
1621
+ if (method === "PATCH") {
1622
+ await handlePatchChatSession(req, res, deps, id);
1623
+ return true;
1624
+ }
336
1625
  if (method === "DELETE") {
337
1626
  await handleDeleteChatSession(req, res, deps, id);
338
1627
  return true;