@tea-agent/loop-agent 0.33.5 → 0.33.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/application/task-lifecycle/advance.js +254 -4
  3. package/dist/application/task-lifecycle/gates.js +50 -0
  4. package/dist/application/task-lifecycle/observe.js +11 -2
  5. package/dist/commands/init-upgrade.js +32 -1
  6. package/dist/commands/init.js +94 -3
  7. package/dist/executors/shell-write-guard.js +26 -8
  8. package/dist/shared/operator/capabilities.js +72 -42
  9. package/dist/shared/resilient-git.js +133 -0
  10. package/dist/task/source-prepare/artifact-meta.js +137 -0
  11. package/dist/task/source-prepare/index.js +2 -0
  12. package/dist/task/source-prepare/parse-intent.js +58 -10
  13. package/dist/task/source-prepare/prepare.js +180 -16
  14. package/dist/task/source-prepare/reference-integrity.js +18 -2
  15. package/dist/task/source-prepare/semantic-intake.js +404 -0
  16. package/dist/worker/console/app-data.js +2 -0
  17. package/dist/worker/console/chat/chat-event-store.js +190 -25
  18. package/dist/worker/console/chat/pi-console-config.js +250 -32
  19. package/dist/worker/console/chat/pi-runtime.js +625 -71
  20. package/dist/worker/console/chat/resource-loader.js +5 -4
  21. package/dist/worker/console/chat/routes.js +324 -146
  22. package/dist/worker/console/chat/runtime-context.js +48 -12
  23. package/dist/worker/console/chat/runtime-selection.js +59 -0
  24. package/dist/worker/console/chat/shortcuts.js +1 -0
  25. package/dist/worker/console/chat/tool-adapter.js +9 -3
  26. package/dist/worker/console/chat/tools.js +5 -1
  27. package/dist/worker/console/dag-execution-receipt.js +380 -0
  28. package/dist/worker/console/operator-actions.js +559 -68
  29. package/dist/worker/console/server.js +8 -15
  30. package/dist/worker/console/static/assets/index-BUOLppPr.js +28 -0
  31. package/dist/worker/console/static/assets/index-C1KzazY5.css +1 -0
  32. package/dist/worker/console/static/index.html +2 -2
  33. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +45 -8
  34. package/dist/worker/console/static-src/operator-chat/refs.js +9 -0
  35. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +257 -0
  36. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +16 -0
  37. package/dist/worker/console/static-src/operator-chat/useChatStream.js +210 -184
  38. package/dist/worker/console/static-src/operator-chat/useChatThread.js +49 -5
  39. package/dist/worker/console/static-src/operator-chat/useComposer.js +17 -0
  40. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +225 -74
  41. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +196 -0
  42. package/dist/worker/delivery/final-verification.js +13 -5
  43. package/dist/worker/delivery/package.js +31 -19
  44. package/dist/worker/delivery/verification-bundle.js +6 -4
  45. package/dist/worker/observe/static/operator-chrome.css +5 -2
  46. package/dist/worker/observe/static/operator-chrome.js +6 -1
  47. package/dist/worker/observe/static/styles.css +39 -9
  48. package/dist/workflows/dag/frontend-worktree-diff.js +12 -27
  49. package/dist/workflows/dag/workspace-checkpoint.js +8 -27
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/skills/loop-agent/references/command-reference.md +3 -1
  53. package/skills/loop-agent/references/source-and-plan-practice.md +13 -0
  54. package/skills/loop-agent/references/task-workflow.md +4 -0
  55. package/dist/worker/console/chat/instruction-skills.js +0 -217
  56. package/dist/worker/console/static/assets/index-CnUXAqxG.css +0 -1
  57. package/dist/worker/console/static/assets/index-CteJFFL2.js +0 -29
@@ -8,9 +8,10 @@
8
8
  * denied. Interview/Official loaders are unchanged.
9
9
  *
10
10
  * Note: this adapter is NOT the SDK's own ResourceLoader (that is
11
- * DefaultResourceLoader, created inside createAgentSessionServices with
12
- * noContextFiles/noSkills/noExtensions). This adapter is the operator-chat
13
- * declarative surface used for capabilities reporting and red-team checks.
11
+ * DefaultResourceLoader, created inside createAgentSessionServices with the
12
+ * standard OPEN surface since ADR 0012 — no noContextFiles/noSkills/
13
+ * noExtensions switches). This adapter is the operator-chat declarative
14
+ * surface used for capabilities reporting and red-team checks.
14
15
  * The three-gate authorization (authorizeOperatorChatTool) is the actual
15
16
  * enforcement; this adapter describes what that gate admits.
16
17
  */
@@ -29,7 +30,7 @@ export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
29
30
  export function createOperatorChatResourceLoader() {
30
31
  let active = new Set();
31
32
  return {
32
- mode: "operator-chat-closed",
33
+ mode: "operator-chat-open",
33
34
  allowedToolIds: OPERATOR_CHAT_ALLOWED_TOOLS,
34
35
  deniedToolIds: OPERATOR_CHAT_DENIED_TOOLS,
35
36
  deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
@@ -6,24 +6,22 @@
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";
19
17
  import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
20
- import { THINKING_LEVELS, composeOperatorChatSystemPrompt, } from "./pi-runtime.js";
18
+ import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
21
19
  import { ComposerDraftStore } from "./composer-draft-store.js";
22
20
  import { walkRepoFiles } from "./repo-walk.js";
23
21
  import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
24
22
  import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
25
23
  import { projectRuntimeContext } from "./runtime-context.js";
26
- import { patchModelsConfig, readModelsConfig, readPackageInventory, readSkillPreferences, writeSkillPreferences, } from "./pi-console-config.js";
24
+ import { patchModelsConfig, readModelsConfig, readPackageInventory, } from "./pi-console-config.js";
27
25
  import { ChatInterviewAdapter, projectChatInterviewState, } from "./interview-adapter.js";
28
26
  import { classifyChatCompactionFailure } from "./compaction-errors.js";
29
27
  import { evaluateMutationGate, isMutationMethod } from "../security.js";
@@ -33,9 +31,24 @@ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
33
31
  import { projectTaskContext } from "./context-panel.js";
34
32
  import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
35
33
  import { sendJson } from "../routes.js";
36
- import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
34
+ import { openSseResponse, writeSseEvent } from "../operation-sse.js";
35
+ /** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
36
+ * the same wire shape; its prefix intentionally fails the current process
37
+ * epoch comparison and triggers safe snapshot recovery (M5.1). */
38
+ function parseChatEventCursor(header) {
39
+ if (!header?.trim())
40
+ return { epoch: null, seq: 0 };
41
+ const raw = header.trim();
42
+ const colon = raw.lastIndexOf(":");
43
+ const seqPart = colon >= 0 ? raw.slice(colon + 1) : raw;
44
+ const seq = Number.parseInt(seqPart, 10);
45
+ return {
46
+ epoch: colon > 0 ? raw.slice(0, colon) : null,
47
+ seq: Number.isFinite(seq) && seq >= 0 ? seq : 0,
48
+ };
49
+ }
37
50
  function writeChatSse(res, event) {
38
- // Persisted Chat events carry their own eventId (<sessionId>:<seq>); write
51
+ // Persisted Chat events carry their own eventId (`<epoch>:<seq>`); write
39
52
  // it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
40
53
  if ("seq" in event && event.eventId) {
41
54
  res.write(`id: ${event.eventId}\n`);
@@ -93,21 +106,32 @@ function turnEventToStorePartial(event) {
93
106
  return undefined;
94
107
  }
95
108
  }
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. */
109
+ /** Guarantee durable terminal events for a turn that ended without the SDK
110
+ * emitting them (client abort, pre-prompt persistence failure, runtime
111
+ * error). Without `agent_end`, reconnect replay stops mid-turn; without
112
+ * `agent_settled`, the single-stream UI keeps idle-grace streaming forever
113
+ * (M4.4). Idempotent for normal settled turns that already have both. */
101
114
  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) {
115
+ const snap = deps.events.snapshot(sessionId);
116
+ const hasAgentEnd = snap.some((event) => event.turnId === turnId && event.kind === "agent_end");
117
+ if (!hasAgentEnd) {
106
118
  deps.events.append(sessionId, turnId, {
107
119
  kind: "agent_end",
108
120
  data: { willRetry: false },
109
121
  });
110
122
  }
123
+ // Error/abort paths historically only forced agent_end. After the M4 single
124
+ // stream keeps UI streaming through agent_end for idle grace, a matching
125
+ // agent_settled is required so state reconcile and the applier both finish.
126
+ const hasSettled = deps.events
127
+ .snapshot(sessionId)
128
+ .some((event) => event.turnId === turnId && event.kind === "agent_settled");
129
+ if (!hasSettled) {
130
+ deps.events.append(sessionId, turnId, {
131
+ kind: "agent_settled",
132
+ data: {},
133
+ });
134
+ }
111
135
  }
112
136
  async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
113
137
  const chunks = [];
@@ -198,9 +222,23 @@ function gateMutation(req, deps) {
198
222
  }
199
223
  return { ok: true };
200
224
  }
225
+ /**
226
+ * RF-02: map a RuntimeSnapshotUnavailableError (critical snapshot projection
227
+ * failure — model enumeration / system prompt) to 503 PI_RUNTIME_UNAVAILABLE.
228
+ * Returns true when the response was sent; other errors keep their existing
229
+ * 409/500 branches so the mutation/reload semantics never regress.
230
+ */
231
+ function sendRuntimeUnavailable(res, error) {
232
+ if (!(error instanceof RuntimeSnapshotUnavailableError))
233
+ return false;
234
+ sendJson(res, 503, {
235
+ ok: false,
236
+ error: { code: error.code, message: error.message },
237
+ });
238
+ return true;
239
+ }
201
240
  export async function handleChatCapabilities(_req, res, deps) {
202
241
  const toolSchemas = buildModelCallableToolSchemas();
203
- const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
204
242
  sendJson(res, 200, {
205
243
  ok: true,
206
244
  schemaVersion: 1,
@@ -208,18 +246,6 @@ export async function handleChatCapabilities(_req, res, deps) {
208
246
  deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
209
247
  deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
210
248
  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
249
  // ADR 0011: full Pi tools including bash are default-active.
224
250
  hasBash: true,
225
251
  });
@@ -276,10 +302,7 @@ export async function handleCreateChatSession(req, res, deps) {
276
302
  return;
277
303
  }
278
304
  try {
279
- deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
280
- const snapshot = snapshotInput === undefined
281
- ? undefined
282
- : projectCompactSnapshot(snapshotInput);
305
+ const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
283
306
  const handle = await deps.runtime.createSession({
284
307
  ...(provider && modelId ? { model: { provider, modelId } } : {}),
285
308
  ...(snapshot
@@ -491,6 +514,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
491
514
  const lastEvent = snapshot.at(-1);
492
515
  sendJson(res, 200, {
493
516
  ok: true,
517
+ eventEpoch: deps.events.epoch(sessionId),
494
518
  session: {
495
519
  ...record,
496
520
  composerDraft,
@@ -1025,23 +1049,31 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1025
1049
  }
1026
1050
  await deps.operationLinker.recoverSession(sessionId);
1027
1051
  openSseResponse(res);
1028
- // Resume from the client's last seen eventId (Last-Event-ID header, T04).
1052
+ // Resume from the client's last seen eventId (`<epoch>:<seq>`). A previous
1053
+ // Console boot/legacy cursor has a different prefix and receives snapshot.
1029
1054
  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;
1055
+ const cursor = parseChatEventCursor(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
1056
+ const replay = deps.events.listFrom(sessionId, cursor);
1057
+ let lastSeq = cursor.seq;
1033
1058
  if ("error" in replay) {
1034
1059
  // Cursor aged out of the bounded ring (T07 fence): tell the client to
1035
1060
  // reconcile from a fresh snapshot rather than render a gap as if current.
1061
+ // The frame carries the ring tail (latestEventId) so the client can resume
1062
+ // the replayed snapshot with a fresh Last-Event-ID (recovery cursor).
1063
+ const retained = deps.events.snapshot(sessionId);
1036
1064
  writeChatSse(res, {
1037
1065
  kind: "reconcile",
1038
1066
  sessionId,
1039
- reason: "cursor_expired",
1067
+ reason: replay.error === "CURSOR_EPOCH_MISMATCH"
1068
+ ? "epoch_mismatch"
1069
+ : "cursor_expired",
1040
1070
  minSeq: replay.minSeq,
1071
+ epoch: replay.epoch,
1041
1072
  latestTurnId: deps.events.latestTurnId(sessionId),
1073
+ latestEventId: retained.at(-1)?.eventId ?? null,
1042
1074
  });
1043
1075
  // Fall back to full retained snapshot so the client can re-render.
1044
- for (const ev of deps.events.snapshot(sessionId)) {
1076
+ for (const ev of retained) {
1045
1077
  writeChatSse(res, ev);
1046
1078
  lastSeq = Math.max(lastSeq, ev.seq);
1047
1079
  }
@@ -1052,6 +1084,15 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1052
1084
  lastSeq = Math.max(lastSeq, ev.seq);
1053
1085
  }
1054
1086
  }
1087
+ // Delimit retained replay from follow mode. The client uses this marker to
1088
+ // ensure its sole GET /events consumer is ready before POST /turns (M4.1).
1089
+ const retainedTail = deps.events.snapshot(sessionId).at(-1);
1090
+ writeChatSse(res, {
1091
+ kind: "stream_ready",
1092
+ sessionId,
1093
+ epoch: deps.events.epoch(sessionId),
1094
+ latestEventId: retainedTail?.eventId ?? null,
1095
+ });
1055
1096
  let closed = false;
1056
1097
  res.once("close", () => {
1057
1098
  closed = true;
@@ -1211,47 +1252,6 @@ async function handlePiConfig(req, res, deps, resource) {
1211
1252
  });
1212
1253
  return;
1213
1254
  }
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
1255
  }
1256
1256
  catch (error) {
1257
1257
  const failure = error;
@@ -1279,94 +1279,148 @@ async function handleChatFiles(res, deps, sessionId, query) {
1279
1279
  .map((path) => ({ path }));
1280
1280
  sendJson(res, 200, { ok: true, files });
1281
1281
  }
1282
- async function handleRuntimeContext(res, deps, sessionId) {
1283
- const record = await deps.store.get(sessionId);
1284
- if (!record) {
1285
- sendJson(res, 404, { ok: false });
1282
+ export async function handleRuntimeContext(res, deps, sessionId) {
1283
+ const ensured = await ensureActiveSession(deps, sessionId);
1284
+ if (!ensured.ok) {
1285
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1286
+ return;
1287
+ }
1288
+ // AC-03: system prompt comes from the REAL Pi session (agent.state.systemPrompt
1289
+ // fallback chain); resources come from that session's ResourceLoader. The raw
1290
+ // prompt stays server-side; the browser only ever receives the scrubbed +
1291
+ // length-limited projection. RF-02: a critical projection failure is 503
1292
+ // PI_RUNTIME_UNAVAILABLE — never a 500 leak or a fake-empty context.
1293
+ let snapshot;
1294
+ try {
1295
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId, {
1296
+ includeRaw: true,
1297
+ });
1298
+ }
1299
+ catch (error) {
1300
+ if (sendRuntimeUnavailable(res, error))
1301
+ return;
1302
+ throw error;
1303
+ }
1304
+ if (!snapshot) {
1305
+ sendJson(res, 503, {
1306
+ ok: false,
1307
+ error: {
1308
+ code: "PI_RUNTIME_UNAVAILABLE",
1309
+ message: `chat session not active: ${sessionId}`,
1310
+ },
1311
+ });
1286
1312
  return;
1287
1313
  }
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
1314
  sendJson(res, 200, {
1293
1315
  ok: true,
1294
1316
  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,
1317
+ systemPrompt: snapshot.systemPrompt.raw ?? "",
1318
+ model: snapshot.selection.model
1319
+ ? {
1320
+ provider: snapshot.selection.model.provider,
1321
+ modelId: snapshot.selection.model.id,
1322
+ }
1323
+ : undefined,
1324
+ thinkingLevel: snapshot.selection.thinkingSelection === "auto"
1325
+ ? snapshot.selection.effectiveThinkingLevel
1326
+ : snapshot.selection.thinkingSelection,
1327
+ activeTools: snapshot.activeTools,
1328
+ contextFiles: snapshot.contextFiles,
1329
+ skills: snapshot.skills,
1330
+ extensions: snapshot.extensions,
1331
+ packages: snapshot.packages.map((pkg) => ({
1332
+ name: pkg.spec,
1333
+ source: pkg.spec,
1334
+ scope: pkg.scope,
1335
+ enabled: pkg.enabled,
1336
+ ...(pkg.version ? { version: pkg.version } : {}),
1337
+ ...(pkg.path ? { path: pkg.path } : {}),
1338
+ ...(pkg.cwd ? { cwd: pkg.cwd } : {}),
1339
+ resolved: pkg.resolved,
1340
+ diagnostics: pkg.diagnostics,
1301
1341
  })),
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,
1342
+ diagnostics: snapshot.diagnostics,
1308
1343
  }),
1309
1344
  });
1310
1345
  }
1311
- async function handleModels(req, res, deps, sessionId) {
1312
- if (!(await requireChatSession(res, deps, sessionId)))
1346
+ // Exported for the direct handler-boundary tests (trace binding for
1347
+ // vt-route-model-errors); router dispatch behavior is unchanged.
1348
+ export async function handleModels(req, res, deps, sessionId) {
1349
+ // AC-01/AC-06: models (and model/thinking mutation) requests reuse the same
1350
+ // activation path. A missing/reopen-failed runtime is a STRUCTURED error —
1351
+ // never a fabricated empty model list.
1352
+ const ensured = await ensureActiveSession(deps, sessionId);
1353
+ if (!ensured.ok) {
1354
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1313
1355
  return;
1356
+ }
1314
1357
  try {
1315
1358
  if ((req.method ?? "GET").toUpperCase() === "GET") {
1359
+ let models;
1360
+ try {
1361
+ models = await deps.runtime.listModels(sessionId);
1362
+ }
1363
+ catch (error) {
1364
+ // AC-06: a model-list failure is a STRUCTURED runtime error — never
1365
+ // folded into a fake empty list.
1366
+ sendJson(res, 503, {
1367
+ ok: false,
1368
+ error: {
1369
+ code: "PI_RUNTIME_UNAVAILABLE",
1370
+ message: error instanceof Error ? error.message : String(error),
1371
+ },
1372
+ });
1373
+ return;
1374
+ }
1316
1375
  sendJson(res, 200, {
1317
1376
  ok: true,
1318
- models: await deps.runtime.listModels(sessionId),
1377
+ models: models.map((model) => ({
1378
+ provider: model.provider,
1379
+ id: model.id ?? model.modelId ?? "",
1380
+ ...(model.name ? { name: model.name } : {}),
1381
+ hasCredentials: model.hasCredentials,
1382
+ })),
1319
1383
  thinkingLevels: THINKING_LEVELS,
1320
1384
  });
1321
1385
  return;
1322
1386
  }
1323
1387
  const gate = gateMutation(req, deps);
1324
1388
  if (!gate.ok) {
1325
- sendJson(res, gate.status, {
1326
- ok: false,
1327
- error: { code: gate.code, message: gate.message },
1328
- });
1389
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1329
1390
  return;
1330
1391
  }
1331
1392
  const body = await readJsonBody(req);
1332
1393
  let updated;
1333
1394
  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
- });
1395
+ const model = await deps.runtime.applyModel(sessionId, { provider: body.provider, modelId: body.modelId });
1396
+ updated = await deps.store.setRuntimeSelection(sessionId, { modelProvider: model.provider, modelId: model.modelId });
1342
1397
  }
1343
- else if (typeof body.thinkingLevel === "string" &&
1344
- THINKING_LEVELS.includes(body.thinkingLevel)) {
1398
+ else if (typeof body.thinkingLevel === "string" && (body.thinkingLevel === "auto" || THINKING_LEVELS.includes(body.thinkingLevel))) {
1345
1399
  const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
1346
- updated = await deps.store.setRuntimeSelection(sessionId, {
1347
- thinkingLevel,
1348
- });
1400
+ updated = await deps.store.setRuntimeSelection(sessionId, thinkingLevel === "auto" ? {} : { thinkingLevel });
1349
1401
  }
1350
1402
  else {
1351
- sendJson(res, 400, {
1352
- ok: false,
1353
- error: {
1354
- code: "INVALID_INPUT",
1355
- message: "provider/modelId or thinkingLevel is required",
1356
- },
1357
- });
1403
+ sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
1358
1404
  return;
1359
1405
  }
1360
- sendJson(res, 200, { ok: true, session: updated });
1406
+ // Design §5: mutations return the fresh snapshot so the browser can
1407
+ // atomically replace its state (revision increments server-side).
1408
+ // RF-02: the mutation may already be applied server-side, but a failed
1409
+ // snapshot read must surface as 503 PI_RUNTIME_UNAVAILABLE — NOT be
1410
+ // folded into a 409 MODEL_SWITCH_FAILED (the frontend refresh path
1411
+ // re-reads the snapshot to converge on the applied selection).
1412
+ const snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1413
+ sendJson(res, 200, { ok: true, session: updated, snapshot });
1361
1414
  }
1362
1415
  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
- });
1416
+ if (error instanceof RuntimeSnapshotUnavailableError) {
1417
+ sendJson(res, 503, {
1418
+ ok: false,
1419
+ error: { code: error.code, message: error.message },
1420
+ });
1421
+ return;
1422
+ }
1423
+ sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
1370
1424
  }
1371
1425
  }
1372
1426
  async function requireChatSession(res, deps, sessionId) {
@@ -1381,6 +1435,132 @@ async function requireChatSession(res, deps, sessionId) {
1381
1435
  });
1382
1436
  return false;
1383
1437
  }
1438
+ /**
1439
+ * Unified session activation (design §2.1 / AC-01): models, runtime snapshot,
1440
+ * reload and model/thinking mutation requests all flow through this path.
1441
+ *
1442
+ * - runtime already holds the session → ok;
1443
+ * - only a persisted record exists → reopen from record.sessionFile;
1444
+ * - no record / missing session file → 404 PI_SESSION_NOT_FOUND;
1445
+ * - SDK reopen failure → 503 PI_RUNTIME_UNAVAILABLE.
1446
+ *
1447
+ * Never fabricates a fresh session or an empty model list.
1448
+ */
1449
+ export async function ensureActiveSession(deps, sessionId) {
1450
+ if (deps.runtime.hasSession(sessionId))
1451
+ return { ok: true };
1452
+ const record = await deps.store.get(sessionId);
1453
+ if (!record) {
1454
+ return {
1455
+ ok: false,
1456
+ status: 404,
1457
+ code: "PI_SESSION_NOT_FOUND",
1458
+ message: `chat session not found: ${sessionId}`,
1459
+ };
1460
+ }
1461
+ const reopened = await deps.runtime.reopenSession({
1462
+ sessionId,
1463
+ sessionFile: record.sessionFile,
1464
+ ...(record.modelProvider && record.modelId
1465
+ ? { model: { provider: record.modelProvider, modelId: record.modelId } }
1466
+ : {}),
1467
+ });
1468
+ if (!reopened.ok) {
1469
+ return reopened.code === "NO_SESSION_FILE"
1470
+ ? {
1471
+ ok: false,
1472
+ status: 404,
1473
+ code: "PI_SESSION_NOT_FOUND",
1474
+ message: reopened.message,
1475
+ }
1476
+ : {
1477
+ ok: false,
1478
+ status: 503,
1479
+ code: "PI_RUNTIME_UNAVAILABLE",
1480
+ message: reopened.message,
1481
+ };
1482
+ }
1483
+ return { ok: true };
1484
+ }
1485
+ /**
1486
+ * POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
1487
+ * structured busy/failure semantics. Never aborts, never silently queues.
1488
+ */
1489
+ export async function handleReloadSession(req, res, deps, sessionId) {
1490
+ const gate = gateMutation(req, deps);
1491
+ if (!gate.ok) {
1492
+ sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1493
+ return;
1494
+ }
1495
+ const ensured = await ensureActiveSession(deps, sessionId);
1496
+ if (!ensured.ok) {
1497
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1498
+ return;
1499
+ }
1500
+ const result = await deps.runtime.reloadSession(sessionId);
1501
+ if (!result.ok) {
1502
+ const status = result.code === "PI_SESSION_NOT_FOUND"
1503
+ ? 404
1504
+ : result.code === "PI_SESSION_BUSY"
1505
+ ? 409
1506
+ : 500;
1507
+ sendJson(res, status, { ok: false, error: { code: result.code, message: result.message } });
1508
+ return;
1509
+ }
1510
+ // RF-02: the reload succeeded, but a failed snapshot read is still a
1511
+ // structured 503 — never a 200 with fake-empty resources.
1512
+ let snapshot;
1513
+ try {
1514
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1515
+ }
1516
+ catch (error) {
1517
+ if (sendRuntimeUnavailable(res, error))
1518
+ return;
1519
+ throw error;
1520
+ }
1521
+ sendJson(res, 200, {
1522
+ ok: true,
1523
+ revision: result.revision,
1524
+ snapshot,
1525
+ resourceCounts: snapshot
1526
+ ? {
1527
+ skills: snapshot.skills.length,
1528
+ extensions: snapshot.extensions.length,
1529
+ packages: snapshot.packages.length,
1530
+ contextFiles: snapshot.contextFiles.length,
1531
+ }
1532
+ : undefined,
1533
+ });
1534
+ }
1535
+ /**
1536
+ * GET /sessions/:id/runtime-snapshot (AC-01): the unified live snapshot.
1537
+ */
1538
+ export async function handleRuntimeSnapshot(_res, res, deps, sessionId) {
1539
+ const ensured = await ensureActiveSession(deps, sessionId);
1540
+ if (!ensured.ok) {
1541
+ sendJson(res, ensured.status, { ok: false, error: { code: ensured.code, message: ensured.message } });
1542
+ return;
1543
+ }
1544
+ // RF-02: a critical snapshot projection failure is 503 PI_RUNTIME_UNAVAILABLE
1545
+ // — never a 200 fake-empty snapshot.
1546
+ let snapshot;
1547
+ try {
1548
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
1549
+ }
1550
+ catch (error) {
1551
+ if (sendRuntimeUnavailable(res, error))
1552
+ return;
1553
+ throw error;
1554
+ }
1555
+ if (!snapshot) {
1556
+ sendJson(res, 503, {
1557
+ ok: false,
1558
+ error: { code: "PI_RUNTIME_UNAVAILABLE", message: `chat session not active: ${sessionId}` },
1559
+ });
1560
+ return;
1561
+ }
1562
+ sendJson(res, 200, { ok: true, snapshot });
1563
+ }
1384
1564
  async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
1385
1565
  const gate = gateMutation(req, deps);
1386
1566
  if (!gate.ok) {
@@ -2153,16 +2333,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
2153
2333
  await handlePiConfig(req, res, deps, "packages");
2154
2334
  return true;
2155
2335
  }
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
2336
  if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
2167
2337
  await handleChatCapabilities(req, res, deps);
2168
2338
  return true;
@@ -2221,7 +2391,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
2221
2391
  await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
2222
2392
  return true;
2223
2393
  }
2224
- const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
2394
+ const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
2225
2395
  if (m4Route) {
2226
2396
  const id = decodeURIComponent(m4Route[1]);
2227
2397
  const route = m4Route[2];
@@ -2237,6 +2407,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
2237
2407
  await handleRuntimeContext(res, deps, id);
2238
2408
  return true;
2239
2409
  }
2410
+ if (route === "runtime-snapshot" && method === "GET") {
2411
+ await handleRuntimeSnapshot(req, res, deps, id);
2412
+ return true;
2413
+ }
2240
2414
  if (route === "models" && method === "GET") {
2241
2415
  await handleModels(req, res, deps, id);
2242
2416
  return true;
@@ -2245,6 +2419,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
2245
2419
  await handleModels(req, res, deps, id);
2246
2420
  return true;
2247
2421
  }
2422
+ if (route === "reload" && method === "POST") {
2423
+ await handleReloadSession(req, res, deps, id);
2424
+ return true;
2425
+ }
2248
2426
  }
2249
2427
  const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
2250
2428
  if (method === "POST" && sessionMainline) {