@tea-agent/loop-agent 0.44.0-next.6 → 0.44.0-next.8

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 (105) hide show
  1. package/CHANGELOG.md +288 -454
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi/guards.js +4 -0
  4. package/dist/executors/dag-pi/model-config.js +30 -0
  5. package/dist/{workflows/dag/frontend-plan-completeness.js → executors/dag-pi/plan/facts.js} +25 -41
  6. package/dist/executors/dag-pi/plan/ledger-contract.js +55 -0
  7. package/dist/executors/dag-pi/tools/contract-tools.js +515 -0
  8. package/dist/executors/dag-pi/tools/design-terminal-tools.js +156 -0
  9. package/dist/executors/dag-pi/tools/plan-decision-tools.js +519 -0
  10. package/dist/executors/dag-pi/tools/review-terminal-tools.js +156 -0
  11. package/dist/executors/dag-pi/tools/scout-evidence-tools.js +270 -0
  12. package/dist/executors/dag-pi-executor.js +64 -1907
  13. package/dist/shared/git-workspace-status.js +2 -0
  14. package/dist/task/frontend-preflight.js +1 -1
  15. package/dist/task/source-prepare/semantic-intake.js +1 -1
  16. package/dist/task/source-prepare/source-fidelity-pi.js +1 -1
  17. package/dist/worker/console/chat/assistant-content.js +5 -34
  18. package/dist/worker/console/chat/chat-event-store.js +27 -10
  19. package/dist/worker/console/chat/db-connections.js +72 -8
  20. package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +52 -24
  21. package/dist/worker/console/chat/pi-runtime.js +34 -0
  22. package/dist/worker/console/chat/routes.js +138 -9
  23. package/dist/worker/console/chat/session-store.js +147 -19
  24. package/dist/worker/console/chat/turn-process.js +69 -23
  25. package/dist/worker/console/chat/user-turn-outline.js +19 -0
  26. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Brn2sPzL.js → abnfDiagram-N423BO3Z-r1NAPU_5.js} +1 -1
  27. package/dist/worker/console/static/assets/{arc-BIMNTG0W.js → arc-dT5T4sVL.js} +1 -1
  28. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G--WtcgR_A.js → architectureDiagram-T3A2C74G-CP_cOq46.js} +1 -1
  29. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BT_Ztog1.js → blockDiagram-VBNYF7ZC-sToEZxEf.js} +1 -1
  30. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-C5hbVr-l.js → c4Diagram-5PPSVZJV-DSxwwi2m.js} +1 -1
  31. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +1 -0
  32. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BKKnyR9Z.js → chunk-2GRJ4B5K-B9fh0IkF.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-BHs6biqA.js → chunk-2Q5K7J3B-CAUNl-A6.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BdSFy_vP.js → chunk-5RXB4S5H-BomeqbNX.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BpJIdTKV.js → chunk-5VM5RSS4-BNAuTOLE.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-C_iWJhkT.js → chunk-6Q2QTUOP-DfzjRD5y.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-BTbmx3-k.js → chunk-GF5L2VYU-C3uE-yc2.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-D4Ee6DmP.js → chunk-JWPE2WC7-C3sQMMYr.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-B487SORR.js → chunk-KBJHAD2P-D_AtEYQL.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BKYHjNrr.js → chunk-RYQCIY6F-DW4TwhKv.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DrTdoYZG.js → chunk-XXDRQBXY-CTQDmJcp.js} +1 -1
  42. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +1 -0
  43. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +1 -0
  44. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CttUY_Jo.js → cose-bilkent-JH36ORCC-iMqPuBsL.js} +1 -1
  45. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-B4Rm5gnR.js → cynefin-VYW2F7L2-DktIuDWs.js} +1 -1
  46. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-ByxHBq_S.js → cynefinDiagram-MW4NZA55-BgVnjyEs.js} +1 -1
  47. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-DGrfNFA0.js → dagre-VZM6K2ZE-CR25E4bu.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-KjrUPquv.js → diagram-7IWD3JNH-CPM2h5HY.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-VS4mlm75.js → diagram-B4RE2ZJO-DqF0x2g1.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-BLXYmFTM.js → diagram-LBJQPF4R-G8BYp2eb.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C6xoFAqH.js → diagram-Q27KOJAE-BbC4kT_D.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-UB23O5K3-C478dCe7.js → diagram-UB23O5K3-CKZwZ-G5.js} +1 -1
  53. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DHEpW5zN.js → ebnfDiagram-BXEA7PRR-kJDmheia.js} +1 -1
  54. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CA5GKfo9.js → erDiagram-JOGREHBK-DFTVHyxB.js} +1 -1
  55. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-zysi0Fl-.js → flowDiagram-UKHOOZJN-BqdN5_os.js} +1 -1
  56. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DGzcKA8Y.js → ganttDiagram-PKOTCBZU-SYDjVakh.js} +1 -1
  57. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CHsuIG1w.js → gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js} +1 -1
  58. package/dist/worker/console/static/assets/index-B28Onmfy.css +1 -0
  59. package/dist/worker/console/static/assets/index-CTMZsDmh.js +486 -0
  60. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BexZHPCp.js → infoDiagram-6WML65LV-DIeAmJ45.js} +1 -1
  61. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Gkx8q-DB.js → ishikawaDiagram-WSZJBQD7-oikqUAlo.js} +1 -1
  62. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-XDW3qrJ7.js → journeyDiagram-NVQOT4AX-mKEF_xYT.js} +1 -1
  63. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DJ07eEaN.js → kanban-definition-27J2QSJJ-BdWJHPnN.js} +1 -1
  64. package/dist/worker/console/static/assets/{linear-DL-zeqOn.js → linear-DGRlieMH.js} +1 -1
  65. package/dist/worker/console/static/assets/{mermaid.core-C0HdXtOf.js → mermaid.core-4P9yPC9c.js} +5 -5
  66. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CqRX0lrt.js → mindmap-definition-FAOFIHXS-5PTSkos6.js} +1 -1
  67. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVM9JZ2H.js → pegDiagram-VL7TDLO6-DONIFpG5.js} +1 -1
  68. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BA3ozdKk.js → pieDiagram-7S7Q4E2Y-D_Z4S70M.js} +1 -1
  69. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B-0_p9_4.js → quadrantDiagram-CIZ2JOQS-CAsjh8iY.js} +1 -1
  70. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DHRzPWFc.js → railroadDiagram-AXF67PYL-Cx4Dd6ac.js} +1 -1
  71. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-9BEC_PcW.js → requirementDiagram-LRYGKXZP-cQb7mwyH.js} +1 -1
  72. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DMxeW4rN.js → sankeyDiagram-W5VNT64P-Cd-DhUdf.js} +1 -1
  73. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DixP6LIU.js → sequenceDiagram-SI44F4Z6-AlKYRgIk.js} +1 -1
  74. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B_EifJBo.js → sizeCapture-X5ZJPWSS-CUy_ET4e.js} +1 -1
  75. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BwikwWbX.js → stateDiagram-OKZ733FA-CTuVe6Sa.js} +1 -1
  76. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +1 -0
  77. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-C162l9o1.js → swimlanes-SLNWSIFB-ChFLJb-v.js} +2 -2
  78. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +8 -0
  79. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-mpe_u1i4.js → timeline-definition-Z64GVDOM-DZZeiAPr.js} +1 -1
  80. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DyUk54HX.js → vennDiagram-T6HMQDX7-t4O91cZU.js} +1 -1
  81. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-HYyMkzfm.js → wardleyDiagram-T6FBY63Y-CV5qGHZu.js} +1 -1
  82. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DRoh7skR.js → xychartDiagram-ELKLHX3M-DyXbytUI.js} +1 -1
  83. package/dist/worker/console/static/index.html +2 -2
  84. package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
  85. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +58 -3
  86. package/dist/worker/console/static-src/operator-chat/process-label.js +19 -6
  87. package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
  88. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +2 -0
  89. package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
  90. package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
  91. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
  92. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +202 -43
  93. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -23
  94. package/dist/worker/console/static-src/operator-chat/useChatThread.js +55 -18
  95. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +5 -2
  96. package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
  97. package/dist/worker/scheduler/scheduled-goal-evidence.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/worker/console/static/assets/channel-DNWB4hpR.js +0 -1
  100. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-COhFgJji.js +0 -1
  101. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-COhFgJji.js +0 -1
  102. package/dist/worker/console/static/assets/index-CJh6pVLm.js +0 -482
  103. package/dist/worker/console/static/assets/index-C_kvKmFM.css +0 -1
  104. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-S2ZHMo1c.js +0 -1
  105. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BZm4ByfV.js +0 -8
@@ -19,6 +19,7 @@ import { OPERATOR_CHAT_ALLOWED_TOOLS, OPERATOR_CHAT_DENIED_TOOLS, OPERATOR_CHAT_
19
19
  import { InvalidChatSessionModeError, SESSION_MODE_ACTION_DENIED_CODE, SESSION_MODE_ACTION_DENIED_MESSAGE, SESSION_MODE_IMMUTABLE_CODE, UnknownChatSessionModeError, isWorkflowSessionMode, parseCreateSessionMode, readStoredSessionMode, shouldAcceptOperatorSnapshot, } from "./session-mode.js";
20
20
  import { buildModelCallableToolSchemas } from "./tool-adapter.js";
21
21
  import { cleanChatTitle, acceptedOperationIdFromResult, isBlankChatSession } from "./session-store.js";
22
+ import { projectUserTurnOutline } from "./user-turn-outline.js";
22
23
  import { hashChatTurnPayload, projectChatToolEventPayload, projectUnknownChatEventPayload, projectCompactSnapshot, } from "./chat-event-store.js";
23
24
  import { projectDeferredTurnSummary, projectDeferredTurnSummaries, } from "./deferred-turn.js";
24
25
  import { THINKING_LEVELS, RuntimeSnapshotUnavailableError, } from "./pi-runtime.js";
@@ -231,6 +232,43 @@ function turnSummary(turn) {
231
232
  abortRequestedAt: turn.abortRequestedAt ?? null,
232
233
  };
233
234
  }
235
+ function turnStateSummary(turn) {
236
+ return {
237
+ turnId: turn.turnId,
238
+ state: turn.state === "queued" ? "running" : turn.state,
239
+ };
240
+ }
241
+ function turnStatesForIds(events, sessionId, turnIds) {
242
+ const projected = new Map();
243
+ for (const turnId of turnIds) {
244
+ if (!turnId || projected.has(turnId))
245
+ continue;
246
+ const turn = events.getTurn(sessionId, turnId);
247
+ if (turn)
248
+ projected.set(turnId, turnStateSummary(turn));
249
+ }
250
+ return [...projected.values()];
251
+ }
252
+ const MAX_STATE_QUERY_TURN_IDS = 8;
253
+ const MAX_STATE_QUERY_TURN_ID_CHARS = 240;
254
+ function requestedStateTurnIds(req) {
255
+ const result = [];
256
+ const seen = new Set();
257
+ const url = new URL(req.url ?? "/", "http://localhost");
258
+ for (const raw of url.searchParams.getAll("turnId")) {
259
+ const turnId = raw.trim();
260
+ if (!turnId ||
261
+ turnId.length > MAX_STATE_QUERY_TURN_ID_CHARS ||
262
+ seen.has(turnId)) {
263
+ continue;
264
+ }
265
+ seen.add(turnId);
266
+ result.push(turnId);
267
+ if (result.length >= MAX_STATE_QUERY_TURN_IDS)
268
+ break;
269
+ }
270
+ return result;
271
+ }
234
272
  function isTerminalTurnState(state) {
235
273
  return state === "settled" || state === "aborted" || state === "failed";
236
274
  }
@@ -421,6 +459,20 @@ export function turnEventToStorePartial(event) {
421
459
  : {}),
422
460
  }),
423
461
  };
462
+ case "tool_update":
463
+ return {
464
+ kind: "tool_update",
465
+ data: projectChatToolEventPayload({
466
+ kind: "tool_update",
467
+ toolCallId: String(event.toolCallId ?? ""),
468
+ toolName: String(event.toolName ?? ""),
469
+ value: event.partialResult,
470
+ ...(typeof event.step === "number" ? { step: event.step } : {}),
471
+ ...(typeof event.parentToolCallId === "string"
472
+ ? { parentToolCallId: event.parentToolCallId }
473
+ : {}),
474
+ }),
475
+ };
424
476
  case "tool_result": {
425
477
  // Plan A (2026-08-16): surface the accepted-operation link on the live
426
478
  // SSE frame so the frontend semantic event can resolve operation:<id>.
@@ -924,25 +976,47 @@ export async function handleForkChatSession(req, res, deps, sourceSessionId) {
924
976
  });
925
977
  return;
926
978
  }
979
+ let forkedHandle;
980
+ let forkPersisted = false;
927
981
  try {
982
+ const body = await readJsonBody(req);
983
+ const throughMessageId = typeof body.throughMessageId === "string" && body.throughMessageId.trim()
984
+ ? body.throughMessageId.trim()
985
+ : undefined;
986
+ const throughUserMessageIndex = throughMessageId
987
+ ? source.messages
988
+ .filter((message) => message.role === "user")
989
+ .findIndex((message) => message.id === throughMessageId)
990
+ : undefined;
991
+ if (throughMessageId && throughUserMessageIndex === -1) {
992
+ throw new Error("FORK_MESSAGE_NOT_FOUND");
993
+ }
928
994
  const handle = await deps.runtime.forkSession({
929
995
  sourceSessionFile: source.sessionFile ?? "",
930
996
  sessionMode: source.sessionMode,
931
997
  ...(source.modelProvider && source.modelId
932
998
  ? { model: { provider: source.modelProvider, modelId: source.modelId } }
933
999
  : {}),
1000
+ ...(throughUserMessageIndex !== undefined
1001
+ ? { throughUserMessageIndex }
1002
+ : {}),
934
1003
  });
1004
+ forkedHandle = handle;
935
1005
  await deps.store.forkFrom({
936
1006
  sourceSessionId,
937
1007
  targetSessionId: handle.sessionId,
938
1008
  targetSessionFile: handle.sessionFile,
1009
+ ...(throughMessageId ? { throughMessageId } : {}),
939
1010
  });
1011
+ forkPersisted = true;
940
1012
  await deps.attachSession?.(handle.sessionId).catch(() => undefined);
941
- const copied = deps.events.forkContextRefs({
942
- sourceSessionId,
943
- targetSessionId: handle.sessionId,
944
- targetTurnId: `${handle.sessionId}:fork`,
945
- });
1013
+ const copied = throughMessageId
1014
+ ? { copiedCount: 0 }
1015
+ : deps.events.forkContextRefs({
1016
+ sourceSessionId,
1017
+ targetSessionId: handle.sessionId,
1018
+ targetTurnId: `${handle.sessionId}:fork`,
1019
+ });
946
1020
  await deps.operationLinker.recoverSession(handle.sessionId);
947
1021
  sendJson(res, 201, {
948
1022
  ok: true,
@@ -956,6 +1030,12 @@ export async function handleForkChatSession(req, res, deps, sourceSessionId) {
956
1030
  });
957
1031
  }
958
1032
  catch (error) {
1033
+ if (forkedHandle && !forkPersisted) {
1034
+ deps.runtime.dispose(forkedHandle.sessionId);
1035
+ await deps.store
1036
+ .discardUnownedSession(forkedHandle.sessionId, forkedHandle.sessionFile)
1037
+ .catch(() => undefined);
1038
+ }
959
1039
  sendJson(res, 409, {
960
1040
  ok: false,
961
1041
  error: {
@@ -1105,12 +1185,18 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
1105
1185
  const sddSessionIdentity = deps.sddDataAlignment && (await deps.sddDataAlignment.isEligible())
1106
1186
  ? { telemetrySessionId: record.telemetrySessionId ?? null }
1107
1187
  : undefined;
1188
+ const projectedMessages = projectStoredMessagesWithEventOrder(messagePage.messages, snapshot);
1189
+ const turnStates = turnStatesForIds(deps.events, sessionId, [
1190
+ ...projectedMessages.map((message) => message.turnId),
1191
+ ...snapshot.map((event) => event.turnId),
1192
+ ]);
1108
1193
  sendJson(res, 200, {
1109
1194
  ok: true,
1110
1195
  eventEpoch: deps.events.epoch(sessionId),
1111
1196
  session: {
1112
1197
  ...projectBrowserChatSession(record),
1113
- messages: projectStoredMessagesWithEventOrder(messagePage.messages, snapshot),
1198
+ messages: projectedMessages,
1199
+ turnStates,
1114
1200
  composerDraft,
1115
1201
  activeTurnId: activeTurn?.turnId,
1116
1202
  turnPhase: activeTurn
@@ -1130,6 +1216,7 @@ export async function handleGetChatSession(_req, res, deps, sessionId) {
1130
1216
  sessionStats: telemetry.stats,
1131
1217
  usage: telemetry.usage,
1132
1218
  turnTelemetry: deriveChatTurnTelemetry(snapshot),
1219
+ userTurnOutline: projectUserTurnOutline(record.messages ?? []),
1133
1220
  ...(sddSessionIdentity
1134
1221
  ? { sddDataAlignment: sddSessionIdentity }
1135
1222
  : {}),
@@ -1408,7 +1495,14 @@ function emitUserMessageEvent(deps, sessionId, turnId, userMessage, clientReques
1408
1495
  data: {
1409
1496
  ...(clientRequestId ? { clientRequestId } : {}),
1410
1497
  ...(steering ? { steering: true } : {}),
1411
- message: { id: userMessage.id, text: userMessage.text, at: userMessage.at },
1498
+ message: {
1499
+ id: userMessage.id,
1500
+ text: userMessage.text,
1501
+ at: userMessage.at,
1502
+ ...(userMessage.attachments?.length
1503
+ ? { attachments: userMessage.attachments }
1504
+ : {}),
1505
+ },
1412
1506
  },
1413
1507
  });
1414
1508
  }
@@ -1416,7 +1510,7 @@ function emitUserMessageEvent(deps, sessionId, turnId, userMessage, clientReques
1416
1510
  * get_state equivalent): exposes the active turn + the latest turn's durable
1417
1511
  * outcome WITHOUT the full message payload, so the UI can poll it on an
1418
1512
  * interval / visibility / online without paying for session detail. */
1419
- export async function handleGetChatSessionState(_req, res, deps, sessionId) {
1513
+ export async function handleGetChatSessionState(req, res, deps, sessionId) {
1420
1514
  const record = await deps.store.get(sessionId);
1421
1515
  if (!record) {
1422
1516
  sendJson(res, 404, {
@@ -1447,9 +1541,15 @@ export async function handleGetChatSessionState(_req, res, deps, sessionId) {
1447
1541
  : runtimeBusy
1448
1542
  ? "running"
1449
1543
  : "idle";
1544
+ const turnStates = turnStatesForIds(deps.events, sessionId, [
1545
+ active?.turnId,
1546
+ latest?.turnId,
1547
+ ...requestedStateTurnIds(req),
1548
+ ]);
1450
1549
  sendJson(res, 200, {
1451
1550
  ok: true,
1452
1551
  state: {
1552
+ turnStates,
1453
1553
  // Legacy flat fields retained for older clients.
1454
1554
  activeTurnId: active?.turnId ?? null,
1455
1555
  activeTurnState: active?.state ?? null,
@@ -4484,7 +4584,14 @@ export async function handleChatMessages(res, deps, sessionId, url) {
4484
4584
  limit: Number.isFinite(limit) ? limit : 30,
4485
4585
  beforeId: url.searchParams.get("beforeId") ?? undefined,
4486
4586
  });
4487
- sendJson(res, 200, { ok: true, ...page });
4587
+ const snapshot = deps.events.snapshot(sessionId);
4588
+ const messages = projectStoredMessagesWithEventOrder(page.messages, snapshot);
4589
+ sendJson(res, 200, {
4590
+ ok: true,
4591
+ ...page,
4592
+ messages,
4593
+ turnStates: turnStatesForIds(deps.events, sessionId, messages.map((message) => message.turnId)),
4594
+ });
4488
4595
  }
4489
4596
  catch (error) {
4490
4597
  sendJson(res, 404, {
@@ -4496,6 +4603,23 @@ export async function handleChatMessages(res, deps, sessionId, url) {
4496
4603
  });
4497
4604
  }
4498
4605
  }
4606
+ export async function handleChatAttachment(res, deps, sessionId, attachmentId) {
4607
+ const attachment = await deps.store.readAttachment(sessionId, attachmentId);
4608
+ if (!attachment) {
4609
+ sendJson(res, 404, {
4610
+ ok: false,
4611
+ error: { code: "NOT_FOUND", message: "chat attachment not found" },
4612
+ });
4613
+ return;
4614
+ }
4615
+ res.writeHead(200, {
4616
+ "content-type": attachment.metadata.mimeType,
4617
+ "content-length": String(attachment.bytes.length),
4618
+ "cache-control": "private, max-age=31536000, immutable",
4619
+ "x-content-type-options": "nosniff",
4620
+ });
4621
+ res.end(attachment.bytes);
4622
+ }
4499
4623
  /**
4500
4624
  * Dispatch a chat HTTP request. Returns true if handled.
4501
4625
  * Mounted under /api/operator/v1/chat/*.
@@ -4933,6 +5057,11 @@ async function dispatchChatRequest(req, res, deps, pathname, workspaceScope) {
4933
5057
  await handleChatMessages(res, deps, decodeURIComponent(sessionMessages[1]), url);
4934
5058
  return true;
4935
5059
  }
5060
+ const sessionAttachment = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/attachments\/([^/]+)$/);
5061
+ if (method === "GET" && sessionAttachment) {
5062
+ await handleChatAttachment(res, deps, decodeURIComponent(sessionAttachment[1]), decodeURIComponent(sessionAttachment[2]));
5063
+ return true;
5064
+ }
4936
5065
  const sessionPrompt = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)\/prompt$/);
4937
5066
  if (method === "POST" && sessionPrompt) {
4938
5067
  await handleChatPrompt(req, res, deps, decodeURIComponent(sessionPrompt[1]));
@@ -11,7 +11,8 @@
11
11
  * operator action call. Every invocation is re-authorized here.
12
12
  */
13
13
  import path from "node:path";
14
- import { readdir, rm } from "node:fs/promises";
14
+ import { randomUUID } from "node:crypto";
15
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
15
16
  import { readJsonIfExists, writeSecureJson, } from "../../../infrastructure/console/app-data.js";
16
17
  import { dispatchOperatorAction } from "../operator-actions.js";
17
18
  import { authorizeOperatorChatTool } from "./tools.js";
@@ -19,6 +20,18 @@ import { authorizeChatSessionTool, DEFAULT_CONSOLE_CHAT_SESSION_MODE, readStored
19
20
  import { projectOperationForChat } from "./chat-event-store.js";
20
21
  import { scrubSecrets } from "./explore-tools.js";
21
22
  import { hashDeferredTurnPayload, parseDeferredTurns, } from "./deferred-turn.js";
23
+ const CHAT_IMAGE_MIME_TYPES = new Set([
24
+ "image/png",
25
+ "image/jpeg",
26
+ "image/gif",
27
+ "image/webp",
28
+ ]);
29
+ function isChatImageMimeType(value) {
30
+ return typeof value === "string" && CHAT_IMAGE_MIME_TYPES.has(value);
31
+ }
32
+ function isChatAttachmentId(value) {
33
+ return typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value);
34
+ }
22
35
  /** True only for a durable session that has never received user work. */
23
36
  export function isBlankChatSession(record) {
24
37
  return (record.state === "active" &&
@@ -48,6 +61,11 @@ export function cleanChatTitle(value) {
48
61
  function recordPath(appData, sessionId) {
49
62
  return path.join(appData.chats, `${sessionId}.json`);
50
63
  }
64
+ function attachmentDir(appData, sessionId) {
65
+ if (!/^[a-zA-Z0-9_-]+$/.test(sessionId))
66
+ throw new Error("invalid chat session id");
67
+ return path.join(appData.chats, "attachments", sessionId);
68
+ }
51
69
  /** True when `sessionFile` lives inside this store's dedicated Pi JSONL dir. */
52
70
  export function isConsoleOwnedSessionFile(appData, sessionFile) {
53
71
  const value = sessionFile?.trim();
@@ -196,6 +214,12 @@ export class ChatSessionStore {
196
214
  const source = await this.get(input.sourceSessionId);
197
215
  if (!source)
198
216
  throw new Error(`chat session not found: ${input.sourceSessionId}`);
217
+ const throughIndex = input.throughMessageId
218
+ ? source.messages.findIndex((message) => message.id === input.throughMessageId && message.role === "user")
219
+ : -1;
220
+ if (input.throughMessageId && throughIndex < 0) {
221
+ throw new Error("FORK_MESSAGE_NOT_FOUND");
222
+ }
199
223
  return this.serialize(input.targetSessionId, async () => {
200
224
  const now = new Date().toISOString();
201
225
  const record = {
@@ -208,11 +232,30 @@ export class ChatSessionStore {
208
232
  updatedAt: now,
209
233
  deferredTurns: [],
210
234
  deferredQueueRevision: 0,
211
- messages: structuredClone(source.messages),
212
- toolInvocations: structuredClone(source.toolInvocations),
235
+ messages: structuredClone(throughIndex >= 0
236
+ ? source.messages.slice(0, throughIndex + 1)
237
+ : source.messages),
238
+ toolInvocations: throughIndex >= 0 ? [] : structuredClone(source.toolInvocations),
213
239
  };
214
- await this.persist(record);
215
- return record;
240
+ const attachmentIds = record.messages.flatMap((message) => Array.isArray(message.attachments)
241
+ ? message.attachments.map((attachment) => attachment.id)
242
+ : []);
243
+ if (!attachmentIds.every(isChatAttachmentId)) {
244
+ throw new Error("INVALID_CHAT_ATTACHMENT_REFERENCE");
245
+ }
246
+ try {
247
+ if (attachmentIds.length > 0) {
248
+ const targetDir = attachmentDir(this.appData, input.targetSessionId);
249
+ await mkdir(targetDir, { recursive: true });
250
+ await Promise.all(attachmentIds.map((attachmentId) => copyFile(path.join(attachmentDir(this.appData, input.sourceSessionId), attachmentId), path.join(targetDir, attachmentId))));
251
+ }
252
+ await this.persist(record);
253
+ return record;
254
+ }
255
+ catch (error) {
256
+ await this.removeAttachmentFiles(input.targetSessionId).catch(() => undefined);
257
+ throw error;
258
+ }
216
259
  });
217
260
  }
218
261
  /** Atomically backfill the per-conversation simulated identity on first use. */
@@ -286,17 +329,84 @@ export class ChatSessionStore {
286
329
  const record = await this.get(sessionId);
287
330
  if (!record)
288
331
  return false;
289
- const sessionFile = record.sessionFile;
290
- await rm(recordPath(this.appData, sessionId), { force: true });
291
- this.options.onRecordChange?.(sessionId, undefined);
292
- // Record first, then best-effort JSONL unlink. Final consistency is
293
- // "both gone" — a leftover JSONL is harmless, a leftover record is not.
294
- if (isConsoleOwnedSessionFile(this.appData, sessionFile) && sessionFile) {
295
- await rm(sessionFile, { force: true }).catch(() => undefined);
296
- }
332
+ await this.removeSessionArtifacts(sessionId, record.sessionFile);
297
333
  return true;
298
334
  });
299
335
  }
336
+ /** Roll back a runtime-created session that never acquired a durable owner. */
337
+ async discardUnownedSession(sessionId, sessionFile) {
338
+ await this.serialize(sessionId, () => this.removeSessionArtifacts(sessionId, sessionFile));
339
+ }
340
+ async removeSessionArtifacts(sessionId, sessionFile) {
341
+ await rm(recordPath(this.appData, sessionId), { force: true });
342
+ await this.removeAttachmentFiles(sessionId).catch(() => undefined);
343
+ this.options.onRecordChange?.(sessionId, undefined);
344
+ // Record first, then best-effort JSONL unlink. Final consistency is
345
+ // "both gone" — a leftover JSONL is harmless, a leftover record is not.
346
+ if (isConsoleOwnedSessionFile(this.appData, sessionFile) && sessionFile) {
347
+ await rm(sessionFile, { force: true }).catch(() => undefined);
348
+ }
349
+ }
350
+ async removeAttachmentFiles(sessionId, attachmentIds) {
351
+ const dir = attachmentDir(this.appData, sessionId);
352
+ if (!attachmentIds) {
353
+ await rm(dir, { recursive: true, force: true });
354
+ return;
355
+ }
356
+ await Promise.all(attachmentIds.map((attachmentId) => rm(path.join(dir, attachmentId), { force: true })));
357
+ }
358
+ async saveAttachments(sessionId, images) {
359
+ if (images.length === 0)
360
+ return [];
361
+ if (!(await this.get(sessionId)))
362
+ throw new Error(`chat session not found: ${sessionId}`);
363
+ const dir = attachmentDir(this.appData, sessionId);
364
+ await mkdir(dir, { recursive: true });
365
+ const stored = [];
366
+ try {
367
+ for (const [index, image] of images.entries()) {
368
+ if (!isChatImageMimeType(image.mimeType)) {
369
+ throw new Error("unsupported chat attachment mime type");
370
+ }
371
+ const id = randomUUID();
372
+ const bytes = Buffer.from(image.data, "base64");
373
+ await writeFile(path.join(dir, id), bytes, { flag: "wx", mode: 0o600 });
374
+ stored.push({
375
+ id,
376
+ name: `图片 ${index + 1}`,
377
+ mimeType: image.mimeType,
378
+ byteLength: bytes.length,
379
+ });
380
+ }
381
+ }
382
+ catch (error) {
383
+ await this.removeAttachmentFiles(sessionId, stored.map((attachment) => attachment.id)).catch(() => undefined);
384
+ throw error;
385
+ }
386
+ return stored;
387
+ }
388
+ async readAttachment(sessionId, attachmentId) {
389
+ if (!isChatAttachmentId(attachmentId))
390
+ return undefined;
391
+ const record = await this.get(sessionId);
392
+ const metadata = record?.messages
393
+ .flatMap((message) => Array.isArray(message.attachments) ? message.attachments : [])
394
+ .find((attachment) => attachment.id === attachmentId);
395
+ if (!metadata || !isChatImageMimeType(metadata.mimeType))
396
+ return undefined;
397
+ try {
398
+ return {
399
+ metadata,
400
+ bytes: await readFile(path.join(attachmentDir(this.appData, sessionId), attachmentId)),
401
+ };
402
+ }
403
+ catch {
404
+ return undefined;
405
+ }
406
+ }
407
+ async removeAttachments(sessionId, attachments) {
408
+ await this.removeAttachmentFiles(sessionId, attachments.map((attachment) => attachment.id));
409
+ }
300
410
  async putDeferredTurn(sessionId, input) {
301
411
  return this.serialize(sessionId, async () => {
302
412
  const record = await this.get(sessionId);
@@ -454,11 +564,13 @@ export class ChatSessionStore {
454
564
  const deferredTurn = index >= 0 ? queue[index] : undefined;
455
565
  if (!deferredTurn || deferredTurn.state !== "dispatching")
456
566
  return undefined;
567
+ const attachments = await this.saveAttachments(sessionId, deferredTurn.images);
457
568
  const message = {
458
569
  id: `m_${record.messages.length + 1}_${Math.random().toString(36).slice(2, 8)}`,
459
570
  role: "user",
460
571
  text,
461
572
  at: new Date().toISOString(),
573
+ ...(attachments.length > 0 ? { attachments } : {}),
462
574
  };
463
575
  queue.splice(index, 1);
464
576
  record.messages.push(message);
@@ -468,7 +580,13 @@ export class ChatSessionStore {
468
580
  if (!record.displayTitle)
469
581
  record.displayTitle = cleanChatTitle(message.text);
470
582
  record.updatedAt = message.at;
471
- await this.persist(record);
583
+ try {
584
+ await this.persist(record);
585
+ }
586
+ catch (error) {
587
+ await this.removeAttachments(sessionId, attachments).catch(() => undefined);
588
+ throw error;
589
+ }
472
590
  return {
473
591
  record,
474
592
  message,
@@ -623,11 +741,20 @@ export async function runChatTurn(options) {
623
741
  };
624
742
  }
625
743
  if (options.persistUserMessage !== false) {
626
- const userMessage = await store.appendMessage(sessionId, {
627
- role: "user",
628
- text,
629
- ...(options.clientRequestId ? { clientRequestId: options.clientRequestId } : {}),
630
- });
744
+ const attachments = await store.saveAttachments(sessionId, options.images ?? []);
745
+ let userMessage;
746
+ try {
747
+ userMessage = await store.appendMessage(sessionId, {
748
+ role: "user",
749
+ text,
750
+ ...(options.clientRequestId ? { clientRequestId: options.clientRequestId } : {}),
751
+ ...(attachments.length > 0 ? { attachments } : {}),
752
+ });
753
+ }
754
+ catch (error) {
755
+ await store.removeAttachments(sessionId, attachments).catch(() => undefined);
756
+ throw error;
757
+ }
631
758
  const afterUserMessage = await store.get(sessionId);
632
759
  if (afterUserMessage)
633
760
  await options.onUserMessagePersisted?.(afterUserMessage, userMessage);
@@ -687,6 +814,7 @@ export async function runChatTurn(options) {
687
814
  // persisted + framed above. Nothing further to persist here.
688
815
  break;
689
816
  case "tool_call":
817
+ case "tool_update":
690
818
  // Forwarded to the caller (SSE) for UI feedback. The actual tool
691
819
  // execution happens in the SDK's customTools[].execute(), which
692
820
  // routes to dispatchChatToolCall (Gate 3). Do NOT dispatch here —
@@ -51,7 +51,7 @@ export function projectTurnFlow(group) {
51
51
  (item.kind === "step" && item.node.status === "running"))
52
52
  .sort((a, b) => compareChatOrder(conversationNodeOrder(a.node), conversationNodeOrder(b.node)));
53
53
  const postAnswer = conversationItems
54
- .filter((item) => item.kind === "max-tokens")
54
+ .filter((item) => item.kind === "max-tokens" || item.kind === "turn-error")
55
55
  .sort((a, b) => compareChatOrder(conversationNodeOrder(a.node), conversationNodeOrder(b.node)));
56
56
  const preludeKeys = new Set(prelude.map((item) => item.node.key));
57
57
  const postAnswerKeys = new Set(postAnswer.map((item) => item.node.key));
@@ -86,6 +86,19 @@ export function projectTurnFlow(group) {
86
86
  }
87
87
  return { prelude, process, postAnswer };
88
88
  }
89
+ /**
90
+ * Main-chat projection: request provenance and model-step bookkeeping remain
91
+ * available to Trajectory, but do not occupy the user-facing message stream.
92
+ */
93
+ export function projectTimelineTurnFlow(group) {
94
+ const flow = projectTurnFlow(group);
95
+ return {
96
+ prelude: [],
97
+ process: flow.process.filter((entry) => entry.source === "process" ||
98
+ (entry.item.kind !== "request" && entry.item.kind !== "step")),
99
+ postAnswer: flow.postAnswer,
100
+ };
101
+ }
89
102
  function emptyGroup(key, turnId) {
90
103
  return {
91
104
  key,
@@ -94,6 +107,7 @@ function emptyGroup(key, turnId) {
94
107
  tools: [],
95
108
  processItems: [],
96
109
  conversationItems: [],
110
+ turnClosed: false,
97
111
  live: false,
98
112
  };
99
113
  }
@@ -129,6 +143,7 @@ export function hydrateSessionThread(raw) {
129
143
  ...(item.clientRequestId ? { clientRequestId: item.clientRequestId } : {}),
130
144
  ...(item.eventSeq === undefined ? {} : { eventSeq: item.eventSeq }),
131
145
  ...(item.turnId ? { turnId: item.turnId } : {}),
146
+ ...(item.attachments?.length ? { attachments: item.attachments } : {}),
132
147
  });
133
148
  continue;
134
149
  }
@@ -225,12 +240,17 @@ function flushToolBuffer(processItems, buffer) {
225
240
  };
226
241
  const dagProgress = dagProgressActivityForTool(tool);
227
242
  if (dagProgress) {
228
- processItems.push({ kind: "dag-progress", progress: dagProgress, ...order });
243
+ processItems.push({
244
+ kind: "dag-progress",
245
+ progress: dagProgress,
246
+ toolCallCount: 1,
247
+ ...order,
248
+ });
229
249
  continue;
230
250
  }
231
251
  const activity = semanticActivityForTool(tool);
232
252
  if (activity) {
233
- processItems.push({ kind: "activity", activity, ...order });
253
+ processItems.push({ kind: "activity", activity, toolCallCount: 1, ...order });
234
254
  }
235
255
  else {
236
256
  processItems.push({ kind: "tool", tool, ...order });
@@ -238,15 +258,28 @@ function flushToolBuffer(processItems, buffer) {
238
258
  }
239
259
  }
240
260
  function collapseDagProgressItems(items) {
241
- const seen = new Set();
242
- return items.filter((item) => {
243
- if (item.kind !== "dag-progress")
244
- return true;
245
- if (seen.has(item.progress.activityRef))
246
- return false;
247
- seen.add(item.progress.activityRef);
248
- return true;
249
- });
261
+ const collapsed = [];
262
+ const progressIndexByRef = new Map();
263
+ for (const item of items) {
264
+ if (item.kind !== "dag-progress") {
265
+ collapsed.push(item);
266
+ continue;
267
+ }
268
+ const existingIndex = progressIndexByRef.get(item.progress.activityRef);
269
+ if (existingIndex === undefined) {
270
+ progressIndexByRef.set(item.progress.activityRef, collapsed.length);
271
+ collapsed.push(item);
272
+ continue;
273
+ }
274
+ const existing = collapsed[existingIndex];
275
+ if (existing?.kind === "dag-progress") {
276
+ collapsed[existingIndex] = {
277
+ ...existing,
278
+ toolCallCount: (existing.toolCallCount ?? 1) + (item.toolCallCount ?? 1),
279
+ };
280
+ }
281
+ }
282
+ return collapsed;
250
283
  }
251
284
  function fillProcessDetails(group) {
252
285
  const usedToolIds = new Set();
@@ -289,6 +322,7 @@ function fillProcessDetails(group) {
289
322
  items.push({
290
323
  kind: "text",
291
324
  text: block.text,
325
+ assistantMessageId: assistant.id,
292
326
  ...(anchorSeq === undefined ? {} : { eventSeq: anchorSeq }),
293
327
  createdAt: anchorAt,
294
328
  });
@@ -353,8 +387,16 @@ function fillProcessDetails(group) {
353
387
  }
354
388
  addAssistantNode(assistant, getDisplayableAssistantBlocks(assistant.content ?? []));
355
389
  }
356
- const blocks = finalAssistant?.content ?? [];
357
- const split = splitFinalAssistantBlocks(blocks, { isStreaming: group.live });
390
+ const blocks = finalAssistant?.content ??
391
+ (finalAssistant?.text.trim()
392
+ ? [{ type: "text", text: finalAssistant.text }]
393
+ : []);
394
+ // Without durable step-completion metadata, only a normally settled Turn can
395
+ // prove that its latest assistant generation is a final answer. Keep partial
396
+ // text in process for running/aborted/failed and legacy unknown states.
397
+ const split = splitFinalAssistantBlocks(blocks, {
398
+ isStreaming: group.live || group.turnState !== "settled",
399
+ });
358
400
  if (finalAssistant) {
359
401
  addAssistantNode(finalAssistant, split.processBlocks);
360
402
  }
@@ -391,10 +433,6 @@ function fillProcessDetails(group) {
391
433
  group.processItems = collapseDagProgressItems(processItems);
392
434
  if (!finalAssistant)
393
435
  return;
394
- if (blocks.length === 0) {
395
- group.answer = finalAssistant;
396
- return;
397
- }
398
436
  if (split.answerBlocks.length === 0)
399
437
  return;
400
438
  const answerText = joinAssistantText(split.answerBlocks);
@@ -404,9 +442,6 @@ function fillProcessDetails(group) {
404
442
  content: split.answerBlocks,
405
443
  };
406
444
  }
407
- /**
408
- * Build user → process → answer groups for the message timeline.
409
- */
410
445
  export function buildTurnProcessGroups(input) {
411
446
  const visible = ensureLinkedSubmissionOrder(input.messages
412
447
  .filter((m) => m.role === "user" || m.role === "assistant")
@@ -523,10 +558,21 @@ export function buildTurnProcessGroups(input) {
523
558
  }
524
559
  return a.createdAt - b.createdAt;
525
560
  });
561
+ const turnState = group.turnId
562
+ ? input.turnStatesById?.[group.turnId]
563
+ : undefined;
564
+ if (turnState !== undefined)
565
+ group.turnState = turnState;
566
+ group.turnClosed =
567
+ turnState === "settled" ||
568
+ turnState === "aborted" ||
569
+ turnState === "failed";
570
+ const targetsActiveAssistant = input.activeAssistantId !== null &&
571
+ group.assistants.some((assistant) => assistant.id === input.activeAssistantId);
526
572
  group.live =
527
573
  input.streaming &&
528
- input.activeAssistantId !== null &&
529
- group.assistants.some((a) => a.id === input.activeAssistantId);
574
+ !group.turnClosed &&
575
+ (targetsActiveAssistant || turnState === "running");
530
576
  fillProcessDetails(group);
531
577
  group.conversationItems = (input.conversationNodes ?? [])
532
578
  .filter((node) => node.location.kind !== "session" && node.location.turnId === group.turnId)