@tea-agent/loop-agent 0.23.1 → 0.24.1

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