@tea-agent/loop-agent 0.34.3 → 0.34.5

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 (47) hide show
  1. package/AGENTS.md +8 -3
  2. package/CHANGELOG.md +66 -22
  3. package/README.md +2 -2
  4. package/dist/shared/operator/capabilities.js +0 -7
  5. package/dist/worker/cli.js +21 -27
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/chat-event-store.js +134 -4
  8. package/dist/worker/console/chat/pi-runtime.js +308 -63
  9. package/dist/worker/console/chat/resource-preferences-store.js +152 -0
  10. package/dist/worker/console/chat/routes.js +425 -19
  11. package/dist/worker/console/chat/shortcuts.js +208 -9
  12. package/dist/worker/console/chat/tool-preview.js +162 -5
  13. package/dist/worker/console/chat/turn-execution-registry.js +82 -0
  14. package/dist/worker/console/dag-execution-receipt.js +14 -1
  15. package/dist/worker/console/doctor.js +1 -1
  16. package/dist/worker/console/index.js +1 -0
  17. package/dist/worker/console/open-browser.js +134 -0
  18. package/dist/worker/console/recovery-cta.js +1 -1
  19. package/dist/worker/console/server.js +5 -1
  20. package/dist/worker/console/static/assets/index-qpkysQYW.css +1 -0
  21. package/dist/worker/console/static/assets/index-y980PqtP.js +56 -0
  22. package/dist/worker/console/static/index.html +2 -2
  23. package/dist/worker/console/static-src/chat-markdown-security.js +38 -0
  24. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +1 -3
  25. package/dist/worker/console/static-src/operator-chat/format.js +2 -2
  26. package/dist/worker/console/static-src/operator-chat/refs.js +24 -0
  27. package/dist/worker/console/static-src/operator-chat/resource-auto-invocation.js +91 -0
  28. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +690 -0
  29. package/dist/worker/console/static-src/operator-chat/turn-submission.js +158 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatStream.js +535 -86
  31. package/dist/worker/console/static-src/operator-chat/useChatThread.js +11 -5
  32. package/dist/worker/console/static-src/operator-chat/useComposer.js +81 -3
  33. package/dist/workflows/dag/init-hybrid.js +2 -0
  34. package/docs/architecture/evolution.md +1 -1
  35. package/docs/architecture/system-overview.md +1 -1
  36. package/docs/architecture/worker-and-feature.md +1 -1
  37. package/docs/operations/local-development-environment.md +4 -2
  38. package/docs/templates/branch-merge-report.md +9 -0
  39. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  40. package/docs/templates/evaluation/agents-map-verbose-v0.md +2 -2
  41. package/docs/templates/init-managed-agents.md +2 -2
  42. package/package.json +6 -2
  43. package/skills/agent-worker/SKILL.md +1 -1
  44. package/skills/agent-worker/references/agent-worker-operator.md +2 -2
  45. package/skills/loop-agent/references/command-reference.md +2 -3
  46. package/dist/worker/console/static/assets/index-BQkhJpV8.css +0 -1
  47. package/dist/worker/console/static/assets/index-BpuHmlSP.js +0 -29
@@ -15,8 +15,9 @@
15
15
  import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_DENIED_OPERATOR_ACTIONS, } from "./tools.js";
16
16
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
17
17
  import { cleanChatTitle } from "./session-store.js";
18
- import { projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
18
+ import { hashChatTurnPayload, projectChatToolEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
19
19
  import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
20
+ import { ResourcePreferencesError, setSkillAutoInvocationPreference, } from "./resource-preferences-store.js";
20
21
  import { ComposerDraftStore } from "./composer-draft-store.js";
21
22
  import { walkRepoFiles } from "./repo-walk.js";
22
23
  import { isSensitivePath, scrubSecrets } from "./explore-tools.js";
@@ -33,6 +34,23 @@ import { projectTaskContext } from "./context-panel.js";
33
34
  import { contractApplyPayloadHash, issueHumanGateToken, verifyHumanGateToken, } from "../human-gate-token.js";
34
35
  import { sendJson } from "../routes.js";
35
36
  import { openSseResponse, writeSseEvent } from "../operation-sse.js";
37
+ function turnSummary(turn) {
38
+ return {
39
+ turnId: turn.turnId,
40
+ clientRequestId: turn.clientRequestId ?? null,
41
+ state: turn.state,
42
+ ordinal: turn.ordinal,
43
+ createdAt: turn.createdAt,
44
+ startedAt: turn.startedAt ?? null,
45
+ finishedAt: turn.finishedAt ?? null,
46
+ error: turn.error ?? null,
47
+ abortRequestedAt: turn.abortRequestedAt ?? null,
48
+ };
49
+ }
50
+ function isTerminalTurnState(state) {
51
+ return state === "settled" || state === "aborted" || state === "failed";
52
+ }
53
+ const DEFAULT_ABORT_WAIT_MS = 8_000;
36
54
  /** Parse Chat's `<epoch>:<seq>` SSE cursor. Legacy `<sessionId>:<seq>` has
37
55
  * the same wire shape; its prefix intentionally fails the current process
38
56
  * epoch comparison and triggers safe snapshot recovery (M5.1). */
@@ -648,13 +666,25 @@ export async function handleGetChatSessionState(_req, res, deps, sessionId) {
648
666
  }
649
667
  const active = deps.events.getActiveTurn(sessionId);
650
668
  const latest = deps.events.latestTurn(sessionId);
669
+ const runtimeFacts = typeof deps.runtime.getSessionBusyFacts === "function"
670
+ ? deps.runtime.getSessionBusyFacts(sessionId)
671
+ : {
672
+ isStreaming: false,
673
+ isPromptRunning: false,
674
+ isCompacting: false,
675
+ isBashRunning: false,
676
+ };
651
677
  sendJson(res, 200, {
652
678
  ok: true,
653
679
  state: {
680
+ // Legacy flat fields retained for older clients.
654
681
  activeTurnId: active?.turnId ?? null,
655
682
  activeTurnState: active?.state ?? null,
656
683
  latestTurnId: latest?.turnId ?? null,
657
684
  latestTurnState: latest?.state ?? null,
685
+ activeTurn: active ? turnSummary(active) : null,
686
+ latestTurn: latest ? turnSummary(latest) : null,
687
+ runtime: runtimeFacts,
658
688
  },
659
689
  });
660
690
  }
@@ -862,12 +892,25 @@ export async function handleChatPrompt(req, res, deps, sessionId) {
862
892
  // prompt SSE route uses the same durable Turn resource as POST /turns.
863
893
  const createdTurn = deps.events.createTurn(sessionId);
864
894
  if (!createdTurn.ok) {
895
+ if (createdTurn.code === "REQUEST_ID_REUSE_CONFLICT") {
896
+ sendJson(res, 409, {
897
+ ok: false,
898
+ accepted: false,
899
+ error: {
900
+ code: createdTurn.code,
901
+ message: createdTurn.message,
902
+ },
903
+ });
904
+ return;
905
+ }
865
906
  sendJson(res, 409, {
866
907
  ok: false,
908
+ accepted: false,
867
909
  error: {
868
910
  code: "TURN_ACTIVE",
869
- message: `chat turn already active: ${createdTurn.activeTurn.turnId}`,
911
+ message: "上一轮仍在运行,已返回当前 activeTurn;请重新连接或稍后再试。",
870
912
  },
913
+ activeTurn: turnSummary(createdTurn.activeTurn),
871
914
  });
872
915
  return;
873
916
  }
@@ -1009,6 +1052,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1009
1052
  if (!gate.ok) {
1010
1053
  sendJson(res, gate.status, {
1011
1054
  ok: false,
1055
+ accepted: false,
1012
1056
  error: { code: gate.code, message: gate.message },
1013
1057
  });
1014
1058
  return;
@@ -1020,6 +1064,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1020
1064
  catch (error) {
1021
1065
  sendJson(res, 400, {
1022
1066
  ok: false,
1067
+ accepted: false,
1023
1068
  error: {
1024
1069
  code: "INVALID_INPUT",
1025
1070
  message: error instanceof Error ? error.message : String(error),
@@ -1028,6 +1073,9 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1028
1073
  return;
1029
1074
  }
1030
1075
  const text = typeof body.text === "string" ? body.text.trim() : "";
1076
+ const clientRequestId = typeof body.clientRequestId === "string" && body.clientRequestId.trim()
1077
+ ? body.clientRequestId.trim()
1078
+ : undefined;
1031
1079
  let images;
1032
1080
  try {
1033
1081
  images = parseChatImages(body.images);
@@ -1035,6 +1083,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1035
1083
  catch (error) {
1036
1084
  sendJson(res, 400, {
1037
1085
  ok: false,
1086
+ accepted: false,
1038
1087
  error: {
1039
1088
  code: "INVALID_ATTACHMENT",
1040
1089
  message: error instanceof Error ? error.message : String(error),
@@ -1045,6 +1094,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1045
1094
  if (!text) {
1046
1095
  sendJson(res, 400, {
1047
1096
  ok: false,
1097
+ accepted: false,
1048
1098
  error: { code: "INVALID_INPUT", message: "text is required" },
1049
1099
  });
1050
1100
  return;
@@ -1054,6 +1104,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1054
1104
  if (!record) {
1055
1105
  sendJson(res, 404, {
1056
1106
  ok: false,
1107
+ accepted: false,
1057
1108
  error: {
1058
1109
  code: "NOT_FOUND",
1059
1110
  message: `chat session not found: ${sessionId}`,
@@ -1075,6 +1126,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1075
1126
  });
1076
1127
  sendJson(res, mapped.status, {
1077
1128
  ok: false,
1129
+ accepted: false,
1078
1130
  error: { code: mapped.code, message: mapped.message },
1079
1131
  });
1080
1132
  return;
@@ -1084,6 +1136,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1084
1136
  if (!actionContext) {
1085
1137
  sendJson(res, 503, {
1086
1138
  ok: false,
1139
+ accepted: false,
1087
1140
  error: {
1088
1141
  code: "CHAT_NO_ACTION_CONTEXT",
1089
1142
  message: "Chat runtime has no operator action context wired",
@@ -1091,30 +1144,78 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1091
1144
  });
1092
1145
  return;
1093
1146
  }
1094
- const created = deps.events.createTurn(sessionId);
1147
+ const payloadHash = hashChatTurnPayload({ text, images });
1148
+ const created = deps.events.createTurn(sessionId, {
1149
+ clientRequestId,
1150
+ payloadHash,
1151
+ });
1095
1152
  if (!created.ok) {
1153
+ if (created.code === "REQUEST_ID_REUSE_CONFLICT") {
1154
+ sendJson(res, 409, {
1155
+ ok: false,
1156
+ accepted: false,
1157
+ error: {
1158
+ code: created.code,
1159
+ message: created.message,
1160
+ },
1161
+ });
1162
+ return;
1163
+ }
1096
1164
  sendJson(res, 409, {
1097
1165
  ok: false,
1166
+ accepted: false,
1098
1167
  error: {
1099
1168
  code: "TURN_ACTIVE",
1100
- message: `chat turn already active: ${created.activeTurn.turnId}`,
1169
+ message: "上一轮仍在运行,已返回当前 activeTurn;请重新连接或稍后再试。",
1101
1170
  },
1171
+ activeTurn: turnSummary(created.activeTurn),
1102
1172
  });
1103
1173
  return;
1104
1174
  }
1105
1175
  const turn = created.turn;
1176
+ // Idempotent retry of an already-accepted request: return the same Turn and
1177
+ // never re-run the prompt (even if it already finished).
1178
+ if (created.idempotent) {
1179
+ sendJson(res, 202, {
1180
+ ok: true,
1181
+ accepted: true,
1182
+ idempotent: true,
1183
+ turnId: turn.turnId,
1184
+ ordinal: turn.ordinal,
1185
+ state: turn.state,
1186
+ turn: turnSummary(turn),
1187
+ });
1188
+ return;
1189
+ }
1190
+ const controller = new AbortController();
1191
+ const execution = deps.turnExecutions?.register({
1192
+ sessionId,
1193
+ turnId: turn.turnId,
1194
+ clientRequestId,
1195
+ controller,
1196
+ });
1106
1197
  deps.events.setTurnState(sessionId, turn.turnId, "running");
1198
+ const running = deps.events.getTurn(sessionId, turn.turnId) ?? {
1199
+ ...turn,
1200
+ state: "running",
1201
+ };
1107
1202
  sendJson(res, 202, {
1108
1203
  ok: true,
1109
- turnId: turn.turnId,
1110
- ordinal: turn.ordinal,
1111
- state: "running",
1204
+ accepted: true,
1205
+ idempotent: false,
1206
+ turnId: running.turnId,
1207
+ ordinal: running.ordinal,
1208
+ state: running.state,
1209
+ turn: turnSummary(running),
1112
1210
  });
1113
1211
  // Deliberately detached from the request lifecycle: closing the POST
1114
1212
  // response cannot abort the prompt; clients follow GET /events instead.
1213
+ // Abort ownership lives in the execution registry (real Stop path).
1115
1214
  void (async () => {
1116
1215
  const { runChatTurn } = await import("./session-store.js");
1117
1216
  let firstUserText;
1217
+ let terminalState = "failed";
1218
+ let terminalError;
1118
1219
  try {
1119
1220
  const outcome = await runChatTurn({
1120
1221
  runtime: deps.runtime,
@@ -1123,6 +1224,7 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1123
1224
  sessionId,
1124
1225
  text,
1125
1226
  images,
1227
+ signal: controller.signal,
1126
1228
  onUserMessagePersisted: (record) => {
1127
1229
  const users = record.messages.filter((message) => message.role === "user");
1128
1230
  if (users.length !== 1)
@@ -1147,24 +1249,142 @@ export async function handleCreateChatTurn(req, res, deps, sessionId) {
1147
1249
  },
1148
1250
  });
1149
1251
  ensureAgentEndEvent(deps, sessionId, turn.turnId);
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 });
1252
+ if (controller.signal.aborted) {
1253
+ terminalState = "aborted";
1254
+ terminalError = {
1255
+ code: "CLIENT_ABORTED",
1256
+ message: "turn aborted by client",
1257
+ };
1258
+ deps.events.setTurnState(sessionId, turn.turnId, "aborted", terminalError);
1259
+ }
1260
+ else if (outcome.ok) {
1261
+ terminalState = "settled";
1262
+ deps.events.setTurnState(sessionId, turn.turnId, "settled");
1263
+ if (firstUserText)
1264
+ scheduleAutomaticTitle(deps, {
1265
+ sessionId,
1266
+ turnId: turn.turnId,
1267
+ firstUserText,
1268
+ });
1269
+ }
1270
+ else {
1271
+ terminalState = "failed";
1272
+ terminalError = outcome.error;
1273
+ deps.events.setTurnState(sessionId, turn.turnId, "failed", outcome.error);
1274
+ }
1153
1275
  }
1154
1276
  catch (error) {
1155
1277
  const message = error instanceof Error ? error.message : String(error);
1156
- deps.events.append(sessionId, turn.turnId, {
1157
- kind: "error",
1158
- data: { message },
1159
- });
1160
- ensureAgentEndEvent(deps, sessionId, turn.turnId);
1161
- deps.events.setTurnState(sessionId, turn.turnId, "failed", {
1162
- code: "CHAT_TURN_FAILED",
1163
- message,
1278
+ if (controller.signal.aborted) {
1279
+ terminalState = "aborted";
1280
+ terminalError = {
1281
+ code: "CLIENT_ABORTED",
1282
+ message: "turn aborted by client",
1283
+ };
1284
+ ensureAgentEndEvent(deps, sessionId, turn.turnId);
1285
+ deps.events.setTurnState(sessionId, turn.turnId, "aborted", terminalError);
1286
+ }
1287
+ else {
1288
+ terminalState = "failed";
1289
+ terminalError = { code: "CHAT_TURN_FAILED", message };
1290
+ deps.events.append(sessionId, turn.turnId, {
1291
+ kind: "error",
1292
+ data: { message },
1293
+ });
1294
+ ensureAgentEndEvent(deps, sessionId, turn.turnId);
1295
+ deps.events.setTurnState(sessionId, turn.turnId, "failed", terminalError);
1296
+ }
1297
+ }
1298
+ finally {
1299
+ execution?.resolveCompletion({
1300
+ ok: terminalState === "settled",
1301
+ state: terminalState,
1302
+ error: terminalError,
1164
1303
  });
1304
+ deps.turnExecutions?.clear(sessionId, turn.turnId);
1165
1305
  }
1166
1306
  })();
1167
1307
  }
1308
+ /** Real server-side abort: invokes Pi session.abort via the execution registry. */
1309
+ export async function handleAbortChatTurn(req, res, deps, sessionId, turnId) {
1310
+ const gate = gateMutation(req, deps);
1311
+ if (!gate.ok) {
1312
+ sendJson(res, gate.status, {
1313
+ ok: false,
1314
+ accepted: false,
1315
+ error: { code: gate.code, message: gate.message },
1316
+ });
1317
+ return;
1318
+ }
1319
+ const turn = deps.events.getTurn(sessionId, turnId);
1320
+ if (!turn) {
1321
+ sendJson(res, 404, {
1322
+ ok: false,
1323
+ accepted: false,
1324
+ error: {
1325
+ code: "NOT_FOUND",
1326
+ message: `chat turn not found: ${turnId}`,
1327
+ },
1328
+ });
1329
+ return;
1330
+ }
1331
+ if (isTerminalTurnState(turn.state)) {
1332
+ sendJson(res, 200, {
1333
+ ok: true,
1334
+ accepted: true,
1335
+ idempotent: true,
1336
+ turn: turnSummary(turn),
1337
+ });
1338
+ return;
1339
+ }
1340
+ const execution = deps.turnExecutions?.get(sessionId, turnId);
1341
+ if (!execution) {
1342
+ // Active durable Turn without process ownership (restart orphan / detached).
1343
+ const closed = deps.events.setTurnState(sessionId, turnId, "failed", {
1344
+ code: "CHAT_TURN_OWNER_LOST",
1345
+ message: "turn ownership lost; cannot abort a turn this process does not own",
1346
+ });
1347
+ sendJson(res, 409, {
1348
+ ok: false,
1349
+ accepted: false,
1350
+ error: {
1351
+ code: "CHAT_TURN_OWNER_LOST",
1352
+ message: "turn ownership lost; cannot abort a turn this process does not own",
1353
+ },
1354
+ turn: closed ? turnSummary(closed) : turnSummary(turn),
1355
+ });
1356
+ return;
1357
+ }
1358
+ deps.events.markAbortRequested?.(sessionId, turnId);
1359
+ if (!execution.controller.signal.aborted)
1360
+ execution.controller.abort();
1361
+ const waited = await (deps.turnExecutions?.waitForCompletion(sessionId, turnId, DEFAULT_ABORT_WAIT_MS) ?? Promise.resolve({ status: "missing" }));
1362
+ if (waited.status === "completed") {
1363
+ const finalTurn = deps.events.getTurn(sessionId, turnId) ??
1364
+ {
1365
+ ...turn,
1366
+ state: waited.outcome.state,
1367
+ };
1368
+ sendJson(res, 200, {
1369
+ ok: true,
1370
+ accepted: true,
1371
+ idempotent: false,
1372
+ turn: turnSummary(finalTurn),
1373
+ });
1374
+ return;
1375
+ }
1376
+ // Timeout or missing completion: keep non-terminal; client reconciles via /state.
1377
+ const pending = deps.events.getTurn(sessionId, turnId) ?? turn;
1378
+ sendJson(res, 202, {
1379
+ ok: false,
1380
+ accepted: false,
1381
+ error: {
1382
+ code: "TURN_ABORT_PENDING",
1383
+ message: "abort requested; turn has not yet reached a terminal state",
1384
+ },
1385
+ turn: turnSummary(pending),
1386
+ });
1387
+ }
1168
1388
  export async function handleGetChatTurn(_req, res, deps, sessionId, turnId) {
1169
1389
  const turn = deps.events.getTurn(sessionId, turnId);
1170
1390
  if (!turn) {
@@ -1423,6 +1643,25 @@ async function handleChatFiles(res, deps, sessionId, query) {
1423
1643
  .map((path) => ({ path }));
1424
1644
  sendJson(res, 200, { ok: true, files });
1425
1645
  }
1646
+ export async function handleChatCommands(res, deps, sessionId) {
1647
+ try {
1648
+ const commands = typeof deps.runtime.listSlashCommands === "function"
1649
+ ? await deps.runtime.listSlashCommands(sessionId)
1650
+ : [];
1651
+ res.statusCode = 200;
1652
+ res.setHeader("content-type", "application/json; charset=utf-8");
1653
+ res.end(JSON.stringify({ ok: true, commands }));
1654
+ }
1655
+ catch (error) {
1656
+ res.statusCode = 200;
1657
+ res.setHeader("content-type", "application/json; charset=utf-8");
1658
+ res.end(JSON.stringify({
1659
+ ok: true,
1660
+ commands: [],
1661
+ warning: error instanceof Error ? error.message : "commands unavailable",
1662
+ }));
1663
+ }
1664
+ }
1426
1665
  export async function handleRuntimeContext(res, deps, sessionId) {
1427
1666
  const ensured = await ensureActiveSession(deps, sessionId);
1428
1667
  if (!ensured.ok) {
@@ -1636,6 +1875,159 @@ export async function ensureActiveSession(deps, sessionId) {
1636
1875
  }
1637
1876
  return { ok: true };
1638
1877
  }
1878
+ /**
1879
+ * PATCH /sessions/:id/skills/:resourceId/auto-invocation
1880
+ * Persist repo-scoped preference, then try immediate reloadSession when idle.
1881
+ * Busy sessions keep preferenceSaved=true and reloadApplied=false (no abort).
1882
+ */
1883
+ export async function handleSkillAutoInvocation(req, res, deps, sessionId, resourceId) {
1884
+ const gate = gateMutation(req, deps);
1885
+ if (!gate.ok) {
1886
+ sendJson(res, gate.status, {
1887
+ ok: false,
1888
+ error: { code: gate.code, message: gate.message },
1889
+ });
1890
+ return;
1891
+ }
1892
+ const ensured = await ensureActiveSession(deps, sessionId);
1893
+ if (!ensured.ok) {
1894
+ sendJson(res, ensured.status, {
1895
+ ok: false,
1896
+ error: { code: ensured.code, message: ensured.message },
1897
+ });
1898
+ return;
1899
+ }
1900
+ const id = resourceId.trim();
1901
+ if (!id) {
1902
+ sendJson(res, 400, {
1903
+ ok: false,
1904
+ error: {
1905
+ code: "INVALID_INPUT",
1906
+ message: "resourceId is required",
1907
+ },
1908
+ });
1909
+ return;
1910
+ }
1911
+ let body;
1912
+ try {
1913
+ body = await readJsonBody(req);
1914
+ }
1915
+ catch (error) {
1916
+ sendJson(res, 400, {
1917
+ ok: false,
1918
+ error: {
1919
+ code: "INVALID_INPUT",
1920
+ message: error instanceof Error ? error.message : String(error),
1921
+ },
1922
+ });
1923
+ return;
1924
+ }
1925
+ if (typeof body.autoInvocationEnabled !== "boolean") {
1926
+ sendJson(res, 400, {
1927
+ ok: false,
1928
+ error: {
1929
+ code: "INVALID_INPUT",
1930
+ message: "autoInvocationEnabled boolean is required",
1931
+ },
1932
+ });
1933
+ return;
1934
+ }
1935
+ const found = await deps.runtime.findSkillByResourceId(sessionId, id);
1936
+ if (!found) {
1937
+ sendJson(res, 404, {
1938
+ ok: false,
1939
+ error: {
1940
+ code: "PI_SKILL_NOT_FOUND",
1941
+ message: `skill resourceId not in current session inventory: ${id}`,
1942
+ },
1943
+ });
1944
+ return;
1945
+ }
1946
+ const prefsPath = deps.appData.resourcePreferences;
1947
+ if (!prefsPath) {
1948
+ sendJson(res, 500, {
1949
+ ok: false,
1950
+ error: {
1951
+ code: "PI_RESOURCE_PREFERENCES_WRITE_FAILED",
1952
+ message: "resourcePreferences path is not configured",
1953
+ },
1954
+ });
1955
+ return;
1956
+ }
1957
+ try {
1958
+ await setSkillAutoInvocationPreference({
1959
+ filePath: prefsPath,
1960
+ resourceId: id,
1961
+ autoInvocationEnabled: body.autoInvocationEnabled,
1962
+ });
1963
+ }
1964
+ catch (error) {
1965
+ if (error instanceof ResourcePreferencesError) {
1966
+ const status = error.code === "PI_RESOURCE_PREFERENCES_MALFORMED" ? 500 : 500;
1967
+ sendJson(res, status, {
1968
+ ok: false,
1969
+ error: { code: error.code, message: error.message },
1970
+ });
1971
+ return;
1972
+ }
1973
+ sendJson(res, 500, {
1974
+ ok: false,
1975
+ error: {
1976
+ code: "PI_RESOURCE_PREFERENCES_WRITE_FAILED",
1977
+ message: error instanceof Error ? error.message : String(error),
1978
+ },
1979
+ });
1980
+ return;
1981
+ }
1982
+ const reload = await deps.runtime.reloadSession(sessionId);
1983
+ if (!reload.ok) {
1984
+ if (reload.code === "PI_SESSION_BUSY") {
1985
+ // Preference is durable; do not abort the busy turn or pretend reload applied.
1986
+ sendJson(res, 200, {
1987
+ ok: true,
1988
+ preferenceSaved: true,
1989
+ reloadApplied: false,
1990
+ error: { code: reload.code, message: reload.message },
1991
+ });
1992
+ return;
1993
+ }
1994
+ sendJson(res, reload.code === "PI_SESSION_NOT_FOUND" ? 404 : 500, {
1995
+ ok: false,
1996
+ preferenceSaved: true,
1997
+ reloadApplied: false,
1998
+ error: { code: reload.code, message: reload.message },
1999
+ });
2000
+ return;
2001
+ }
2002
+ let snapshot;
2003
+ try {
2004
+ snapshot = await deps.runtime.getRuntimeSnapshot(sessionId);
2005
+ }
2006
+ catch (error) {
2007
+ if (sendRuntimeUnavailable(res, error))
2008
+ return;
2009
+ throw error;
2010
+ }
2011
+ if (!snapshot) {
2012
+ sendJson(res, 503, {
2013
+ ok: false,
2014
+ preferenceSaved: true,
2015
+ reloadApplied: true,
2016
+ error: {
2017
+ code: "PI_RUNTIME_UNAVAILABLE",
2018
+ message: `chat session not active after reload: ${sessionId}`,
2019
+ },
2020
+ });
2021
+ return;
2022
+ }
2023
+ sendJson(res, 200, {
2024
+ ok: true,
2025
+ preferenceSaved: true,
2026
+ reloadApplied: true,
2027
+ revision: reload.revision,
2028
+ snapshot,
2029
+ });
2030
+ }
1639
2031
  /**
1640
2032
  * POST /sessions/:id/reload (AC-05 / AC-06): official session.reload() with
1641
2033
  * structured busy/failure semantics. Never aborts, never silently queues.
@@ -2551,7 +2943,12 @@ export async function handleChatRequest(req, res, deps, pathname) {
2551
2943
  await handleMutationHumanGate(req, res, deps, decodeURIComponent(mutationHumanGate[1]));
2552
2944
  return true;
2553
2945
  }
2554
- const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload)$/);
2946
+ const skillAutoInvocation = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/skills\/([^/]+)\/auto-invocation$/);
2947
+ if (method === "PATCH" && skillAutoInvocation) {
2948
+ await handleSkillAutoInvocation(req, res, deps, decodeURIComponent(skillAutoInvocation[1]), decodeURIComponent(skillAutoInvocation[2]));
2949
+ return true;
2950
+ }
2951
+ const m4Route = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/(draft|files|runtime-context|runtime-snapshot|models|model|reload|commands)$/);
2555
2952
  if (m4Route) {
2556
2953
  const id = decodeURIComponent(m4Route[1]);
2557
2954
  const route = m4Route[2];
@@ -2583,6 +2980,10 @@ export async function handleChatRequest(req, res, deps, pathname) {
2583
2980
  await handleReloadSession(req, res, deps, id);
2584
2981
  return true;
2585
2982
  }
2983
+ if (route === "commands" && method === "GET") {
2984
+ await handleChatCommands(res, deps, id);
2985
+ return true;
2986
+ }
2586
2987
  }
2587
2988
  const sessionMainline = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/branch\/mainline$/);
2588
2989
  if (method === "POST" && sessionMainline) {
@@ -2609,6 +3010,11 @@ export async function handleChatRequest(req, res, deps, pathname) {
2609
3010
  await handleCreateChatTurn(req, res, deps, decodeURIComponent(sessionTurn[1]));
2610
3011
  return true;
2611
3012
  }
3013
+ const sessionTurnAbort = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)\/abort$/);
3014
+ if (method === "POST" && sessionTurnAbort) {
3015
+ await handleAbortChatTurn(req, res, deps, decodeURIComponent(sessionTurnAbort[1]), decodeURIComponent(sessionTurnAbort[2]));
3016
+ return true;
3017
+ }
2612
3018
  const sessionTurnDetail = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/turns\/([^/]+)$/);
2613
3019
  if (method === "GET" && sessionTurnDetail) {
2614
3020
  await handleGetChatTurn(req, res, deps, decodeURIComponent(sessionTurnDetail[1]), decodeURIComponent(sessionTurnDetail[2]));