@tea-agent/loop-agent 0.33.6 → 0.33.7-beta.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 (69) hide show
  1. package/CHANGELOG.md +39 -23
  2. package/dist/application/task-lifecycle/advance.js +4 -254
  3. package/dist/application/task-lifecycle/gates.js +0 -50
  4. package/dist/application/task-lifecycle/observe.js +2 -11
  5. package/dist/commands/init-upgrade.js +1 -32
  6. package/dist/commands/init.js +3 -94
  7. package/dist/executors/shell-executor.js +91 -4
  8. package/dist/executors/shell-write-guard.js +8 -26
  9. package/dist/shared/operator/capabilities.js +42 -72
  10. package/dist/task/source-prepare/index.js +0 -2
  11. package/dist/task/source-prepare/parse-intent.js +10 -58
  12. package/dist/task/source-prepare/prepare.js +16 -180
  13. package/dist/task/source-prepare/reference-integrity.js +2 -18
  14. package/dist/worker/console/app-data.js +0 -2
  15. package/dist/worker/console/chat/chat-event-store.js +25 -190
  16. package/dist/worker/console/chat/instruction-skills.js +217 -0
  17. package/dist/worker/console/chat/pi-console-config.js +32 -250
  18. package/dist/worker/console/chat/pi-runtime.js +71 -625
  19. package/dist/worker/console/chat/resource-loader.js +4 -5
  20. package/dist/worker/console/chat/routes.js +146 -324
  21. package/dist/worker/console/chat/runtime-context.js +12 -48
  22. package/dist/worker/console/chat/runtime-selection.js +0 -59
  23. package/dist/worker/console/chat/shortcuts.js +0 -1
  24. package/dist/worker/console/chat/tool-adapter.js +3 -9
  25. package/dist/worker/console/chat/tools.js +1 -5
  26. package/dist/worker/console/operator-actions.js +68 -559
  27. package/dist/worker/console/server.js +15 -8
  28. package/dist/worker/console/static/assets/index-CnUXAqxG.css +1 -0
  29. package/dist/worker/console/static/assets/index-CteJFFL2.js +29 -0
  30. package/dist/worker/console/static/index.html +2 -2
  31. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +8 -45
  32. package/dist/worker/console/static-src/operator-chat/refs.js +0 -9
  33. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +0 -16
  34. package/dist/worker/console/static-src/operator-chat/useChatStream.js +184 -210
  35. package/dist/worker/console/static-src/operator-chat/useChatThread.js +5 -49
  36. package/dist/worker/console/static-src/operator-chat/useComposer.js +0 -17
  37. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +74 -225
  38. package/dist/worker/delivery/final-verification.js +5 -13
  39. package/dist/worker/delivery/package.js +19 -31
  40. package/dist/worker/delivery/verification-bundle.js +4 -6
  41. package/dist/worker/observe/static/operator-chrome.css +2 -5
  42. package/dist/worker/observe/static/operator-chrome.js +1 -6
  43. package/dist/worker/observe/static/styles.css +9 -39
  44. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +462 -33
  45. package/dist/workflows/dag/backend-test-case-manifest.js +4 -0
  46. package/dist/workflows/dag/backend-test-markdown-workflow.js +25 -1
  47. package/dist/workflows/dag/backend-test-module-stem.js +5 -0
  48. package/dist/workflows/dag/backend-test-pytest-collection.js +345 -24
  49. package/dist/workflows/dag/backend-test-scenario-param.js +552 -124
  50. package/dist/workflows/dag/backend-test-writer-completeness.js +47 -16
  51. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  52. package/dist/workflows/dag/frontend-worktree-diff.js +27 -12
  53. package/dist/workflows/dag/init-hybrid.js +49 -37
  54. package/dist/workflows/dag/types.js +7 -0
  55. package/dist/workflows/dag/workspace-checkpoint.js +27 -8
  56. package/docs/templates/backend-test-dag.json +33 -30
  57. package/harness.json +1 -1
  58. package/package.json +1 -1
  59. package/skills/loop-agent/references/command-reference.md +1 -3
  60. package/skills/loop-agent/references/source-and-plan-practice.md +0 -13
  61. package/skills/loop-agent/references/task-workflow.md +0 -4
  62. package/dist/shared/resilient-git.js +0 -133
  63. package/dist/task/source-prepare/artifact-meta.js +0 -137
  64. package/dist/task/source-prepare/semantic-intake.js +0 -404
  65. package/dist/worker/console/dag-execution-receipt.js +0 -380
  66. package/dist/worker/console/static/assets/index-BUOLppPr.js +0 -28
  67. package/dist/worker/console/static/assets/index-C1KzazY5.css +0 -1
  68. package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +0 -257
  69. package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +0 -196
@@ -8,10 +8,9 @@
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 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.
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.
15
14
  * The three-gate authorization (authorizeOperatorChatTool) is the actual
16
15
  * enforcement; this adapter describes what that gate admits.
17
16
  */
@@ -30,7 +29,7 @@ export const OPERATOR_CHAT_SAFE_EXPLORE_TOOL_IDS = Object.freeze([
30
29
  export function createOperatorChatResourceLoader() {
31
30
  let active = new Set();
32
31
  return {
33
- mode: "operator-chat-open",
32
+ mode: "operator-chat-closed",
34
33
  allowedToolIds: OPERATOR_CHAT_ALLOWED_TOOLS,
35
34
  deniedToolIds: OPERATOR_CHAT_DENIED_TOOLS,
36
35
  deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
@@ -6,22 +6,24 @@
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
9
+ * GET /capabilities — Chat tool surface + instruction skills
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";
15
16
  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";
16
18
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
17
19
  import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
18
- import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
20
+ import { THINKING_LEVELS, composeOperatorChatSystemPrompt, } from "./pi-runtime.js";
19
21
  import { ComposerDraftStore } from "./composer-draft-store.js";
20
22
  import { walkRepoFiles } from "./repo-walk.js";
21
23
  import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
22
24
  import { listRepoDirectory, readRepoPreview, RepoBrowserError, } from "./repo-browser.js";
23
25
  import { projectRuntimeContext } from "./runtime-context.js";
24
- import { patchModelsConfig, readModelsConfig, readPackageInventory, } from "./pi-console-config.js";
26
+ import { patchModelsConfig, readModelsConfig, readPackageInventory, readSkillPreferences, writeSkillPreferences, } from "./pi-console-config.js";
25
27
  import { ChatInterviewAdapter, projectChatInterviewState, } from "./interview-adapter.js";
26
28
  import { classifyChatCompactionFailure } from "./compaction-errors.js";
27
29
  import { evaluateMutationGate, isMutationMethod } from "../security.js";
@@ -31,24 +33,9 @@ import { ContractApplyReceiptStore } from "./contract-apply-receipt-store.js";
31
33
  import { projectTaskContext } from "./context-panel.js";
32
34
  import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
33
35
  import { sendJson } from "../routes.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
- }
36
+ import { openSseResponse, parseLastEventId, writeSseEvent, } from "../operation-sse.js";
50
37
  function writeChatSse(res, event) {
51
- // Persisted Chat events carry their own eventId (`<epoch>:<seq>`); write
38
+ // Persisted Chat events carry their own eventId (<sessionId>:<seq>); write
52
39
  // it as the SSE `id:` frame so the browser tracks Last-Event-ID for reconnect.
53
40
  if ("seq" in event && event.eventId) {
54
41
  res.write(`id: ${event.eventId}\n`);
@@ -106,32 +93,21 @@ function turnEventToStorePartial(event) {
106
93
  return undefined;
107
94
  }
108
95
  }
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. */
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. */
114
101
  function ensureAgentEndEvent(deps, sessionId, turnId) {
115
- const snap = deps.events.snapshot(sessionId);
116
- const hasAgentEnd = snap.some((event) => event.turnId === turnId && event.kind === "agent_end");
117
- if (!hasAgentEnd) {
102
+ const already = deps.events
103
+ .snapshot(sessionId)
104
+ .some((event) => event.turnId === turnId && event.kind === "agent_end");
105
+ if (!already) {
118
106
  deps.events.append(sessionId, turnId, {
119
107
  kind: "agent_end",
120
108
  data: { willRetry: false },
121
109
  });
122
110
  }
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
- }
135
111
  }
136
112
  async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
137
113
  const chunks = [];
@@ -222,23 +198,9 @@ function gateMutation(req, deps) {
222
198
  }
223
199
  return { ok: true };
224
200
  }
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
- }
240
201
  export async function handleChatCapabilities(_req, res, deps) {
241
202
  const toolSchemas = buildModelCallableToolSchemas();
203
+ const skills = await loadOperatorChatInstructionSkills(deps.skillsDir);
242
204
  sendJson(res, 200, {
243
205
  ok: true,
244
206
  schemaVersion: 1,
@@ -246,6 +208,18 @@ export async function handleChatCapabilities(_req, res, deps) {
246
208
  deniedTools: OPERATOR_CHAT_DENIED_TOOLS,
247
209
  deniedOperatorActions: OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS,
248
210
  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
+ },
249
223
  // ADR 0011: full Pi tools including bash are default-active.
250
224
  hasBash: true,
251
225
  });
@@ -302,7 +276,10 @@ export async function handleCreateChatSession(req, res, deps) {
302
276
  return;
303
277
  }
304
278
  try {
305
- const snapshot = snapshotInput === undefined ? undefined : projectCompactSnapshot(snapshotInput);
279
+ deps.runtime.setDisabledInstructionSkills(await readSkillPreferences(deps.appData));
280
+ const snapshot = snapshotInput === undefined
281
+ ? undefined
282
+ : projectCompactSnapshot(snapshotInput);
306
283
  const handle = await deps.runtime.createSession({
307
284
  ...(provider && modelId ? { model: { provider, modelId } } : {}),
308
285
  ...(snapshot
@@ -514,7 +491,6 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
514
491
  const lastEvent = snapshot.at(-1);
515
492
  sendJson(res, 200, {
516
493
  ok: true,
517
- eventEpoch: deps.events.epoch(sessionId),
518
494
  session: {
519
495
  ...record,
520
496
  composerDraft,
@@ -1049,31 +1025,23 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1049
1025
  }
1050
1026
  await deps.operationLinker.recoverSession(sessionId);
1051
1027
  openSseResponse(res);
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.
1028
+ // Resume from the client's last seen eventId (Last-Event-ID header, T04).
1054
1029
  const lastEventIdHeader = req.headers["last-event-id"];
1055
- const cursor = parseChatEventCursor(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
1056
- const replay = deps.events.listFrom(sessionId, cursor);
1057
- let lastSeq = cursor.seq;
1030
+ const afterSeq = parseLastEventId(Array.isArray(lastEventIdHeader) ? lastEventIdHeader[0] : lastEventIdHeader);
1031
+ const replay = deps.events.listFrom(sessionId, afterSeq);
1032
+ let lastSeq = afterSeq;
1058
1033
  if ("error" in replay) {
1059
1034
  // Cursor aged out of the bounded ring (T07 fence): tell the client to
1060
1035
  // 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);
1064
1036
  writeChatSse(res, {
1065
1037
  kind: "reconcile",
1066
1038
  sessionId,
1067
- reason: replay.error === "CURSOR_EPOCH_MISMATCH"
1068
- ? "epoch_mismatch"
1069
- : "cursor_expired",
1039
+ reason: "cursor_expired",
1070
1040
  minSeq: replay.minSeq,
1071
- epoch: replay.epoch,
1072
1041
  latestTurnId: deps.events.latestTurnId(sessionId),
1073
- latestEventId: retained.at(-1)?.eventId ?? null,
1074
1042
  });
1075
1043
  // Fall back to full retained snapshot so the client can re-render.
1076
- for (const ev of retained) {
1044
+ for (const ev of deps.events.snapshot(sessionId)) {
1077
1045
  writeChatSse(res, ev);
1078
1046
  lastSeq = Math.max(lastSeq, ev.seq);
1079
1047
  }
@@ -1084,15 +1052,6 @@ export async function handleChatEventsStream(req, res, deps, sessionId) {
1084
1052
  lastSeq = Math.max(lastSeq, ev.seq);
1085
1053
  }
1086
1054
  }
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
- });
1096
1055
  let closed = false;
1097
1056
  res.once("close", () => {
1098
1057
  closed = true;
@@ -1252,6 +1211,47 @@ async function handlePiConfig(req, res, deps, resource) {
1252
1211
  });
1253
1212
  return;
1254
1213
  }
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,148 +1279,94 @@ async function handleChatFiles(res, deps, sessionId, query) {
1279
1279
  .map((path) => ({ path }));
1280
1280
  sendJson(res, 200, { ok: true, files });
1281
1281
  }
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
- });
1282
+ async function handleRuntimeContext(res, deps, sessionId) {
1283
+ const record = await deps.store.get(sessionId);
1284
+ if (!record) {
1285
+ sendJson(res, 404, { ok: false });
1312
1286
  return;
1313
1287
  }
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 });
1314
1292
  sendJson(res, 200, {
1315
1293
  ok: true,
1316
1294
  context: projectRuntimeContext({
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,
1295
+ systemPrompt,
1296
+ skills: skills.loaded.map((skill) => ({
1297
+ name: skill.name,
1298
+ description: skill.description,
1299
+ charCount: skill.charCount,
1300
+ bodyText: skill.bodyText,
1341
1301
  })),
1342
- diagnostics: snapshot.diagnostics,
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,
1343
1308
  }),
1344
1309
  });
1345
1310
  }
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 } });
1311
+ async function handleModels(req, res, deps, sessionId) {
1312
+ if (!(await requireChatSession(res, deps, sessionId)))
1355
1313
  return;
1356
- }
1357
1314
  try {
1358
1315
  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
- }
1375
1316
  sendJson(res, 200, {
1376
1317
  ok: true,
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
- })),
1318
+ models: await deps.runtime.listModels(sessionId),
1383
1319
  thinkingLevels: THINKING_LEVELS,
1384
1320
  });
1385
1321
  return;
1386
1322
  }
1387
1323
  const gate = gateMutation(req, deps);
1388
1324
  if (!gate.ok) {
1389
- sendJson(res, gate.status, { ok: false, error: { code: gate.code, message: gate.message } });
1325
+ sendJson(res, gate.status, {
1326
+ ok: false,
1327
+ error: { code: gate.code, message: gate.message },
1328
+ });
1390
1329
  return;
1391
1330
  }
1392
1331
  const body = await readJsonBody(req);
1393
1332
  let updated;
1394
1333
  if (typeof body.provider === "string" && typeof body.modelId === "string") {
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 });
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
+ });
1397
1342
  }
1398
- else if (typeof body.thinkingLevel === "string" && (body.thinkingLevel === "auto" || THINKING_LEVELS.includes(body.thinkingLevel))) {
1343
+ else if (typeof body.thinkingLevel === "string" &&
1344
+ THINKING_LEVELS.includes(body.thinkingLevel)) {
1399
1345
  const thinkingLevel = deps.runtime.applyThinkingLevel(sessionId, body.thinkingLevel);
1400
- updated = await deps.store.setRuntimeSelection(sessionId, thinkingLevel === "auto" ? {} : { thinkingLevel });
1346
+ updated = await deps.store.setRuntimeSelection(sessionId, {
1347
+ thinkingLevel,
1348
+ });
1401
1349
  }
1402
1350
  else {
1403
- sendJson(res, 400, { ok: false, error: { code: "INVALID_INPUT", message: "provider/modelId or thinkingLevel is required" } });
1404
- return;
1405
- }
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 });
1414
- }
1415
- catch (error) {
1416
- if (error instanceof RuntimeSnapshotUnavailableError) {
1417
- sendJson(res, 503, {
1351
+ sendJson(res, 400, {
1418
1352
  ok: false,
1419
- error: { code: error.code, message: error.message },
1353
+ error: {
1354
+ code: "INVALID_INPUT",
1355
+ message: "provider/modelId or thinkingLevel is required",
1356
+ },
1420
1357
  });
1421
1358
  return;
1422
1359
  }
1423
- sendJson(res, 409, { ok: false, error: { code: "MODEL_SWITCH_FAILED", message: error instanceof Error ? error.message : String(error) } });
1360
+ sendJson(res, 200, { ok: true, session: updated });
1361
+ }
1362
+ 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
+ });
1424
1370
  }
1425
1371
  }
1426
1372
  async function requireChatSession(res, deps, sessionId) {
@@ -1435,132 +1381,6 @@ async function requireChatSession(res, deps, sessionId) {
1435
1381
  });
1436
1382
  return false;
1437
1383
  }
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
- }
1564
1384
  async function handleLinkWorkspaceOperation(req, res, deps, sessionId) {
1565
1385
  const gate = gateMutation(req, deps);
1566
1386
  if (!gate.ok) {
@@ -2333,6 +2153,16 @@ export async function handleChatRequest(req, res, deps, pathname) {
2333
2153
  await handlePiConfig(req, res, deps, "packages");
2334
2154
  return true;
2335
2155
  }
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
+ }
2336
2166
  if (method === "GET" && pathname === "/api/operator/v1/chat/capabilities") {
2337
2167
  await handleChatCapabilities(req, res, deps);
2338
2168
  return true;
@@ -2391,7 +2221,7 @@ export async function handleChatRequest(req, res, deps, pathname) {
2391
2221
  await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
2392
2222
  return true;
2393
2223
  }
2394
- const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
2224
+ const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|models|model)$/);
2395
2225
  if (m4Route) {
2396
2226
  const id = decodeURIComponent(m4Route[1]);
2397
2227
  const route = m4Route[2];
@@ -2407,10 +2237,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
2407
2237
  await handleRuntimeContext(res, deps, id);
2408
2238
  return true;
2409
2239
  }
2410
- if (route === "runtime-snapshot" && method === "GET") {
2411
- await handleRuntimeSnapshot(req, res, deps, id);
2412
- return true;
2413
- }
2414
2240
  if (route === "models" && method === "GET") {
2415
2241
  await handleModels(req, res, deps, id);
2416
2242
  return true;
@@ -2419,10 +2245,6 @@ export async function handleChatRequest(req, res, deps, pathname) {
2419
2245
  await handleModels(req, res, deps, id);
2420
2246
  return true;
2421
2247
  }
2422
- if (route === "reload" && method === "POST") {
2423
- await handleReloadSession(req, res, deps, id);
2424
- return true;
2425
- }
2426
2248
  }
2427
2249
  const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
2428
2250
  if (method === "POST" && sessionMainline) {