@tea-agent/loop-agent 0.23.1 → 0.24.0

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 (53) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/bin/agent-worker.js +0 -0
  4. package/dist/executors/shell-executor.js +20 -7
  5. package/dist/shared/operator/capabilities.js +475 -2
  6. package/dist/worker/console/app-data.js +2 -0
  7. package/dist/worker/console/chat/artifact-card.js +23 -0
  8. package/dist/worker/console/chat/chat-event-store.js +495 -0
  9. package/dist/worker/console/chat/chat-ui-policy.js +25 -0
  10. package/dist/worker/console/chat/composer-draft-store.js +45 -0
  11. package/dist/worker/console/chat/context-panel.js +54 -0
  12. package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
  13. package/dist/worker/console/chat/explore-tools.js +299 -0
  14. package/dist/worker/console/chat/human-gate-card.js +37 -0
  15. package/dist/worker/console/chat/interview-adapter.js +136 -0
  16. package/dist/worker/console/chat/operation-card.js +23 -0
  17. package/dist/worker/console/chat/pi-console-config.js +158 -0
  18. package/dist/worker/console/chat/pi-runtime.js +581 -43
  19. package/dist/worker/console/chat/repo-browser.js +140 -0
  20. package/dist/worker/console/chat/repo-walk.js +116 -0
  21. package/dist/worker/console/chat/resource-loader.js +18 -17
  22. package/dist/worker/console/chat/routes.js +1354 -65
  23. package/dist/worker/console/chat/runtime-context.js +24 -0
  24. package/dist/worker/console/chat/runtime-selection.js +37 -0
  25. package/dist/worker/console/chat/session-store.js +210 -11
  26. package/dist/worker/console/chat/shortcuts.js +15 -0
  27. package/dist/worker/console/chat/tool-adapter.js +81 -194
  28. package/dist/worker/console/chat/tools.js +72 -48
  29. package/dist/worker/console/chat/usage.js +37 -0
  30. package/dist/worker/console/chat/workspace-landing.js +56 -0
  31. package/dist/worker/console/dag-confirmation.js +42 -8
  32. package/dist/worker/console/human-gate-token.js +130 -0
  33. package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
  34. package/dist/worker/console/operation-runner.js +6 -2
  35. package/dist/worker/console/operation-sse.js +26 -0
  36. package/dist/worker/console/operator-actions.js +420 -7
  37. package/dist/worker/console/server.js +14 -2
  38. package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
  39. package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
  40. package/dist/worker/console/static/index.html +2 -2
  41. package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
  42. package/dist/workflows/dag/backend-test-result-contract.js +229 -0
  43. package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
  44. package/dist/workflows/dag/init-hybrid.js +2 -1
  45. package/docs/README.md +1 -1
  46. package/docs/architecture/README.md +5 -5
  47. package/docs/architecture/evolution.md +4 -4
  48. package/docs/architecture/worker-and-feature.md +1 -1
  49. package/docs/templates/backend-test-dag.json +2 -2
  50. package/harness.json +1 -1
  51. package/package.json +1 -1
  52. package/dist/worker/console/static/assets/index-DVl7Jxt5.js +0 -25
  53. package/dist/worker/console/static/assets/index-lVcIr9Ju.css +0 -1
@@ -16,6 +16,13 @@ import { toCanonicalDraftForCli, } from "./draft-store.js";
16
16
  import { normalizeConsoleWorkflowKind } from "./workflow-kinds.js";
17
17
  import { deriveTaskIdentityFromPrd, nextTaskIdRevision, } from "./prd-identity.js";
18
18
  import { resolveSiblingAgentWorkerBin } from "./sibling-controller.js";
19
+ import { projectOperationEventSummary, projectOperationForChat, } from "./chat/chat-event-store.js";
20
+ import { MutationGateReceiptStore } from "./mutation-gate-receipt-store.js";
21
+ import { issueHumanGateToken, mutationGatePayloadHash, verifyHumanGateToken, } from "./human-gate-token.js";
22
+ const MUTATION_GATE_ACTIONS = new Set([
23
+ "standaloneTaskRerun",
24
+ "workerTaskRetry",
25
+ ]);
19
26
  const MUTATION_OR_LONG = new Set([
20
27
  "newTask",
21
28
  "importPrd",
@@ -192,6 +199,83 @@ export async function dispatchOperatorAction(ctx, req) {
192
199
  body: operatorSucceeded("operator capabilities", doc),
193
200
  };
194
201
  }
202
+ case "operationGet": {
203
+ const operationId = str(p.operationId);
204
+ if (!operationId)
205
+ return invalid(action, "operationId is required");
206
+ const operation = await ctx.operations.get(operationId);
207
+ if (!operation) {
208
+ return {
209
+ kind: "error",
210
+ status: 404,
211
+ body: operatorFailed({
212
+ command: action,
213
+ outcome: "not-found",
214
+ code: "NOT_FOUND",
215
+ message: `operation not found: ${operationId}`,
216
+ }),
217
+ };
218
+ }
219
+ return {
220
+ kind: "sync",
221
+ status: 200,
222
+ body: operatorSucceeded(action, projectOperationForChat(operation)),
223
+ };
224
+ }
225
+ case "operationList": {
226
+ const rawLimit = typeof p.limit === "number" ? p.limit : Number(p.limit ?? 20);
227
+ if (!Number.isFinite(rawLimit))
228
+ return invalid(action, "limit must be a number");
229
+ const limit = Math.max(1, Math.min(50, Math.floor(rawLimit)));
230
+ const state = str(p.state);
231
+ const actionFilter = str(p.action);
232
+ const operations = (await ctx.operations.list())
233
+ .filter((operation) => !state || operation.state === state)
234
+ .filter((operation) => !actionFilter || operation.action === actionFilter)
235
+ .slice(-limit)
236
+ .reverse()
237
+ .map(projectOperationForChat);
238
+ return {
239
+ kind: "sync",
240
+ status: 200,
241
+ body: operatorSucceeded(action, { operations, limit }),
242
+ };
243
+ }
244
+ case "operationEventSummary": {
245
+ const operationId = str(p.operationId);
246
+ if (!operationId)
247
+ return invalid(action, "operationId is required");
248
+ const operation = await ctx.operations.get(operationId);
249
+ if (!operation) {
250
+ return {
251
+ kind: "error",
252
+ status: 404,
253
+ body: operatorFailed({
254
+ command: action,
255
+ outcome: "not-found",
256
+ code: "NOT_FOUND",
257
+ message: `operation not found: ${operationId}`,
258
+ }),
259
+ };
260
+ }
261
+ const rawLimit = typeof p.limit === "number" ? p.limit : Number(p.limit ?? 20);
262
+ if (!Number.isFinite(rawLimit))
263
+ return invalid(action, "limit must be a number");
264
+ const limit = Math.max(1, Math.min(100, Math.floor(rawLimit)));
265
+ const events = ctx.events
266
+ .snapshot(operationId)
267
+ .slice(-limit)
268
+ .map(projectOperationEventSummary);
269
+ return {
270
+ kind: "sync",
271
+ status: 200,
272
+ body: operatorSucceeded(action, {
273
+ operation: projectOperationForChat(operation),
274
+ events,
275
+ limit,
276
+ }),
277
+ };
278
+ }
195
279
  case "inspect": {
196
280
  const body = await runReadCli(ctx, ["inspect", "--json"], "inspect");
197
281
  return { kind: "sync", status: body.ok ? 200 : 400, body };
@@ -479,6 +563,16 @@ export async function dispatchOperatorAction(ctx, req) {
479
563
  case "prepareDagConfirmation": {
480
564
  const dagHandle = str(p.dagHandle) ?? "staged-dag.json";
481
565
  const taskId = str(p.taskId);
566
+ const operatorAction = str(p.operatorAction);
567
+ const operatorActionArgs = p.operatorActionArgs;
568
+ if (operatorActionArgs !== undefined && (!Array.isArray(operatorActionArgs) || operatorActionArgs.some((value) => typeof value !== "string"))) {
569
+ return invalid(action, "operatorActionArgs must be an array of strings");
570
+ }
571
+ if (operatorAction) {
572
+ const target = buildOperatorCapabilitiesDocument().actions.find((entry) => entry.action === operatorAction && entry.description.startsWith("Official CLI action coverage") && entry.humanConfirmation === "required");
573
+ if (!target)
574
+ return invalid(action, "operatorAction must name a required official action");
575
+ }
482
576
  if (!taskId)
483
577
  return invalid(action, "taskId is required");
484
578
  let dagText = str(p.dagText) ?? str(p.dagJson);
@@ -616,6 +710,7 @@ export async function dispatchOperatorAction(ctx, req) {
616
710
  taskId,
617
711
  dagHandle,
618
712
  validationOk: true,
713
+ ...(operatorAction ? { operatorAction, operatorActionArgs: operatorActionArgs ?? [] } : {}),
619
714
  },
620
715
  });
621
716
  return {
@@ -630,7 +725,7 @@ export async function dispatchOperatorAction(ctx, req) {
630
725
  }
631
726
  case "confirmDagConfirmation": {
632
727
  const confirmationId = str(p.confirmationId);
633
- const challenges = p.challenges ?? [];
728
+ const humanGateToken = p.humanGateToken;
634
729
  if (!confirmationId) {
635
730
  return invalid(action, "confirmationId is required");
636
731
  }
@@ -669,7 +764,7 @@ export async function dispatchOperatorAction(ctx, req) {
669
764
  const result = await ctx.confirmations.confirm({
670
765
  confirmationId,
671
766
  operatorSessionId: ctx.operatorSessionId,
672
- challenges,
767
+ humanGateToken,
673
768
  current: {
674
769
  dagSha256: hashDagBytes(dagBytes),
675
770
  sourceBindingSha256: conf.sourceBindingSha256,
@@ -992,6 +1087,13 @@ export async function dispatchOperatorAction(ctx, req) {
992
1087
  if (!runId || !reason || !clientRequestId) {
993
1088
  return invalid(action, "runId, reason, and clientRequestId are required");
994
1089
  }
1090
+ const gated = await consumeMutationGateReceipt(ctx, action, p);
1091
+ if (gated.kind === "error")
1092
+ return gated;
1093
+ if (gated.kind === "idempotent")
1094
+ return gated.result;
1095
+ const profile = str(p.profile);
1096
+ const taskId = str(p.taskId);
995
1097
  const cliArgs = [
996
1098
  "dag",
997
1099
  "rerun-task",
@@ -1003,19 +1105,19 @@ export async function dispatchOperatorAction(ctx, req) {
1003
1105
  clientRequestId,
1004
1106
  "--json",
1005
1107
  ];
1006
- const profile = str(p.profile);
1007
- const taskId = str(p.taskId);
1008
1108
  if (profile)
1009
1109
  cliArgs.push("--profile", profile);
1010
1110
  if (taskId)
1011
1111
  cliArgs.push("--task-id", taskId);
1012
- return acceptOperation(ctx, {
1112
+ const accepted = await acceptOperation(ctx, {
1013
1113
  action,
1014
1114
  actionParams: p,
1015
1115
  clientRequestId,
1016
1116
  taskId,
1017
1117
  cliArgs,
1018
1118
  });
1119
+ await finalizeMutationGateReceipt(ctx, gated.receiptId, accepted);
1120
+ return accepted;
1019
1121
  }
1020
1122
  case "workerTaskRetry": {
1021
1123
  const taskId = str(p.taskId);
@@ -1025,6 +1127,11 @@ export async function dispatchOperatorAction(ctx, req) {
1025
1127
  if (!taskId || !reason || !clientRequestId) {
1026
1128
  return invalid(action, "taskId, reason, and clientRequestId are required");
1027
1129
  }
1130
+ const gated = await consumeMutationGateReceipt(ctx, action, p);
1131
+ if (gated.kind === "error")
1132
+ return gated;
1133
+ if (gated.kind === "idempotent")
1134
+ return gated.result;
1028
1135
  const args = [
1029
1136
  "task",
1030
1137
  "retry",
@@ -1036,7 +1143,7 @@ export async function dispatchOperatorAction(ctx, req) {
1036
1143
  ];
1037
1144
  if (featureId)
1038
1145
  args.push("--feature-id", featureId);
1039
- return acceptOperation(ctx, {
1146
+ const accepted = await acceptOperation(ctx, {
1040
1147
  action,
1041
1148
  actionParams: p,
1042
1149
  clientRequestId,
@@ -1044,8 +1151,149 @@ export async function dispatchOperatorAction(ctx, req) {
1044
1151
  cliArgs: args,
1045
1152
  externalCommand: resolveSiblingAgentWorkerBin(),
1046
1153
  });
1154
+ await finalizeMutationGateReceipt(ctx, gated.receiptId, accepted);
1155
+ return accepted;
1156
+ }
1157
+ case "prepareMutationGate": {
1158
+ const targetAction = str(p.action);
1159
+ const actionParams = p.actionParams && typeof p.actionParams === "object" && !Array.isArray(p.actionParams)
1160
+ ? p.actionParams
1161
+ : undefined;
1162
+ if (!targetAction || !actionParams) {
1163
+ return invalid(action, "action and actionParams are required");
1164
+ }
1165
+ if (!MUTATION_GATE_ACTIONS.has(targetAction)) {
1166
+ return invalid(action, "action must be standaloneTaskRerun or workerTaskRetry");
1167
+ }
1168
+ const secret = ctx.humanGateSecret;
1169
+ if (!secret) {
1170
+ return {
1171
+ kind: "error",
1172
+ status: 503,
1173
+ body: operatorFailed({
1174
+ command: action,
1175
+ outcome: "blocked",
1176
+ code: "INTERNAL_ERROR",
1177
+ message: "humanGateSecret is not configured on action context",
1178
+ }),
1179
+ };
1180
+ }
1181
+ const paramsHash = mutationGatePayloadHash({
1182
+ action: targetAction,
1183
+ operatorSessionId: ctx.operatorSessionId,
1184
+ actionParams,
1185
+ });
1186
+ const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
1187
+ const receiptId = `mut_${crypto.randomUUID()}`;
1188
+ const humanGateToken = issueHumanGateToken({ confirmationId: receiptId, payloadHash: paramsHash, expiresAt }, secret);
1189
+ const store = new MutationGateReceiptStore(ctx.appData);
1190
+ const receipt = await store.prepare({
1191
+ receiptId,
1192
+ operatorSessionId: ctx.operatorSessionId,
1193
+ action: targetAction,
1194
+ paramsHash,
1195
+ actionParams,
1196
+ expiresAt,
1197
+ humanGateToken,
1198
+ });
1199
+ return {
1200
+ kind: "sync",
1201
+ status: 200,
1202
+ body: operatorSucceeded("prepareMutationGate", {
1203
+ receiptId: receipt.receiptId,
1204
+ action: receipt.action,
1205
+ expiresAt: receipt.expiresAt,
1206
+ paramsHash: receipt.paramsHash,
1207
+ humanGateToken: receipt.humanGateToken,
1208
+ state: receipt.state,
1209
+ }),
1210
+ };
1047
1211
  }
1048
- default:
1212
+ default: {
1213
+ // M5 official-command coverage uses one generic tail for newly registered
1214
+ // CLI actions. It remains inside dispatchOperatorAction (the sole dispatch
1215
+ // surface) and only accepts argv arrays declared by the capability schema.
1216
+ const capability = buildOperatorCapabilitiesDocument().actions.find((entry) => entry.action === action && entry.description.startsWith("Official CLI action coverage"));
1217
+ if (capability) {
1218
+ const rawArgs = p.args;
1219
+ if (rawArgs !== undefined && (!Array.isArray(rawArgs) || rawArgs.some((value) => typeof value !== "string"))) {
1220
+ return invalid(action, "args must be an array of strings");
1221
+ }
1222
+ if (capability.humanConfirmation === "required") {
1223
+ const confirmationId = str(p.confirmationId);
1224
+ if (!confirmationId)
1225
+ return invalid(action, "confirmationId is required");
1226
+ const conf = await ctx.confirmations.get(confirmationId);
1227
+ if (!conf) {
1228
+ return {
1229
+ kind: "error",
1230
+ status: 404,
1231
+ body: operatorFailed({ command: action, outcome: "not-found", code: "NOT_FOUND", message: `confirmation not found: ${confirmationId}` }),
1232
+ };
1233
+ }
1234
+ const confirmedArgs = conf.reviewPacket?.operatorActionArgs;
1235
+ const requestedArgs = rawArgs ?? [];
1236
+ if (conf.operatorSessionId !== ctx.operatorSessionId ||
1237
+ conf.reviewPacket?.operatorAction !== action ||
1238
+ !Array.isArray(confirmedArgs) ||
1239
+ JSON.stringify(confirmedArgs) !== JSON.stringify(requestedArgs)) {
1240
+ return {
1241
+ kind: "error",
1242
+ status: 403,
1243
+ body: operatorFailed({ command: action, outcome: "blocked", code: "HUMAN_CONFIRMATION_REQUIRED", message: "confirmation receipt is not bound to this action, arguments, and operator session" }),
1244
+ };
1245
+ }
1246
+ let receiptBytes;
1247
+ try {
1248
+ receiptBytes = await readFile(conf.dagBytesPath, "utf8");
1249
+ }
1250
+ catch (error) {
1251
+ return {
1252
+ kind: "error",
1253
+ status: 409,
1254
+ body: operatorFailed({ command: action, outcome: "conflict", code: "CONFIRMATION_STALE", message: `cannot re-read confirmation receipt bytes: ${error instanceof Error ? error.message : String(error)}` }),
1255
+ };
1256
+ }
1257
+ const consumed = await ctx.confirmations.consume({
1258
+ confirmationId,
1259
+ operatorSessionId: ctx.operatorSessionId,
1260
+ current: {
1261
+ dagSha256: hashDagBytes(receiptBytes),
1262
+ sourceBindingSha256: conf.sourceBindingSha256,
1263
+ taskContractBinding: conf.taskContractBinding,
1264
+ controllerFingerprint: ctx.client.getIdentity?.()?.packageFingerprint?.value ?? conf.controllerFingerprint,
1265
+ validationResultSha256: conf.validationResultSha256,
1266
+ },
1267
+ });
1268
+ if (!consumed.ok) {
1269
+ return {
1270
+ kind: "error",
1271
+ status: confirmationErrorStatus(consumed.code),
1272
+ body: operatorFailed({ command: action, outcome: "rejected", code: consumed.code, message: consumed.message }),
1273
+ };
1274
+ }
1275
+ }
1276
+ const prefix = capability.cli.replace(/^(loop-agent|agent-worker)\s+/, "").split(/\s+/);
1277
+ const args = [...prefix, ...(rawArgs ?? [])];
1278
+ if (capability.kind === "read") {
1279
+ if (!args.includes("--json"))
1280
+ args.push("--json");
1281
+ const body = await runReadCli(ctx, args, prefix.join(" "));
1282
+ return { kind: "sync", status: body.ok ? 200 : 400, body };
1283
+ }
1284
+ const clientRequestId = str(req.clientRequestId);
1285
+ if (!clientRequestId)
1286
+ return invalid(action, "clientRequestId is required");
1287
+ return acceptOperation(ctx, {
1288
+ action,
1289
+ actionParams: p,
1290
+ clientRequestId,
1291
+ cliArgs: args,
1292
+ ...(capability.cli.startsWith("agent-worker ")
1293
+ ? { externalCommand: resolveSiblingAgentWorkerBin() }
1294
+ : {}),
1295
+ });
1296
+ }
1049
1297
  return {
1050
1298
  kind: "error",
1051
1299
  status: 400,
@@ -1056,7 +1304,172 @@ export async function dispatchOperatorAction(ctx, req) {
1056
1304
  message: `unknown or unsupported action: ${action}`,
1057
1305
  }),
1058
1306
  };
1307
+ }
1308
+ }
1309
+ }
1310
+ async function consumeMutationGateReceipt(ctx, action, p) {
1311
+ const confirmationId = str(p.confirmationId);
1312
+ const humanGateToken = p.humanGateToken;
1313
+ if (!confirmationId) {
1314
+ return {
1315
+ kind: "error",
1316
+ status: 400,
1317
+ body: operatorFailed({
1318
+ command: action,
1319
+ outcome: "blocked",
1320
+ code: "HUMAN_CONFIRMATION_REQUIRED",
1321
+ message: "confirmationId is required — call prepareMutationGate then confirm via the browser Human Gate",
1322
+ }),
1323
+ };
1324
+ }
1325
+ const secret = ctx.humanGateSecret;
1326
+ if (!secret) {
1327
+ return {
1328
+ kind: "error",
1329
+ status: 503,
1330
+ body: operatorFailed({
1331
+ command: action,
1332
+ outcome: "blocked",
1333
+ code: "INTERNAL_ERROR",
1334
+ message: "humanGateSecret is not configured on action context",
1335
+ }),
1336
+ };
1337
+ }
1338
+ const store = new MutationGateReceiptStore(ctx.appData);
1339
+ const receipt = await store.get(confirmationId);
1340
+ if (!receipt || receipt.operatorSessionId !== ctx.operatorSessionId) {
1341
+ return {
1342
+ kind: "error",
1343
+ status: 404,
1344
+ body: operatorFailed({
1345
+ command: action,
1346
+ outcome: "not-found",
1347
+ code: "NOT_FOUND",
1348
+ message: `mutation gate receipt not found: ${confirmationId}`,
1349
+ }),
1350
+ };
1351
+ }
1352
+ if (receipt.action !== action) {
1353
+ return {
1354
+ kind: "error",
1355
+ status: 403,
1356
+ body: operatorFailed({
1357
+ command: action,
1358
+ outcome: "blocked",
1359
+ code: "HUMAN_CONFIRMATION_REQUIRED",
1360
+ message: "mutation gate receipt is bound to a different action",
1361
+ }),
1362
+ };
1363
+ }
1364
+ const { confirmationId: _cid, humanGateToken: _tok, ...boundParams } = p;
1365
+ const expectedHash = mutationGatePayloadHash({
1366
+ action,
1367
+ operatorSessionId: ctx.operatorSessionId,
1368
+ actionParams: receipt.actionParams,
1369
+ });
1370
+ if (expectedHash !== receipt.paramsHash) {
1371
+ return {
1372
+ kind: "error",
1373
+ status: 403,
1374
+ body: operatorFailed({
1375
+ command: action,
1376
+ outcome: "blocked",
1377
+ code: "HUMAN_CONFIRMATION_REQUIRED",
1378
+ message: "mutation gate receipt params hash mismatch",
1379
+ }),
1380
+ };
1381
+ }
1382
+ // Bound params from the receipt must match the dispatch params (minus gate fields).
1383
+ const presentedParams = {};
1384
+ for (const key of Object.keys(receipt.actionParams)) {
1385
+ presentedParams[key] = boundParams[key];
1386
+ }
1387
+ const presentedHash = mutationGatePayloadHash({
1388
+ action,
1389
+ operatorSessionId: ctx.operatorSessionId,
1390
+ actionParams: presentedParams,
1391
+ });
1392
+ if (presentedHash !== receipt.paramsHash) {
1393
+ return {
1394
+ kind: "error",
1395
+ status: 403,
1396
+ body: operatorFailed({
1397
+ command: action,
1398
+ outcome: "blocked",
1399
+ code: "HUMAN_CONFIRMATION_REQUIRED",
1400
+ message: "dispatch params do not match the prepared mutation gate receipt",
1401
+ }),
1402
+ };
1403
+ }
1404
+ const check = verifyHumanGateToken(humanGateToken, { confirmationId: receipt.receiptId, payloadHash: receipt.paramsHash }, secret);
1405
+ if (!check.ok) {
1406
+ return {
1407
+ kind: "error",
1408
+ status: 403,
1409
+ body: operatorFailed({
1410
+ command: action,
1411
+ outcome: "blocked",
1412
+ code: "HUMAN_CONFIRMATION_REQUIRED",
1413
+ message: check.message,
1414
+ }),
1415
+ };
1416
+ }
1417
+ const reserved = await store.tryReserve(confirmationId);
1418
+ if (!reserved.ok) {
1419
+ return {
1420
+ kind: "error",
1421
+ status: reserved.code === "NOT_FOUND" ? 404 : reserved.code === "EXPIRED" ? 410 : 409,
1422
+ body: operatorFailed({
1423
+ command: action,
1424
+ outcome: "rejected",
1425
+ code: reserved.code === "EXPIRED"
1426
+ ? "CONFIRMATION_EXPIRED"
1427
+ : "HUMAN_CONFIRMATION_REQUIRED",
1428
+ message: reserved.message,
1429
+ }),
1430
+ };
1431
+ }
1432
+ if (!reserved.reserved) {
1433
+ const operationId = reserved.receipt.operationId;
1434
+ if (operationId) {
1435
+ const existing = await ctx.operations.get(operationId);
1436
+ if (existing) {
1437
+ return {
1438
+ kind: "idempotent",
1439
+ result: {
1440
+ kind: "accepted",
1441
+ status: 202,
1442
+ body: {
1443
+ operationId: existing.operationId,
1444
+ state: existing.state,
1445
+ clientRequestId: existing.clientRequestId,
1446
+ action: existing.action,
1447
+ },
1448
+ },
1449
+ };
1450
+ }
1451
+ }
1452
+ return {
1453
+ kind: "error",
1454
+ status: 409,
1455
+ body: operatorFailed({
1456
+ command: action,
1457
+ outcome: "conflict",
1458
+ code: "CONFIRMATION_CONSUMED",
1459
+ message: `mutation gate receipt already ${reserved.reason}`,
1460
+ details: { operationId },
1461
+ }),
1462
+ };
1463
+ }
1464
+ return { kind: "ready", receiptId: confirmationId };
1465
+ }
1466
+ async function finalizeMutationGateReceipt(ctx, receiptId, accepted) {
1467
+ const store = new MutationGateReceiptStore(ctx.appData);
1468
+ if (accepted.kind === "accepted") {
1469
+ await store.markDispatched(receiptId, accepted.body.operationId);
1470
+ return;
1059
1471
  }
1472
+ await store.releaseToPrepared(receiptId);
1060
1473
  }
1061
1474
  async function taskExistsOnDisk(repoRoot, taskId) {
1062
1475
  try {
@@ -11,6 +11,7 @@ import { AssessmentStore } from "./interview/assessment.js";
11
11
  import { InterviewSessionStore } from "./interview/session.js";
12
12
  import { isLoopbackHost } from "./loopback.js";
13
13
  import { ChatSessionStore } from "./chat/session-store.js";
14
+ import { createChatEventStore, createChatOperationLinker, } from "./chat/chat-event-store.js";
14
15
  import { ConsolePiRuntime } from "./chat/pi-runtime.js";
15
16
  import { handleChatRequest } from "./chat/routes.js";
16
17
  import { createOperationEventStore, } from "./operation-sse.js";
@@ -56,7 +57,7 @@ export async function createConsoleServer(options) {
56
57
  persistenceDir: path.join(appData.operations, "events"),
57
58
  });
58
59
  const drafts = new DraftStore(appData);
59
- const confirmations = new DagConfirmationStore(appData);
60
+ const confirmations = new DagConfirmationStore(appData, bootToken.confirmationToken);
60
61
  const interviews = new InterviewSessionStore(appData);
61
62
  const assessments = new AssessmentStore(appData);
62
63
  const readiness = await probePiReadiness({
@@ -90,6 +91,7 @@ export async function createConsoleServer(options) {
90
91
  events,
91
92
  drafts,
92
93
  confirmations,
94
+ humanGateSecret: bootToken.confirmationToken,
93
95
  interviews,
94
96
  assessments,
95
97
  readiness: readinessCache,
@@ -106,6 +108,12 @@ export async function createConsoleServer(options) {
106
108
  // custom-tool execute() calls dispatch into the operator action layer (Gate 3).
107
109
  // Chat session dir is dedicated (app-data/chat-sessions), NOT user default.
108
110
  const chatStore = new ChatSessionStore(appData);
111
+ const chatEvents = createChatEventStore({ appData });
112
+ const chatOperationLinker = createChatOperationLinker({
113
+ chatEvents,
114
+ operations,
115
+ operationEvents: events,
116
+ });
109
117
  const skillsDir = resolveLoopAgentSkillsDir(repoRoot);
110
118
  const chatRuntime = new ConsolePiRuntime({
111
119
  cwd: repoRoot,
@@ -120,6 +128,8 @@ export async function createConsoleServer(options) {
120
128
  const chatRouteDeps = {
121
129
  runtime: chatRuntime,
122
130
  store: chatStore,
131
+ events: chatEvents,
132
+ operationLinker: chatOperationLinker,
123
133
  appData,
124
134
  bootToken,
125
135
  getConsoleOrigin,
@@ -202,7 +212,8 @@ export async function createConsoleServer(options) {
202
212
  if (pathname.startsWith("/api/operator/v1/") ||
203
213
  pathname.startsWith("/api/session/")) {
204
214
  // Phase 4: General Operator Chat routes (mounted under chat/).
205
- if (pathname.startsWith("/api/operator/v1/chat/")) {
215
+ if (pathname.startsWith("/api/operator/v1/chat/") ||
216
+ pathname.startsWith("/api/operator/v1/pi/")) {
206
217
  await handleChatRequest(req, res, chatRouteDeps, pathname);
207
218
  return;
208
219
  }
@@ -259,6 +270,7 @@ export async function createConsoleServer(options) {
259
270
  // leaks across Console restarts (regression: close() previously
260
271
  // only closed the socket, leaving ConsolePiRuntime sessions live).
261
272
  try {
273
+ chatOperationLinker.dispose();
262
274
  chatRuntime.disposeAll();
263
275
  }
264
276
  catch (error) {