@tea-agent/loop-agent 0.33.7-beta.0 → 0.34.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 (101) hide show
  1. package/CHANGELOG.md +67 -16
  2. package/dist/application/task-lifecycle/advance.js +309 -9
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/application/task-lifecycle/plan-transitions.js +13 -21
  6. package/dist/commands/init-upgrade.js +32 -1
  7. package/dist/commands/init.js +94 -3
  8. package/dist/executors/dag-pi-executor.js +18 -3
  9. package/dist/executors/shell-executor.js +4 -91
  10. package/dist/executors/shell-write-guard.js +26 -8
  11. package/dist/shared/operator/capabilities.js +98 -44
  12. package/dist/shared/resilient-git.js +133 -0
  13. package/dist/task/source-prepare/artifact-meta.js +137 -0
  14. package/dist/task/source-prepare/index.js +2 -0
  15. package/dist/task/source-prepare/parse-intent.js +58 -10
  16. package/dist/task/source-prepare/prepare.js +229 -18
  17. package/dist/task/source-prepare/reference-integrity.js +18 -2
  18. package/dist/task/source-prepare/semantic-intake.js +404 -0
  19. package/dist/worker/console/app-data.js +2 -0
  20. package/dist/worker/console/chat/chat-event-store.js +190 -25
  21. package/dist/worker/console/chat/model-resolver.js +17 -0
  22. package/dist/worker/console/chat/pi-console-config.js +250 -32
  23. package/dist/worker/console/chat/pi-runtime.js +1007 -188
  24. package/dist/worker/console/chat/resource-loader.js +5 -4
  25. package/dist/worker/console/chat/routes.js +495 -157
  26. package/dist/worker/console/chat/runtime-context.js +48 -12
  27. package/dist/worker/console/chat/runtime-selection.js +59 -0
  28. package/dist/worker/console/chat/session-store.js +39 -0
  29. package/dist/worker/console/chat/shortcuts.js +1 -0
  30. package/dist/worker/console/chat/tool-adapter.js +9 -3
  31. package/dist/worker/console/chat/tools.js +5 -1
  32. package/dist/worker/console/dag-execution-receipt.js +380 -0
  33. package/dist/worker/console/interview/grill-me.js +7 -6
  34. package/dist/worker/console/operator-actions.js +785 -85
  35. package/dist/worker/console/prd-intake-bridge.js +393 -0
  36. package/dist/worker/console/recovery-cta.js +35 -6
  37. package/dist/worker/console/server.js +8 -15
  38. package/dist/worker/console/static/assets/index-BQkhJpV8.css +1 -0
  39. package/dist/worker/console/static/assets/index-BpuHmlSP.js +29 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/worker/console/static-src/app/console-types.js +1 -0
  42. package/dist/worker/console/static-src/app/usePrdImport.js +2 -1
  43. package/dist/worker/console/static-src/app/useRecoveryActions.js +24 -1
  44. package/dist/worker/console/static-src/app/useRecoveryConsole.js +19 -1
  45. package/dist/worker/console/static-src/app/useTaskWizard.js +57 -2
  46. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  47. package/dist/worker/console/static-src/operator-chat/landing-density.js +23 -0
  48. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  49. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  50. package/dist/worker/console/static-src/operator-chat/session-title-watcher.js +128 -0
  51. package/dist/worker/console/static-src/operator-chat/sidebar-split.js +90 -0
  52. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +37 -0
  53. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +125 -22
  54. package/dist/worker/console/static-src/operator-chat/useChatStream.js +215 -184
  55. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  56. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  57. package/dist/worker/console/static-src/operator-chat/useOverlayFocus.js +84 -0
  58. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  59. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  60. package/dist/worker/console/static-src/operator-chat/useWorkspaceLayout.js +58 -0
  61. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +31 -0
  62. package/dist/worker/delivery/final-verification.js +13 -5
  63. package/dist/worker/delivery/package.js +31 -19
  64. package/dist/worker/delivery/verification-bundle.js +6 -4
  65. package/dist/worker/observability/read-model.js +3 -0
  66. package/dist/worker/observe/static/operator-chrome.css +5 -2
  67. package/dist/worker/observe/static/operator-chrome.js +6 -1
  68. package/dist/worker/observe/static/styles.css +39 -9
  69. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +33 -462
  70. package/dist/workflows/dag/backend-test-case-manifest.js +0 -4
  71. package/dist/workflows/dag/backend-test-markdown-workflow.js +1 -25
  72. package/dist/workflows/dag/backend-test-module-stem.js +0 -5
  73. package/dist/workflows/dag/backend-test-pytest-collection.js +24 -345
  74. package/dist/workflows/dag/backend-test-scenario-param.js +82 -269
  75. package/dist/workflows/dag/backend-test-writer-completeness.js +16 -47
  76. package/dist/workflows/dag/dynamic-runtime/map.js +8 -24
  77. package/dist/workflows/dag/failure-category.js +3 -0
  78. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  79. package/dist/workflows/dag/init-hybrid.js +49 -55
  80. package/dist/workflows/dag/node-execution.js +3 -2
  81. package/dist/workflows/dag/retry-policy.js +44 -11
  82. package/dist/workflows/dag/runner.js +6 -0
  83. package/dist/workflows/dag/scheduler.js +49 -1
  84. package/dist/workflows/dag/types.js +0 -7
  85. package/dist/workflows/dag/validate.js +16 -2
  86. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  87. package/docs/templates/agent-dag.schema.json +4 -4
  88. package/docs/templates/backend-test-dag.json +29 -32
  89. package/harness.json +1 -1
  90. package/package.json +1 -1
  91. package/skills/local-jacoco-coverage/SKILL.md +281 -0
  92. package/skills/local-jacoco-coverage/references/requirement-to-source-mapping.md +85 -0
  93. package/skills/local-jacoco-coverage/references/runtime-alignment.md +106 -0
  94. package/skills/local-jacoco-coverage/scripts/run-coverage-analysis.sh +148 -0
  95. package/skills/local-jacoco-coverage/scripts/start-jacoco-agent.sh +110 -0
  96. package/skills/loop-agent/references/command-reference.md +3 -1
  97. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  98. package/skills/loop-agent/references/task-workflow.md +4 -0
  99. package/dist/worker/console/chat/instruction-skills.js +0 -217
  100. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  101. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -6,24 +6,23 @@
6
6
  * GET /sessions/:id — fetch session metadata + messages
7
7
  * POST /sessions/:id/prompt — SSE stream: send a prompt, stream events
8
8
  * GET /sessions/:id/events — SSE stream: (re)connect to live events
9
- * GET /capabilities — Chat tool surface + instruction skills
9
+ * GET /capabilities — Chat tool surface
10
10
  * DELETE /sessions/:id — dispose a Chat session
11
11
  *
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";
16
15
  import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.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
16
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
17
+ import { cleanChatTitle } from "./session-store.js";
19
18
  import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
20
- import { THINKING_LEVELS, composeOperatorChatSystemPrompt, } from "./pi-runtime.js";
19
+ import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
21
20
  import { ComposerDraftStore } from "./composer-draft-store.js";
22
21
  import { walkRepoFiles } from "./repo-walk.js";
23
22
  import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
24
23
  import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
25
24
  import { projectRuntimeContext } from "./runtime-context.js";
26
- import { patchModelsConfig, readModelsConfig, readPackageInventory, readSkillPreferences, writeSkillPreferences, } from "./pi-console-config.js";
25
+ import { patchModelsConfig, readModelsConfig, readPackageInventory, } from "./pi-console-config.js";
27
26
  import { ChatInterviewAdapter, projectChatInterviewState, } from "./interview-adapter.js";
28
27
  import { classifyChatCompactionFailure } from "./compaction-errors.js";
29
28
  import { evaluateMutationGate, isMutationMethod } from "../security.js";
@@ -33,9 +32,24 @@ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
33
32
  import { projectTaskContext } from "./context-panel.js";
34
33
  import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
35
34
  import { sendJson } from "../routes.js";
36
- import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
35
+ import { openSseResponse, writeSseEvent } from "../operation-sse.js";
36
+ /** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
37
+ * the same wire shape; its prefix intentionally fails the current process
38
+ * epoch comparison and triggers safe snapshot recovery (M5.1). */
39
+ function parseChatEventCursor(header) {
40
+ if (!header?.trim())
41
+ return { epoch: null, seq: 0 };
42
+ const raw = header.trim();
43
+ const colon = raw.lastIndexOf(":");
44
+ const seqPart = colon >= 0 ? raw.slice(colon + 1) : raw;
45
+ const seq = Number.parseInt(seqPart, 10);
46
+ return {
47
+ epoch: colon > 0 ? raw.slice(0, colon) : null,
48
+ seq: Number.isFinite(seq) && seq >= 0 ? seq : 0,
49
+ };
50
+ }
37
51
  function writeChatSse(res, event) {
38
- // Persisted Chat events carry their own eventId (<sessionId>:<seq>); write
52
+ // Persisted Chat events carry their own eventId (`<epoch>:<seq>`); write
39
53
  // it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
40
54
  if ("seq" in event && event.eventId) {
41
55
  res.write(`id: ${event.eventId}\n`);
@@ -93,21 +107,65 @@ function turnEventToStorePartial(event) {
93
107
  return undefined;
94
108
  }
95
109
  }
96
- /** Guarantee a durable terminal event for a turn that ended without the SDK
97
- * emitting agent_end (client abort, pre-prompt persistence failure, runtime
98
- * error). Without it, a reconnect replay stops at agent_start and the UI stays
99
- * streaming forever. Idempotent: normal settled turns already carry the SDK's
100
- * agent_end in the ring, so they are skipped. */
110
+ function emitSessionTitle(deps, sessionId, turnId, title, automatic = false) {
111
+ return deps.events.append(sessionId, turnId, {
112
+ kind: "session_title",
113
+ data: { title, ...(automatic ? { automatic: true } : {}) },
114
+ });
115
+ }
116
+ function shouldEmitPlaceholder(record) {
117
+ return !record.title &&
118
+ record.messages.filter((message) => message.role === "user").length === 1
119
+ ? record.displayTitle
120
+ : undefined;
121
+ }
122
+ function scheduleAutomaticTitle(deps, input) {
123
+ void (async () => {
124
+ try {
125
+ const generated = await deps.runtime.generateTitle(input.sessionId, input.firstUserText);
126
+ const title = generated ? cleanChatTitle(generated) : undefined;
127
+ if (!title)
128
+ return;
129
+ const stored = await deps.store.setAutomaticTitle(input.sessionId, title);
130
+ if (!stored)
131
+ return;
132
+ emitSessionTitle(deps, input.sessionId, input.turnId, stored.title, true);
133
+ // Keep the best-effort Pi mirror inside this detached task's catch
134
+ // boundary: both synchronous throws and rejected Promises are absorbed
135
+ // only after durable title/event persistence has completed.
136
+ await deps.runtime.setSessionName(input.sessionId, stored.title);
137
+ }
138
+ catch {
139
+ // Background title failures must never affect the settled Chat turn.
140
+ }
141
+ })();
142
+ }
143
+ /** Guarantee durable terminal events for a turn that ended without the SDK
144
+ * emitting them (client abort, pre-prompt persistence failure, runtime
145
+ * error). Without `agent_end`, reconnect replay stops mid-turn; without
146
+ * `agent_settled`, the single-stream UI keeps idle-grace streaming forever
147
+ * (M4.4). Idempotent for normal settled turns that already have both. */
101
148
  function ensureAgentEndEvent(deps, sessionId, turnId) {
102
- const already = deps.events
103
- .snapshot(sessionId)
104
- .some((event) => event.turnId === turnId && event.kind === "agent_end");
105
- if (!already) {
149
+ const snap = deps.events.snapshot(sessionId);
150
+ const hasAgentEnd = snap.some((event) => event.turnId === turnId && event.kind === "agent_end");
151
+ if (!hasAgentEnd) {
106
152
  deps.events.append(sessionId, turnId, {
107
153
  kind: "agent_end",
108
154
  data: { willRetry: false },
109
155
  });
110
156
  }
157
+ // Error/abort paths historically only forced agent_end. After the M4 single
158
+ // stream keeps UI streaming through agent_end for idle grace, a matching
159
+ // agent_settled is required so state reconcile and the applier both finish.
160
+ const hasSettled = deps.events
161
+ .snapshot(sessionId)
162
+ .some((event) => event.turnId === turnId && event.kind === "agent_settled");
163
+ if (!hasSettled) {
164
+ deps.events.append(sessionId, turnId, {
165
+ kind: "agent_settled",
166
+ data: {},
167
+ });
168
+ }
111
169
  }
112
170
  async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
113
171
  const chunks = [];
@@ -198,9 +256,53 @@ function gateMutation(req, deps) {
198
256
  }
199
257
  return { ok: true };
200
258
  }
259
+ /**
260
+ * RF-02: map a RuntimeSnapshotUnavailableError (critical snapshot projection
261
+ * failure — model enumeration / system prompt) to 503 PI_RUNTIME_UNAVAILABLE.
262
+ * Returns true when the response was sent; other errors keep their existing
263
+ * 409/500 branches so the mutation/reload semantics never regress.
264
+ */
265
+ function sendRuntimeUnavailable(res, error) {
266
+ if (!(error instanceof RuntimeSnapshotUnavailableError))
267
+ return false;
268
+ sendJson(res, 503, {
269
+ ok: false,
270
+ error: { code: error.code, message: error.message },
271
+ });
272
+ return true;
273
+ }
274
+ /**
275
+ * Map a failed `reopenSession()` result to HTTP status + response error code.
276
+ * Must stay aligned with `ensureActiveSession` reopen-failure branches so
277
+ * lazy-reopen routes (prompt/turns/reopen/compact/branch) and readiness
278
+ * endpoints share one semantic table.
279
+ *
280
+ * Business conflicts (TURN_ACTIVE, PI_SESSION_BUSY, model switch, …) are NOT
281
+ * handled here — only reopen/init readiness failures.
282
+ */
283
+ function mapReopenSessionFailure(reopen) {
284
+ if (reopen.code === "NO_SESSION_FILE") {
285
+ return {
286
+ status: 404,
287
+ code: "PI_SESSION_NOT_FOUND",
288
+ message: reopen.message,
289
+ };
290
+ }
291
+ if (reopen.code === "PI_SESSION_INIT_FAILED") {
292
+ return {
293
+ status: 503,
294
+ code: "PI_SESSION_INIT_FAILED",
295
+ message: reopen.message,
296
+ };
297
+ }
298
+ return {
299
+ status: 503,
300
+ code: "PI_RUNTIME_UNAVAILABLE",
301
+ message: reopen.message,
302
+ };
303
+ }
201
304
  export async function handleChatCapabilities(_req, res, deps) {
202
305
  const toolSchemas = buildModelCallableToolSchemas();
203
- const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
204
306
  sendJson(res, 200, {
205
307
  ok: true,
206
308
  schemaVersion: 1,
@@ -208,18 +310,6 @@ export async function handleChatCapabilities(_req, res, deps) {
208
310
  deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
209
311
  deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
210
312
  tools: toolSchemas,
211
- instructionSkills: {
212
- allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS,
213
- loaded: skills.loaded.map((s) => ({
214
- name: s.name,
215
- description: s.description,
216
- charCount: s.charCount,
217
- })),
218
- failures: skills.failures,
219
- skipped: skills.skipped,
220
- promptFragmentCharCount: composeInstructionSkillsPrompt(skills.loaded)
221
- .length,
222
- },
223
313
  // ADR 0011: full Pi tools including bash are default-active.
224
314
  hasBash: true,
225
315
  });
@@ -276,10 +366,8 @@ export async function handleCreateChatSession(req, res, deps) {
276
366
  return;
277
367
  }
278
368
  try {
279
- deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
280
- const snapshot = snapshotInput === undefined
281
- ? undefined
282
- : projectCompactSnapshot(snapshotInput);
369
+ const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
370
+ // Fast path: durable shell + Console record before full AgentSession ready.
283
371
  const handle = await deps.runtime.createSession({
284
372
  ...(provider && modelId ? { model: { provider, modelId } } : {}),
285
373
  ...(snapshot
@@ -288,7 +376,18 @@ export async function handleCreateChatSession(req, res, deps) {
288
376
  }
289
377
  : {}),
290
378
  });
291
- await deps.store.create({
379
+ if (!handle.sessionFile) {
380
+ deps.runtime.dispose(handle.sessionId);
381
+ sendJson(res, 500, {
382
+ ok: false,
383
+ error: {
384
+ code: "CHAT_SESSION_CREATE_FAILED",
385
+ message: "chat session shell missing sessionFile",
386
+ },
387
+ });
388
+ return;
389
+ }
390
+ const session = await deps.store.create({
292
391
  sessionId: handle.sessionId,
293
392
  sessionFile: handle.sessionFile,
294
393
  modelProvider: handle.model?.provider,
@@ -299,13 +398,33 @@ export async function handleCreateChatSession(req, res, deps) {
299
398
  kind: "compact",
300
399
  data: snapshot,
301
400
  });
401
+ // Best-effort: after materialization, patch resolved default model onto the
402
+ // durable record. Failures stay on the init fence (PI_SESSION_INIT_FAILED).
403
+ if (typeof deps.runtime.ensureSessionReady === "function") {
404
+ void deps.runtime
405
+ .ensureSessionReady(handle.sessionId)
406
+ .then(async () => {
407
+ if (typeof deps.runtime.getRuntimeSelection !== "function")
408
+ return;
409
+ const selection = deps.runtime.getRuntimeSelection(handle.sessionId);
410
+ if (!selection?.model)
411
+ return;
412
+ await deps.store.setRuntimeSelection(handle.sessionId, {
413
+ modelProvider: selection.model.provider,
414
+ modelId: selection.model.modelId,
415
+ });
416
+ })
417
+ .catch(() => undefined);
418
+ }
302
419
  sendJson(res, 201, {
303
420
  ok: true,
304
421
  sessionId: handle.sessionId,
305
422
  sessionFile: handle.sessionFile,
306
423
  model: handle.model,
424
+ session,
307
425
  activeTools: handle.activeTools,
308
426
  hasBash: true,
427
+ ...(handle.initializing ? { initializing: true } : {}),
309
428
  });
310
429
  }
311
430
  catch (error) {
@@ -415,8 +534,17 @@ export async function handleBranchChatSession(req, res, deps, sessionId) {
415
534
  sessionId,
416
535
  sessionFile: record.sessionFile,
417
536
  });
418
- if (!reopened.ok)
419
- throw new Error(reopened.message);
537
+ if (!reopened.ok) {
538
+ const mapped = mapReopenSessionFailure({
539
+ code: reopened.code,
540
+ message: reopened.message,
541
+ });
542
+ sendJson(res, mapped.status, {
543
+ ok: false,
544
+ error: { code: mapped.code, message: mapped.message },
545
+ });
546
+ return;
547
+ }
420
548
  }
421
549
  const body = await readJsonBody(req);
422
550
  if ("clientRequestId" in body || "requestIndex" in body)
@@ -491,6 +619,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
491
619
  const lastEvent = snapshot.at(-1);
492
620
  sendJson(res, 200, {
493
621
  ok: true,
622
+ eventEpoch: deps.events.epoch(sessionId),
494
623
  session: {
495
624
  ...record,
496
625
  composerDraft,
@@ -618,9 +747,13 @@ export async function handleReopenChatSession(req, res, deps, sessionId) {
618
747
  : {}),
619
748
  });
620
749
  if (!result.ok) {
621
- sendJson(res, 409, {
750
+ const mapped = mapReopenSessionFailure({
751
+ code: result.code,
752
+ message: result.message,
753
+ });
754
+ sendJson(res, mapped.status, {
622
755
  ok: false,
623
- error: { code: result.code, message: result.message },
756
+ error: { code: mapped.code, message: mapped.message },
624
757
  });
625
758
  return;
626
759
  }
@@ -703,9 +836,13 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
703
836
  : {}),
704
837
  });
705
838
  if (!reopen.ok) {
706
- sendJson(res, 409, {
839
+ const mapped = mapReopenSessionFailure({
840
+ code: reopen.code,
841
+ message: reopen.message,
842
+ });
843
+ sendJson(res, mapped.status, {
707
844
  ok: false,
708
- error: { code: reopen.code, message: reopen.message },
845
+ error: { code: mapped.code, message: mapped.message },
709
846
  });
710
847
  return;
711
848
  }
@@ -754,6 +891,7 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
754
891
  });
755
892
  // Lazily import to avoid circular module load.
756
893
  const { runChatTurn } = await import("./session-store.js");
894
+ let firstUserText;
757
895
  try {
758
896
  const outcome = await runChatTurn({
759
897
  runtime: deps.runtime,
@@ -763,6 +901,18 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
763
901
  text,
764
902
  images,
765
903
  signal: ac.signal,
904
+ onUserMessagePersisted: (record) => {
905
+ const users = record.messages.filter((message) => message.role === "user");
906
+ if (users.length !== 1)
907
+ return;
908
+ firstUserText = users[0]?.text;
909
+ const placeholder = shouldEmitPlaceholder(record);
910
+ if (!placeholder)
911
+ return;
912
+ const persisted = emitSessionTitle(deps, sessionId, turnId, placeholder);
913
+ if (!res.writableEnded)
914
+ writeChatSse(res, persisted);
915
+ },
766
916
  onOperationAccepted: (accepted) => deps.operationLinker.link({
767
917
  sessionId,
768
918
  turnId,
@@ -813,6 +963,8 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
813
963
  else {
814
964
  ensureAgentEndEvent(deps, sessionId, turnId);
815
965
  deps.events.setTurnState(sessionId, turnId, outcome.ok ? "settled" : "failed", outcome.error);
966
+ if (outcome.ok && firstUserText)
967
+ scheduleAutomaticTitle(deps, { sessionId, turnId, firstUserText });
816
968
  }
817
969
  }
818
970
  catch (error) {
@@ -917,9 +1069,13 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
917
1069
  : {}),
918
1070
  });
919
1071
  if (!reopen.ok) {
920
- sendJson(res, 409, {
1072
+ const mapped = mapReopenSessionFailure({
1073
+ code: reopen.code,
1074
+ message: reopen.message,
1075
+ });
1076
+ sendJson(res, mapped.status, {
921
1077
  ok: false,
922
- error: { code: reopen.code, message: reopen.message },
1078
+ error: { code: mapped.code, message: mapped.message },
923
1079
  });
924
1080
  return;
925
1081
  }
@@ -958,6 +1114,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
958
1114
  // response cannot abort the prompt; clients follow GET /events instead.
959
1115
  void (async () => {
960
1116
  const { runChatTurn } = await import("./session-store.js");
1117
+ let firstUserText;
961
1118
  try {
962
1119
  const outcome = await runChatTurn({
963
1120
  runtime: deps.runtime,
@@ -966,6 +1123,15 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
966
1123
  sessionId,
967
1124
  text,
968
1125
  images,
1126
+ onUserMessagePersisted: (record) => {
1127
+ const users = record.messages.filter((message) => message.role === "user");
1128
+ if (users.length !== 1)
1129
+ return;
1130
+ firstUserText = users[0]?.text;
1131
+ const placeholder = shouldEmitPlaceholder(record);
1132
+ if (placeholder)
1133
+ emitSessionTitle(deps, sessionId, turn.turnId, placeholder);
1134
+ },
969
1135
  onOperationAccepted: (accepted) => deps.operationLinker.link({
970
1136
  sessionId,
971
1137
  turnId: turn.turnId,
@@ -982,6 +1148,8 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
982
1148
  });
983
1149
  ensureAgentEndEvent(deps, sessionId, turn.turnId);
984
1150
  deps.events.setTurnState(sessionId, turn.turnId, outcome.ok ? "settled" : "failed", outcome.error);
1151
+ if (outcome.ok && firstUserText)
1152
+ scheduleAutomaticTitle(deps, { sessionId, turnId: turn.turnId, firstUserText });
985
1153
  }
986
1154
  catch (error) {
987
1155
  const message = error instanceof Error ? error.message : String(error);
@@ -1025,23 +1193,31 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1025
1193
  }
1026
1194
  await deps.operationLinker.recoverSession(sessionId);
1027
1195
  openSseResponse(res);
1028
- // Resume from the client's last seen eventId (Last-Event-ID header, T04).
1196
+ // Resume from the client's last seen eventId (`<epoch>:<seq>`). A previous
1197
+ // Console boot/legacy cursor has a different prefix and receives snapshot.
1029
1198
  const lastEventIdHeader = req.headers["last-event-id"];
1030
- const afterSeq = parseLastEventId(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
1031
- const replay = deps.events.listFrom(sessionId, afterSeq);
1032
- let lastSeq = afterSeq;
1199
+ const cursor = parseChatEventCursor(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
1200
+ const replay = deps.events.listFrom(sessionId, cursor);
1201
+ let lastSeq = cursor.seq;
1033
1202
  if ("error" in replay) {
1034
1203
  // Cursor aged out of the bounded ring (T07 fence): tell the client to
1035
1204
  // reconcile from a fresh snapshot rather than render a gap as if current.
1205
+ // The frame carries the ring tail (latestEventId) so the client can resume
1206
+ // the replayed snapshot with a fresh Last-Event-ID (recovery cursor).
1207
+ const retained = deps.events.snapshot(sessionId);
1036
1208
  writeChatSse(res, {
1037
1209
  kind: "reconcile",
1038
1210
  sessionId,
1039
- reason: "cursor_expired",
1211
+ reason: replay.error === "CURSOR_EPOCH_MISMATCH"
1212
+ ? "epoch_mismatch"
1213
+ : "cursor_expired",
1040
1214
  minSeq: replay.minSeq,
1215
+ epoch: replay.epoch,
1041
1216
  latestTurnId: deps.events.latestTurnId(sessionId),
1217
+ latestEventId: retained.at(-1)?.eventId ?? null,
1042
1218
  });
1043
1219
  // Fall back to full retained snapshot so the client can re-render.
1044
- for (const ev of deps.events.snapshot(sessionId)) {
1220
+ for (const ev of retained) {
1045
1221
  writeChatSse(res, ev);
1046
1222
  lastSeq = Math.max(lastSeq, ev.seq);
1047
1223
  }
@@ -1052,6 +1228,15 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1052
1228
  lastSeq = Math.max(lastSeq, ev.seq);
1053
1229
  }
1054
1230
  }
1231
+ // Delimit retained replay from follow mode. The client uses this marker to
1232
+ // ensure its sole GET /events consumer is ready before POST /turns (M4.1).
1233
+ const retainedTail = deps.events.snapshot(sessionId).at(-1);
1234
+ writeChatSse(res, {
1235
+ kind: "stream_ready",
1236
+ sessionId,
1237
+ epoch: deps.events.epoch(sessionId),
1238
+ latestEventId: retainedTail?.eventId ?? null,
1239
+ });
1055
1240
  let closed = false;
1056
1241
  res.once("close", () => {
1057
1242
  closed = true;
@@ -1211,47 +1396,6 @@ async function handlePiConfig(req, res, deps, resource) {
1211
1396
  });
1212
1397
  return;
1213
1398
  }
1214
- if (req.method === "GET") {
1215
- const disabledNames = await readSkillPreferences(deps.appData);
1216
- const loaded = await loadOperatorChatInstructionSkills(deps.skillsDir);
1217
- sendJson(res, 200, {
1218
- ok: true,
1219
- data: {
1220
- allowed: OPERATOR_CHAT_ALLOWED_INSTRUCTION_SKILLS.map((name) => {
1221
- const skill = loaded.loaded.find((item) => item.name === name);
1222
- return {
1223
- name,
1224
- description: skill?.description ?? "",
1225
- contentHash: skill
1226
- ? createHash("sha256").update(skill.bodyText).digest("hex")
1227
- : undefined,
1228
- };
1229
- }),
1230
- conditional: OPERATOR_CHAT_WORKFLOW_CONDITIONAL_SKILLS,
1231
- denied: OPERATOR_CHAT_DENIED_INSTRUCTION_SKILLS,
1232
- disabledNames,
1233
- },
1234
- });
1235
- }
1236
- else {
1237
- const gate = gateMutation(req, deps);
1238
- if (!gate.ok) {
1239
- sendJson(res, gate.status, {
1240
- ok: false,
1241
- error: { code: gate.code, message: gate.message },
1242
- });
1243
- return;
1244
- }
1245
- const body = await readJsonBody(req);
1246
- const disabledNames = await writeSkillPreferences(deps.appData, Array.isArray(body.disabledNames)
1247
- ? body.disabledNames.filter((name) => typeof name === "string")
1248
- : []);
1249
- deps.runtime.setDisabledInstructionSkills(disabledNames);
1250
- sendJson(res, 200, {
1251
- ok: true,
1252
- data: { disabledNames, appliesTo: "new-sessions" },
1253
- });
1254
- }
1255
1399
  }
1256
1400
  catch (error) {
1257
1401
  const failure = error;
@@ -1279,94 +1423,148 @@ async function handleChatFiles(res, deps, sessionId, query) {
1279
1423
  .map((path) => ({ path }));
1280
1424
  sendJson(res, 200, { ok: true, files });
1281
1425
  }
1282
- async function handleRuntimeContext(res, deps, sessionId) {
1283
- const record = await deps.store.get(sessionId);
1284
- if (!record) {
1285
- sendJson(res, 404, { ok: false });
1426
+ export async function handleRuntimeContext(res, deps, sessionId) {
1427
+ const ensured = await ensureActiveSession(deps, sessionId);
1428
+ if (!ensured.ok) {
1429
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1430
+ return;
1431
+ }
1432
+ // AC-03: system prompt comes from the REAL Pi session (agent.state.systemPrompt
1433
+ // fallback chain); resources come from that session's ResourceLoader. The raw
1434
+ // prompt stays server-side; the browser only ever receives the scrubbed +
1435
+ // length-limited projection. RF-02: a critical projection failure is 503
1436
+ // PI_RUNTIME_UNAVAILABLE — never a 500 leak or a fake-empty context.
1437
+ let snapshot;
1438
+ try {
1439
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId, {
1440
+ includeRaw: true,
1441
+ });
1442
+ }
1443
+ catch (error) {
1444
+ if (sendRuntimeUnavailable(res, error))
1445
+ return;
1446
+ throw error;
1447
+ }
1448
+ if (!snapshot) {
1449
+ sendJson(res, 503, {
1450
+ ok: false,
1451
+ error: {
1452
+ code: "PI_RUNTIME_UNAVAILABLE",
1453
+ message: `chat session not active: ${sessionId}`,
1454
+ },
1455
+ });
1286
1456
  return;
1287
1457
  }
1288
- const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
1289
- const selection = deps.runtime.getRuntimeSelection(sessionId);
1290
- const skillFragment = composeInstructionSkillsPrompt(skills.loaded);
1291
- const systemPrompt = composeOperatorChatSystemPrompt({ skillFragment });
1292
1458
  sendJson(res, 200, {
1293
1459
  ok: true,
1294
1460
  context: projectRuntimeContext({
1295
- systemPrompt,
1296
- skills: skills.loaded.map((skill) => ({
1297
- name: skill.name,
1298
- description: skill.description,
1299
- charCount: skill.charCount,
1300
- bodyText: skill.bodyText,
1461
+ systemPrompt: snapshot.systemPrompt.raw ?? "",
1462
+ model: snapshot.selection.model
1463
+ ? {
1464
+ provider: snapshot.selection.model.provider,
1465
+ modelId: snapshot.selection.model.id,
1466
+ }
1467
+ : undefined,
1468
+ thinkingLevel: snapshot.selection.thinkingSelection === "auto"
1469
+ ? snapshot.selection.effectiveThinkingLevel
1470
+ : snapshot.selection.thinkingSelection,
1471
+ activeTools: snapshot.activeTools,
1472
+ contextFiles: snapshot.contextFiles,
1473
+ skills: snapshot.skills,
1474
+ extensions: snapshot.extensions,
1475
+ packages: snapshot.packages.map((pkg) => ({
1476
+ name: pkg.spec,
1477
+ source: pkg.spec,
1478
+ scope: pkg.scope,
1479
+ enabled: pkg.enabled,
1480
+ ...(pkg.version ? { version: pkg.version } : {}),
1481
+ ...(pkg.path ? { path: pkg.path } : {}),
1482
+ ...(pkg.cwd ? { cwd: pkg.cwd } : {}),
1483
+ resolved: pkg.resolved,
1484
+ diagnostics: pkg.diagnostics,
1301
1485
  })),
1302
- model: selection.model ??
1303
- (record.modelProvider && record.modelId
1304
- ? { provider: record.modelProvider, modelId: record.modelId }
1305
- : undefined),
1306
- thinkingLevel: selection.thinkingLevel ?? record.thinkingLevel,
1307
- activeTools: selection.activeTools,
1486
+ diagnostics: snapshot.diagnostics,
1308
1487
  }),
1309
1488
  });
1310
1489
  }
1311
- async function handleModels(req, res, deps, sessionId) {
1312
- if (!(await requireChatSession(res, deps, sessionId)))
1490
+ // Exported for the direct handler-boundary tests (trace binding for
1491
+ // vt-route-model-errors); router dispatch behavior is unchanged.
1492
+ export async function handleModels(req, res, deps, sessionId) {
1493
+ // AC-01/AC-06: models (and model/thinking mutation) requests reuse the same
1494
+ // activation path. A missing/reopen-failed runtime is a STRUCTURED error —
1495
+ // never a fabricated empty model list.
1496
+ const ensured = await ensureActiveSession(deps, sessionId);
1497
+ if (!ensured.ok) {
1498
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1313
1499
  return;
1500
+ }
1314
1501
  try {
1315
1502
  if ((req.method ?? "GET").toUpperCase() === "GET") {
1503
+ let models;
1504
+ try {
1505
+ models = await deps.runtime.listModels(sessionId);
1506
+ }
1507
+ catch (error) {
1508
+ // AC-06: a model-list failure is a STRUCTURED runtime error — never
1509
+ // folded into a fake empty list.
1510
+ sendJson(res, 503, {
1511
+ ok: false,
1512
+ error: {
1513
+ code: "PI_RUNTIME_UNAVAILABLE",
1514
+ message: error instanceof Error ? error.message : String(error),
1515
+ },
1516
+ });
1517
+ return;
1518
+ }
1316
1519
  sendJson(res, 200, {
1317
1520
  ok: true,
1318
- models: await deps.runtime.listModels(sessionId),
1521
+ models: models.map((model) => ({
1522
+ provider: model.provider,
1523
+ id: model.id ?? model.modelId ?? "",
1524
+ ...(model.name ? { name: model.name } : {}),
1525
+ hasCredentials: model.hasCredentials,
1526
+ })),
1319
1527
  thinkingLevels: THINKING_LEVELS,
1320
1528
  });
1321
1529
  return;
1322
1530
  }
1323
1531
  const gate = gateMutation(req, deps);
1324
1532
  if (!gate.ok) {
1325
- sendJson(res, gate.status, {
1326
- ok: false,
1327
- error: { code: gate.code, message: gate.message },
1328
- });
1533
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1329
1534
  return;
1330
1535
  }
1331
1536
  const body = await readJsonBody(req);
1332
1537
  let updated;
1333
1538
  if (typeof body.provider === "string" && typeof body.modelId === "string") {
1334
- const model = await deps.runtime.applyModel(sessionId, {
1335
- provider: body.provider,
1336
- modelId: body.modelId,
1337
- });
1338
- updated = await deps.store.setRuntimeSelection(sessionId, {
1339
- modelProvider: model.provider,
1340
- modelId: model.modelId,
1341
- });
1539
+ const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
1540
+ updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
1342
1541
  }
1343
- else if (typeof body.thinkingLevel === "string" &&
1344
- THINKING_LEVELS.includes(body.thinkingLevel)) {
1542
+ else if (typeof body.thinkingLevel === "string" && (body.thinkingLevel === "auto" || THINKING_LEVELS.includes(body.thinkingLevel))) {
1345
1543
  const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
1346
- updated = await deps.store.setRuntimeSelection(sessionId, {
1347
- thinkingLevel,
1348
- });
1544
+ updated = await deps.store.setRuntimeSelection(sessionId, thinkingLevel === "auto" ? {} : { thinkingLevel });
1349
1545
  }
1350
1546
  else {
1351
- sendJson(res, 400, {
1352
- ok: false,
1353
- error: {
1354
- code: "INVALID_INPUT",
1355
- message: "provider/modelId or thinkingLevel is required",
1356
- },
1357
- });
1547
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
1358
1548
  return;
1359
1549
  }
1360
- sendJson(res, 200, { ok: true, session: updated });
1550
+ // Design §5: mutations return the fresh snapshot so the browser can
1551
+ // atomically replace its state (revision increments server-side).
1552
+ // RF-02: the mutation may already be applied server-side, but a failed
1553
+ // snapshot read must surface as 503 PI_RUNTIME_UNAVAILABLE — NOT be
1554
+ // folded into a 409 MODEL_SWITCH_FAILED (the frontend refresh path
1555
+ // re-reads the snapshot to converge on the applied selection).
1556
+ const snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1557
+ sendJson(res, 200, { ok: true, session: updated, snapshot });
1361
1558
  }
1362
1559
  catch (error) {
1363
- sendJson(res, 409, {
1364
- ok: false,
1365
- error: {
1366
- code: "MODEL_SWITCH_FAILED",
1367
- message: error instanceof Error ? error.message : String(error),
1368
- },
1369
- });
1560
+ if (error instanceof RuntimeSnapshotUnavailableError) {
1561
+ sendJson(res, 503, {
1562
+ ok: false,
1563
+ error: { code: error.code, message: error.message },
1564
+ });
1565
+ return;
1566
+ }
1567
+ sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
1370
1568
  }
1371
1569
  }
1372
1570
  async function requireChatSession(res, deps, sessionId) {
@@ -1381,6 +1579,144 @@ async function requireChatSession(res, deps, sessionId) {
1381
1579
  });
1382
1580
  return false;
1383
1581
  }
1582
+ /**
1583
+ * Unified session activation (design §2.1 / AC-01): models, runtime snapshot,
1584
+ * reload and model/thinking mutation requests all flow through this path.
1585
+ *
1586
+ * - runtime already holds the session → ok;
1587
+ * - only a persisted record exists → reopen from record.sessionFile;
1588
+ * - no record / missing session file → 404 PI_SESSION_NOT_FOUND;
1589
+ * - SDK reopen failure → 503 PI_RUNTIME_UNAVAILABLE.
1590
+ *
1591
+ * Never fabricates a fresh session or an empty model list.
1592
+ */
1593
+ export async function ensureActiveSession(deps, sessionId) {
1594
+ // Wait for create-path materialization before treating the session as missing.
1595
+ if (typeof deps.runtime.ensureSessionReady === "function") {
1596
+ try {
1597
+ await deps.runtime.ensureSessionReady(sessionId);
1598
+ if (deps.runtime.hasSession(sessionId))
1599
+ return { ok: true };
1600
+ }
1601
+ catch (error) {
1602
+ const code = error && typeof error === "object" && "code" in error
1603
+ ? String(error.code ?? "")
1604
+ : "";
1605
+ if (code === "PI_SESSION_INIT_FAILED") {
1606
+ return {
1607
+ ok: false,
1608
+ status: 503,
1609
+ code: "PI_SESSION_INIT_FAILED",
1610
+ message: error instanceof Error ? error.message : String(error),
1611
+ };
1612
+ }
1613
+ // Not initializing / not found — fall through to reopen path.
1614
+ }
1615
+ }
1616
+ if (deps.runtime.hasSession(sessionId))
1617
+ return { ok: true };
1618
+ const record = await deps.store.get(sessionId);
1619
+ if (!record) {
1620
+ return {
1621
+ ok: false,
1622
+ status: 404,
1623
+ code: "PI_SESSION_NOT_FOUND",
1624
+ message: `chat session not found: ${sessionId}`,
1625
+ };
1626
+ }
1627
+ const reopened = await deps.runtime.reopenSession({
1628
+ sessionId,
1629
+ sessionFile: record.sessionFile,
1630
+ ...(record.modelProvider && record.modelId
1631
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
1632
+ : {}),
1633
+ });
1634
+ if (!reopened.ok) {
1635
+ return { ok: false, ...mapReopenSessionFailure(reopened) };
1636
+ }
1637
+ return { ok: true };
1638
+ }
1639
+ /**
1640
+ * POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
1641
+ * structured busy/failure semantics. Never aborts, never silently queues.
1642
+ */
1643
+ export async function handleReloadSession(req, res, deps, sessionId) {
1644
+ const gate = gateMutation(req, deps);
1645
+ if (!gate.ok) {
1646
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1647
+ return;
1648
+ }
1649
+ const ensured = await ensureActiveSession(deps, sessionId);
1650
+ if (!ensured.ok) {
1651
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1652
+ return;
1653
+ }
1654
+ const result = await deps.runtime.reloadSession(sessionId);
1655
+ if (!result.ok) {
1656
+ const status = result.code === "PI_SESSION_NOT_FOUND"
1657
+ ? 404
1658
+ : result.code === "PI_SESSION_BUSY"
1659
+ ? 409
1660
+ : result.code === "PI_SESSION_INIT_FAILED"
1661
+ ? 503
1662
+ : 500;
1663
+ sendJson(res, status, { ok: false, error: { code: result.code, message: result.message } });
1664
+ return;
1665
+ }
1666
+ // RF-02: the reload succeeded, but a failed snapshot read is still a
1667
+ // structured 503 — never a 200 with fake-empty resources.
1668
+ let snapshot;
1669
+ try {
1670
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1671
+ }
1672
+ catch (error) {
1673
+ if (sendRuntimeUnavailable(res, error))
1674
+ return;
1675
+ throw error;
1676
+ }
1677
+ sendJson(res, 200, {
1678
+ ok: true,
1679
+ revision: result.revision,
1680
+ snapshot,
1681
+ resourceCounts: snapshot
1682
+ ? {
1683
+ skills: snapshot.skills.length,
1684
+ extensions: snapshot.extensions.length,
1685
+ packages: snapshot.packages.length,
1686
+ contextFiles: snapshot.contextFiles.length,
1687
+ }
1688
+ : undefined,
1689
+ });
1690
+ }
1691
+ /**
1692
+ * GET /sessions/:id/runtime-snapshot (AC-01): the unified live snapshot.
1693
+ */
1694
+ export async function handleRuntimeSnapshot(_res, res, deps, sessionId) {
1695
+ const ensured = await ensureActiveSession(deps, sessionId);
1696
+ if (!ensured.ok) {
1697
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1698
+ return;
1699
+ }
1700
+ // RF-02: a critical snapshot projection failure is 503 PI_RUNTIME_UNAVAILABLE
1701
+ // — never a 200 fake-empty snapshot.
1702
+ let snapshot;
1703
+ try {
1704
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1705
+ }
1706
+ catch (error) {
1707
+ if (sendRuntimeUnavailable(res, error))
1708
+ return;
1709
+ throw error;
1710
+ }
1711
+ if (!snapshot) {
1712
+ sendJson(res, 503, {
1713
+ ok: false,
1714
+ error: { code: "PI_RUNTIME_UNAVAILABLE", message: `chat session not active: ${sessionId}` },
1715
+ });
1716
+ return;
1717
+ }
1718
+ sendJson(res, 200, { ok: true, snapshot });
1719
+ }
1384
1720
  async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
1385
1721
  const gate = gateMutation(req, deps);
1386
1722
  if (!gate.ok) {
@@ -2060,9 +2396,13 @@ export async function handleChatCompact(req, res, deps, sessionId) {
2060
2396
  : {}),
2061
2397
  });
2062
2398
  if (!reopened.ok) {
2063
- sendJson(res, 409, {
2399
+ const mapped = mapReopenSessionFailure({
2400
+ code: reopened.code,
2401
+ message: reopened.message,
2402
+ });
2403
+ sendJson(res, mapped.status, {
2064
2404
  ok: false,
2065
- error: { code: reopened.code, message: reopened.message },
2405
+ error: { code: mapped.code, message: mapped.message },
2066
2406
  });
2067
2407
  return;
2068
2408
  }
@@ -2153,16 +2493,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
2153
2493
  await handlePiConfig(req, res, deps, "packages");
2154
2494
  return true;
2155
2495
  }
2156
- if (pathname === "/api/operator/v1/chat/instruction-skills" &&
2157
- method === "GET") {
2158
- await handlePiConfig(req, res, deps, "skills");
2159
- return true;
2160
- }
2161
- if (pathname === "/api/operator/v1/chat/instruction-skills/preferences" &&
2162
- method === "PUT") {
2163
- await handlePiConfig(req, res, deps, "skills");
2164
- return true;
2165
- }
2166
2496
  if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
2167
2497
  await handleChatCapabilities(req, res, deps);
2168
2498
  return true;
@@ -2221,7 +2551,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
2221
2551
  await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
2222
2552
  return true;
2223
2553
  }
2224
- const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
2554
+ const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
2225
2555
  if (m4Route) {
2226
2556
  const id = decodeURIComponent(m4Route[1]);
2227
2557
  const route = m4Route[2];
@@ -2237,6 +2567,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
2237
2567
  await handleRuntimeContext(res, deps, id);
2238
2568
  return true;
2239
2569
  }
2570
+ if (route === "runtime-snapshot" && method === "GET") {
2571
+ await handleRuntimeSnapshot(req, res, deps, id);
2572
+ return true;
2573
+ }
2240
2574
  if (route === "models" && method === "GET") {
2241
2575
  await handleModels(req, res, deps, id);
2242
2576
  return true;
@@ -2245,6 +2579,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
2245
2579
  await handleModels(req, res, deps, id);
2246
2580
  return true;
2247
2581
  }
2582
+ if (route === "reload" && method === "POST") {
2583
+ await handleReloadSession(req, res, deps, id);
2584
+ return true;
2585
+ }
2248
2586
  }
2249
2587
  const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
2250
2588
  if (method === "POST" && sessionMainline) {