@tea-agent/loop-agent 0.44.0-next.2 → 0.44.0-next.4

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 (98) hide show
  1. package/CHANGELOG.md +5 -3
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +2 -2
  4. package/dist/task/config-types.js +2 -1
  5. package/dist/task/contract/schema.js +1 -0
  6. package/dist/worker/console/chat/chat-event-store.js +32 -0
  7. package/dist/worker/console/chat/conversation-nodes.js +383 -0
  8. package/dist/worker/console/chat/db-connections.js +48 -16
  9. package/dist/worker/console/chat/pi-runtime.js +131 -15
  10. package/dist/worker/console/chat/provider-error.js +0 -12
  11. package/dist/worker/console/chat/routes.js +136 -3
  12. package/dist/worker/console/chat/turn-process.js +128 -7
  13. package/dist/worker/console/prd-intake-bridge.js +3 -0
  14. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-B8EdIjdl.js → abnfDiagram-N423BO3Z-B_lmJjOm.js} +1 -1
  15. package/dist/worker/console/static/assets/{arc-CUaduV17.js → arc-pymM4Qeh.js} +1 -1
  16. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-bYAcxRaf.js → architectureDiagram-T3A2C74G-Bw9EPAhi.js} +1 -1
  17. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-FfhFUEMJ.js → blockDiagram-VBNYF7ZC-C7_oXJp2.js} +1 -1
  18. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-0Rqwn2_U.js → c4Diagram-5PPSVZJV-LnLWDbh8.js} +1 -1
  19. package/dist/worker/console/static/assets/channel-Ym13M3Lb.js +1 -0
  20. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D4FbDmsM.js → chunk-2GRJ4B5K-BBiu3sCW.js} +1 -1
  21. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-C2nu32ml.js → chunk-2Q5K7J3B-DiXqDQml.js} +1 -1
  22. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-G2tHSClK.js → chunk-5RXB4S5H-lmNq9nYk.js} +1 -1
  23. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-181nyzuH.js → chunk-5VM5RSS4-boBu74dU.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-D091VbiQ.js → chunk-6Q2QTUOP-CjxqoJLQ.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-BjrMCKTH.js → chunk-GF5L2VYU-DL0vQ9MG.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-T3zFHTpb.js → chunk-JWPE2WC7-DGPqE-O0.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CIB7wrYq.js → chunk-KBJHAD2P-v4BbT_r2.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-HwnAsgDw.js → chunk-RYQCIY6F-Ci0ynvyL.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CQSdQAqo.js → chunk-XXDRQBXY-CGsbJoTV.js} +1 -1
  30. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BeFR1wGo.js +1 -0
  31. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BeFR1wGo.js +1 -0
  32. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CyBVLpjP.js → cose-bilkent-JH36ORCC-CzoRDEI7.js} +1 -1
  33. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-D2Pf3U7m.js → cynefin-VYW2F7L2-BOwFwbX4.js} +1 -1
  34. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-CzXuQ1a2.js → cynefinDiagram-MW4NZA55-2Cfg451l.js} +1 -1
  35. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-9cMmcOsQ.js → dagre-VZM6K2ZE-DbT0D6Y5.js} +1 -1
  36. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DTOr8GLs.js → diagram-7IWD3JNH-Dfd_JoMs.js} +1 -1
  37. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B8saqFHU.js → diagram-B4RE2ZJO-DaGm82kh.js} +1 -1
  38. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Bvygp--w.js → diagram-LBJQPF4R-DMoI8Ibc.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CJyLwBhZ.js → diagram-Q27KOJAE-BtIFA3aY.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CkR1FDRN.js → diagram-UB23O5K3-D_2X1DTZ.js} +1 -1
  41. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BsbNtwvi.js → ebnfDiagram-BXEA7PRR-yFW5ZYlZ.js} +1 -1
  42. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-EVZo9AKy.js → erDiagram-JOGREHBK-DBXh5Svj.js} +1 -1
  43. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-C1x91tWN.js → flowDiagram-UKHOOZJN-C4ycm8KM.js} +1 -1
  44. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CiNBUsrJ.js → ganttDiagram-PKOTCBZU-qr-_Pekr.js} +1 -1
  45. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CSd50p3d.js → gitGraphDiagram-DS77QQ5N-DzOSLv_A.js} +1 -1
  46. package/dist/worker/console/static/assets/index-PFjB6g6l.js +482 -0
  47. package/dist/worker/console/static/assets/index-ZtyB6Ol-.css +1 -0
  48. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-1JDiYCuA.js → infoDiagram-6WML65LV-C7Hl8D7l.js} +1 -1
  49. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BZLE6zpm.js → ishikawaDiagram-WSZJBQD7-fBxVlr6t.js} +1 -1
  50. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cg0nDmbe.js → journeyDiagram-NVQOT4AX-CoiJEVCl.js} +1 -1
  51. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DcwEdKjq.js → kanban-definition-27J2QSJJ-TJXpxNuV.js} +1 -1
  52. package/dist/worker/console/static/assets/{linear-CXvVge5x.js → linear-CjQWz1pG.js} +1 -1
  53. package/dist/worker/console/static/assets/{mermaid.core-5R4WTYSN.js → mermaid.core-DY9_apIE.js} +5 -5
  54. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-By9pSeeq.js → mindmap-definition-FAOFIHXS-HB0Id8gM.js} +1 -1
  55. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DzbVZTqW.js → pegDiagram-VL7TDLO6-0OSWgr6v.js} +1 -1
  56. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B9nbfnAq.js → pieDiagram-7S7Q4E2Y-Cei1GGC2.js} +1 -1
  57. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-D268jfWH.js → quadrantDiagram-CIZ2JOQS-CDy0Adfu.js} +1 -1
  58. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-oNOXPRNn.js → railroadDiagram-AXF67PYL-BwqzUWwN.js} +1 -1
  59. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DXdr8K-4.js → requirementDiagram-LRYGKXZP-Bif2IBRL.js} +1 -1
  60. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Dtm4uOJ0.js → sankeyDiagram-W5VNT64P-CYIoNMLg.js} +1 -1
  61. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-CeeFiof4.js → sequenceDiagram-SI44F4Z6-BDS7Y0aQ.js} +1 -1
  62. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CK1gARll.js → sizeCapture-X5ZJPWSS-lZic-_Bp.js} +1 -1
  63. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-3jMoAcZw.js → stateDiagram-OKZ733FA-WCVlCqSd.js} +1 -1
  64. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-cj5UUB6m.js +1 -0
  65. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Div6kwoD.js → swimlanes-SLNWSIFB-pGccxTw_.js} +2 -2
  66. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-C_Qp6bnh.js +8 -0
  67. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-B7P_6Up9.js → timeline-definition-Z64GVDOM-D29RlrQA.js} +1 -1
  68. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-eOPaGUOX.js → vennDiagram-T6HMQDX7-01fEM1NU.js} +1 -1
  69. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-tAcDnb31.js → wardleyDiagram-T6FBY63Y-C4GeB6dq.js} +1 -1
  70. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Dg_r_785.js → xychartDiagram-ELKLHX3M-BXHrXHps.js} +1 -1
  71. package/dist/worker/console/static/index.html +2 -2
  72. package/dist/worker/console/static-src/operator-chat/animation-frame-batcher.js +38 -0
  73. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +97 -3
  74. package/dist/worker/console/static-src/operator-chat/process-label.js +11 -2
  75. package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +2 -1
  76. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
  77. package/dist/worker/console/static-src/operator-chat/useChatThread.js +9 -0
  78. package/dist/worker/console/workspace-context.js +1 -0
  79. package/dist/worker/observe/static/console-theme.css +174 -0
  80. package/dist/worker/observe/static/console-theme.js +36 -0
  81. package/dist/worker/observe/static/operator-chrome.css +85 -0
  82. package/dist/worker/observe/static/operator-chrome.js +51 -0
  83. package/dist/workflows/dag/frontend-implementation-contract.js +3 -1
  84. package/dist/workflows/dag/frontend-plan-completeness.js +13 -3
  85. package/dist/workflows/dag/frontend-recovery-plan.js +10 -6
  86. package/dist/workflows/dag/frontend-typed-event-store.js +5 -0
  87. package/dist/workflows/dag/frontend-writer-admission.js +1 -0
  88. package/dist/workflows/dag/output-protocol.js +1 -0
  89. package/dist/workflows/dag/rerun-task.js +1 -0
  90. package/docs/governance/harness-methodology-verification.md +11 -0
  91. package/package.json +1 -1
  92. package/dist/worker/console/static/assets/channel-ICokO4I1.js +0 -1
  93. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0c0PBcA.js +0 -1
  94. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0c0PBcA.js +0 -1
  95. package/dist/worker/console/static/assets/index-DHBWEwz_.css +0 -1
  96. package/dist/worker/console/static/assets/index-RFWQLUE-.js +0 -478
  97. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cr_kaxrX.js +0 -1
  98. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bl4fQmZh.js +0 -8
package/CHANGELOG.md CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- - Operator Chat 在后台断线重连后不再丢消息:检测到中断会自动补齐缺口内容,也不会把正常的历史回放误判成丢事件而反复重连。
5
+ - Console 新增低饱和的「暮色蓝」深色与「雾光白」浅色主题,保留默认暖纸色;柔和背景、轻量选中态与独立按钮配色减少视觉刺激。顶部仅显示带「主题」悬停提示的紧凑明暗图标按钮,点击后展开「默认 / 深色 / 浅色」,选完自动收起,支持点击外部或 Escape 关闭;初始使用默认主题;即时切换对话、操作与观测界面,刷新后记住选择,浏览器存储不可用时仍可在当前页面切换。
6
+ - Operator Chat 消息流后续对齐:折叠摘要现在统计 retry/step/command/unknown 等 durable 过程节点,Trajectory 与 HTML 导出保留 DAG progress,并使用稳定过程身份避免直播插入导致 Thinking/工具行重挂载。
7
+ - Operator Chat 统一主时间线、Trajectory 与导出使用同一 DSH flow projection:Thinking、工具、重试、命令和完成态节点按事件序交错,保留 completed-turn 折叠;request/unknown 详情改为有界结构化摘要,不再回显裸节点 JSON。
8
+ - Operator Chat 的消息流对齐 DSH Conversation Node:模型步骤、重试、终止错误、输出上限、每次请求上下文、嵌套工具、`/sdd-bingo` 命令回执和未知事件均以稳定节点留痕;同一模型步骤的多次重试聚合为一条紧凑记录,Provider 终态错误改为轻量行且不再暴露原始响应;流式文本按动画帧合并发布,刷新和断线恢复保持同一身份与顺序,轨迹页面和导出同步展示。
9
+ - Operator Chat 在后台断线重连后不再丢消息:检测到中断会自动补齐缺口内容,也不会把正常的历史回放误判成丢事件而反复重连。
6
10
  - Operator Chat 的恢复快照现在只会合并属于目标会话的乐观提交;切换会话后,旧会话尚未确认的输入不会再因延迟重连恢复而显示到新会话中。
7
11
  - `/sdd-bingo` 成功提示现在展示上报的 Skill 与 Session ID;轨迹顶部会出现当前会话的常驻 SDD 身份栏,可一键复制、首次请求后自动更新;该上报以独立“操作”事件留痕,刷新或导出后仍在,不再伪装成工具调用。
8
12
  - Operator Chat 发送消息后的空白光标改为 Codex 式“正在思考”,真实思考、工具过程或最终回答出现后自然接替;并修复了状态偶发显示在用户输入上方的问题。
@@ -701,5 +705,3 @@
701
705
  ## 历史版本
702
706
 
703
707
  0.35.x 及更早(0.8.0 – 0.35.4-beta.0)的发布说明已归档到 [`CHANGELOG/CHANGELOG-0.35-and-earlier.md`](CHANGELOG/CHANGELOG-0.35-and-earlier.md);版本线维护边界与归档规则见 [`CHANGELOG/README.md`](CHANGELOG/README.md)。
704
-
705
-
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.44.0-next.2",
4
- "gitSha": "440f78f47ea6ba9ec7206387060b3090527c7aaf",
5
- "builtAt": "2026-09-15T01:01:15.370Z"
3
+ "version": "0.44.0-next.4",
4
+ "gitSha": "6d1cd2f6dd13854cd48bcd4b38718a53cc24109a",
5
+ "builtAt": "2026-09-15T17:21:15.173Z"
6
6
  }
@@ -6274,7 +6274,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
6274
6274
  });
6275
6275
  }
6276
6276
  }
6277
- const promptForMissingPhase = (missing) => session.coverageOnly
6277
+ const promptForMissingPhase = (missing) => `Actionable completeness findings (repair only these facts; preserve unrelated committed facts):\n${missing.map((item) => `- fact=${item.kind}${item.id ? `/${item.id}` : ""}; requirements=${item.requirementIds.join(",")}; reason=${item.reason}; owner=${item.owner ?? "Plan"}; repairScope=${item.repairScope ?? "fact"}; next=${item.nextAction ?? "replace-fact-and-finalize"}`).join("\n")}\n\n` + (session.coverageOnly
6278
6278
  ? buildCoveragePrompt(session.coverageSlice ?? [], missing)
6279
6279
  : isCompactLocalSession
6280
6280
  ? buildCompactLocalPrompt(missing)
@@ -6282,7 +6282,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
6282
6282
  ? buildUxRegistryPrompt(missing)
6283
6283
  : isUxLocalSession
6284
6284
  ? buildUxPrompt(session.requirementSlice ?? [], missing)
6285
- : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
6285
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds));
6286
6286
  const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
6287
6287
  if (recovery === "stop")
6288
6288
  return { ...result, ok: false };
@@ -308,9 +308,10 @@ const taskConfigObjectSchema = z.object({
308
308
  .object({
309
309
  allowed: z.array(z.string().min(1)).optional(),
310
310
  forbidden: z.array(z.string().min(1)).optional(),
311
+ requiredClauses: z.array(z.string().min(1)).optional(),
311
312
  })
312
313
  .strict()
313
- .refine((entry) => (entry.allowed?.length ?? 0) > 0 || (entry.forbidden?.length ?? 0) > 0, { message: "capabilityBoundary needs allowed or forbidden entries" })
314
+ .refine((entry) => (entry.allowed?.length ?? 0) > 0 || (entry.forbidden?.length ?? 0) > 0 || (entry.requiredClauses?.length ?? 0) > 0, { message: "capabilityBoundary needs allowed or forbidden entries" })
314
315
  .optional(),
315
316
  /** Generation-frozen interaction id vocabulary. Contract interaction names
316
317
  * must come from this set (plan finalize deterministic check). */
@@ -88,6 +88,7 @@ export const taskContractDraftSchema = z
88
88
  .object({
89
89
  allowed: z.array(z.string().min(1)).optional(),
90
90
  forbidden: z.array(z.string().min(1)).optional(),
91
+ requiredClauses: z.array(z.string().min(1)).optional(),
91
92
  })
92
93
  .strict()
93
94
  .optional(),
@@ -327,6 +327,10 @@ export function projectChatToolEventPayload(input) {
327
327
  toolName: input.toolName,
328
328
  argsPreview: preview,
329
329
  previewHash,
330
+ ...(input.step === undefined ? {} : { step: input.step }),
331
+ ...(input.parentToolCallId
332
+ ? { parentToolCallId: input.parentToolCallId }
333
+ : {}),
330
334
  };
331
335
  }
332
336
  const { preview, previewHash } = projectToolResultPreview(input.value);
@@ -336,9 +340,37 @@ export function projectChatToolEventPayload(input) {
336
340
  resultPreview: preview,
337
341
  previewHash,
338
342
  isError: Boolean(input.isError),
343
+ ...(input.step === undefined ? {} : { step: input.step }),
344
+ ...(input.parentToolCallId
345
+ ? { parentToolCallId: input.parentToolCallId }
346
+ : {}),
339
347
  ...(input.operationId ? { operationId: input.operationId } : {}),
340
348
  };
341
349
  }
350
+ export function projectUnknownChatEventPayload(input) {
351
+ const redact = (value, depth) => {
352
+ if (depth >= 6)
353
+ return "[DEPTH_LIMIT]";
354
+ if (Array.isArray(value)) {
355
+ return value.slice(0, 50).map((item) => redact(item, depth + 1));
356
+ }
357
+ if (!value || typeof value !== "object")
358
+ return value;
359
+ const result = {};
360
+ for (const [key, child] of Object.entries(value).slice(0, 100)) {
361
+ result[key] = /authorization|cookie|password|secret|token|api[-_]?key/i.test(key)
362
+ ? "[REDACTED]"
363
+ : redact(child, depth + 1);
364
+ }
365
+ return result;
366
+ };
367
+ const { preview, previewHash } = boundedRedactedJson(redact(input.value, 0));
368
+ return {
369
+ eventType: input.eventType.slice(0, 120),
370
+ preview,
371
+ previewHash,
372
+ };
373
+ }
342
374
  export function projectOperationEventSummary(event) {
343
375
  const messagePreview = boundedRedactedText(event.message, 600);
344
376
  const dataPreview = event.data === undefined
@@ -0,0 +1,383 @@
1
+ function finiteStep(data) {
2
+ return typeof data.step === "number" && Number.isSafeInteger(data.step) && data.step >= 0
3
+ ? data.step
4
+ : undefined;
5
+ }
6
+ function locationFor(event, step = finiteStep(event.data)) {
7
+ return step === undefined
8
+ ? { kind: "turn", turnId: event.turnId }
9
+ : { kind: "step", turnId: event.turnId, step };
10
+ }
11
+ function status(value) {
12
+ return value === "scheduled" ||
13
+ value === "started" ||
14
+ value === "cancelled" ||
15
+ value === "running" ||
16
+ value === "completed" ||
17
+ value === "failed" ||
18
+ value === "interrupted"
19
+ ? value
20
+ : undefined;
21
+ }
22
+ function positiveInteger(value) {
23
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
24
+ ? value
25
+ : undefined;
26
+ }
27
+ function nonNegativeNumber(value) {
28
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
29
+ ? value
30
+ : undefined;
31
+ }
32
+ function nonEmptyString(value) {
33
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
34
+ }
35
+ function unknownRetryNode(event, base) {
36
+ return {
37
+ ...base,
38
+ key: `unknown:${event.eventId}`,
39
+ kind: "unknown",
40
+ location: locationFor(event),
41
+ data: {
42
+ eventType: "model_retry",
43
+ preview: "Incomplete model retry lifecycle event",
44
+ },
45
+ };
46
+ }
47
+ function retryStateFromEvent(data) {
48
+ if (data.state === "scheduled")
49
+ return "scheduled";
50
+ return data.success === false || data.state === "failed"
51
+ ? "cancelled"
52
+ : "started";
53
+ }
54
+ function isRetryAttempt(value) {
55
+ if (!value || typeof value !== "object" || Array.isArray(value))
56
+ return false;
57
+ const attempt = value;
58
+ return (positiveInteger(attempt.attempt) !== undefined &&
59
+ positiveInteger(attempt.maxAttempts) !== undefined &&
60
+ nonNegativeNumber(attempt.delayMs) !== undefined &&
61
+ nonEmptyString(attempt.errorMessage) !== undefined &&
62
+ (attempt.state === "scheduled" ||
63
+ attempt.state === "started" ||
64
+ attempt.state === "cancelled") &&
65
+ typeof attempt.seq === "number" &&
66
+ typeof attempt.at === "number");
67
+ }
68
+ /** Read the current complete attempt from a projected retry-chain node. */
69
+ export function modelRetryCurrent(node) {
70
+ if (node.kind !== "retry")
71
+ return undefined;
72
+ return isRetryAttempt(node.data.current) ? node.data.current : undefined;
73
+ }
74
+ function modelRetryAttempts(node) {
75
+ return Array.isArray(node.data.attempts)
76
+ ? node.data.attempts.filter(isRetryAttempt)
77
+ : [];
78
+ }
79
+ function retryUpdate(node) {
80
+ const value = node.data.retryUpdate;
81
+ if (!value || typeof value !== "object" || Array.isArray(value))
82
+ return undefined;
83
+ const candidate = value;
84
+ if (positiveInteger(candidate.attempt) === undefined ||
85
+ (candidate.state !== "started" && candidate.state !== "cancelled"))
86
+ return undefined;
87
+ return candidate;
88
+ }
89
+ /** Convert one already-redacted durable event into a stable UI node. */
90
+ export function conversationNodeFromEvent(event) {
91
+ const at = Date.parse(event.at);
92
+ const base = {
93
+ anchorSeq: event.seq,
94
+ updatedSeq: event.seq,
95
+ at: Number.isFinite(at) ? at : 0,
96
+ data: event.data,
97
+ };
98
+ const step = finiteStep(event.data);
99
+ switch (event.kind) {
100
+ case "step_start":
101
+ case "step_end": {
102
+ if (step === undefined)
103
+ return undefined;
104
+ const outcome = status(event.data.outcome);
105
+ return {
106
+ ...base,
107
+ key: `step:${event.turnId}:${step}`,
108
+ kind: "step",
109
+ location: { kind: "step", turnId: event.turnId, step },
110
+ status: event.kind === "step_start" ? "running" : (outcome ?? "completed"),
111
+ };
112
+ }
113
+ case "model_retry": {
114
+ const attempt = positiveInteger(event.data.attempt);
115
+ if (attempt === undefined)
116
+ return unknownRetryNode(event, base);
117
+ const retryState = retryStateFromEvent(event.data);
118
+ const key = `retry:${event.turnId}:${step ?? 0}`;
119
+ if (retryState !== "scheduled") {
120
+ return {
121
+ ...base,
122
+ key,
123
+ kind: "retry",
124
+ location: locationFor(event),
125
+ status: retryState,
126
+ data: {
127
+ retryUpdate: {
128
+ attempt,
129
+ state: retryState,
130
+ seq: event.seq,
131
+ at: base.at,
132
+ },
133
+ },
134
+ };
135
+ }
136
+ const maxAttempts = positiveInteger(event.data.maxAttempts);
137
+ const delayMs = nonNegativeNumber(event.data.delayMs);
138
+ const errorMessage = nonEmptyString(event.data.errorMessage);
139
+ if (maxAttempts === undefined || delayMs === undefined || !errorMessage) {
140
+ return unknownRetryNode(event, base);
141
+ }
142
+ const current = {
143
+ attempt,
144
+ maxAttempts,
145
+ delayMs,
146
+ errorMessage,
147
+ state: "scheduled",
148
+ seq: event.seq,
149
+ at: base.at,
150
+ };
151
+ return {
152
+ ...base,
153
+ key,
154
+ kind: "retry",
155
+ location: locationFor(event),
156
+ status: "scheduled",
157
+ data: { attempts: [current], current },
158
+ };
159
+ }
160
+ case "turn_error":
161
+ return {
162
+ ...base,
163
+ key: `turn-error:${event.turnId}`,
164
+ kind: "turn-error",
165
+ location: locationFor(event),
166
+ status: "failed",
167
+ };
168
+ case "turn_max_tokens":
169
+ return {
170
+ ...base,
171
+ key: `max-tokens:${event.turnId}:${step ?? 0}`,
172
+ kind: "max-tokens",
173
+ location: locationFor(event),
174
+ status: "completed",
175
+ };
176
+ case "request_context": {
177
+ const requestId = typeof event.data.requestId === "string" && event.data.requestId
178
+ ? event.data.requestId
179
+ : event.eventId;
180
+ return {
181
+ ...base,
182
+ key: `request:${requestId}`,
183
+ kind: "request",
184
+ location: locationFor(event),
185
+ status: "completed",
186
+ };
187
+ }
188
+ case "command": {
189
+ const commandId = typeof event.data.commandId === "string" && event.data.commandId
190
+ ? event.data.commandId
191
+ : event.eventId;
192
+ return {
193
+ ...base,
194
+ key: `command:${commandId}`,
195
+ kind: "command",
196
+ location: { kind: "turn", turnId: event.turnId },
197
+ status: status(event.data.outcome) ?? "completed",
198
+ };
199
+ }
200
+ case "unknown":
201
+ return {
202
+ ...base,
203
+ key: `unknown:${event.eventId}`,
204
+ kind: "unknown",
205
+ location: locationFor(event),
206
+ };
207
+ }
208
+ }
209
+ export function conversationNodesFromEvents(events) {
210
+ let nodes = [];
211
+ for (const event of events) {
212
+ const node = conversationNodeFromEvent(event);
213
+ if (node)
214
+ nodes = upsertConversationNode(nodes, node);
215
+ }
216
+ return nodes;
217
+ }
218
+ /** Keyed upsert that preserves the first structural anchor and location. */
219
+ export function upsertConversationNode(nodes, next) {
220
+ if (next.kind === "retry")
221
+ return upsertRetryNode(nodes, next);
222
+ const merged = upsertBase(nodes, next);
223
+ if (next.kind === "step" && next.location.kind === "step") {
224
+ const { step: nextStep, turnId } = next.location;
225
+ if (next.status === "running") {
226
+ return transitionScheduledRetries(merged, (node) => node.location.kind === "step" &&
227
+ node.location.turnId === turnId &&
228
+ node.location.step < nextStep, "started", next.updatedSeq);
229
+ }
230
+ return transitionScheduledRetries(merged, (node) => node.location.kind === "step" &&
231
+ node.location.turnId === turnId &&
232
+ node.location.step === nextStep &&
233
+ node.anchorSeq < next.updatedSeq, "cancelled", next.updatedSeq);
234
+ }
235
+ if (next.kind === "turn-error") {
236
+ return transitionScheduledRetries(merged, (node) => node.location.kind !== "session" &&
237
+ next.location.kind !== "session" &&
238
+ node.location.turnId === next.location.turnId, "cancelled", next.updatedSeq);
239
+ }
240
+ return merged;
241
+ }
242
+ function upsertBase(nodes, next) {
243
+ const index = nodes.findIndex((node) => node.key === next.key);
244
+ if (index < 0) {
245
+ return [...nodes, next].sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key));
246
+ }
247
+ const previous = nodes[index];
248
+ if (next.updatedSeq < previous.updatedSeq)
249
+ return [...nodes];
250
+ const merged = {
251
+ ...previous,
252
+ ...next,
253
+ anchorSeq: previous.anchorSeq,
254
+ at: previous.at,
255
+ location: previous.location,
256
+ data: { ...previous.data, ...next.data },
257
+ };
258
+ const result = [...nodes];
259
+ result[index] = merged;
260
+ return result;
261
+ }
262
+ function retryTurnId(node) {
263
+ return node.location.kind === "session" ? undefined : node.location.turnId;
264
+ }
265
+ function retryCandidateIndex(nodes, next) {
266
+ const exact = nodes.findIndex((node) => node.key === next.key);
267
+ if (exact >= 0)
268
+ return exact;
269
+ const update = retryUpdate(next);
270
+ const nextCurrent = modelRetryCurrent(next);
271
+ const attempt = update?.attempt ?? nextCurrent?.attempt;
272
+ const turnId = retryTurnId(next);
273
+ if (attempt === undefined || !turnId)
274
+ return -1;
275
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
276
+ const candidate = nodes[index];
277
+ const current = modelRetryCurrent(candidate);
278
+ if (candidate.kind !== "retry" || retryTurnId(candidate) !== turnId || !current)
279
+ continue;
280
+ if (current.attempt === attempt)
281
+ return index;
282
+ if (nextCurrent?.state === "scheduled" && current.attempt + 1 === attempt)
283
+ return index;
284
+ }
285
+ return -1;
286
+ }
287
+ function upsertRetryNode(nodes, next) {
288
+ const index = retryCandidateIndex(nodes, next);
289
+ const nextCurrent = modelRetryCurrent(next);
290
+ const update = retryUpdate(next);
291
+ if (index < 0) {
292
+ if (nextCurrent)
293
+ return upsertBase(nodes, next);
294
+ return upsertBase(nodes, {
295
+ ...next,
296
+ key: `unknown:${next.key}:${next.updatedSeq}`,
297
+ kind: "unknown",
298
+ status: undefined,
299
+ data: {
300
+ eventType: "model_retry",
301
+ preview: "Orphan model retry lifecycle update",
302
+ },
303
+ });
304
+ }
305
+ const previous = nodes[index];
306
+ if (next.updatedSeq < previous.updatedSeq)
307
+ return [...nodes];
308
+ let attempts = modelRetryAttempts(previous);
309
+ if (nextCurrent) {
310
+ attempts = attempts.map((attempt) => attempt.state === "scheduled" && attempt.attempt < nextCurrent.attempt
311
+ ? { ...attempt, state: "started" }
312
+ : attempt);
313
+ const existing = attempts.findIndex((attempt) => attempt.attempt === nextCurrent.attempt);
314
+ if (existing >= 0)
315
+ attempts[existing] = { ...attempts[existing], ...nextCurrent };
316
+ else
317
+ attempts = [...attempts, nextCurrent];
318
+ }
319
+ else if (update) {
320
+ attempts = attempts.map((attempt) => attempt.attempt === update.attempt
321
+ ? { ...attempt, state: update.state }
322
+ : attempt);
323
+ }
324
+ const current = attempts.at(-1);
325
+ if (!current)
326
+ return [...nodes];
327
+ const result = [...nodes];
328
+ result[index] = {
329
+ ...previous,
330
+ updatedSeq: next.updatedSeq,
331
+ status: current.state,
332
+ data: { attempts, current },
333
+ };
334
+ return result;
335
+ }
336
+ function transitionScheduledRetries(nodes, predicate, state, updatedSeq) {
337
+ return nodes.map((node) => {
338
+ const current = modelRetryCurrent(node);
339
+ if (node.kind !== "retry" || current?.state !== "scheduled" || !predicate(node)) {
340
+ return node;
341
+ }
342
+ const attempts = modelRetryAttempts(node).map((attempt) => attempt.attempt === current.attempt
343
+ ? { ...attempt, state }
344
+ : attempt);
345
+ const nextCurrent = attempts.at(-1);
346
+ return {
347
+ ...node,
348
+ updatedSeq: Math.max(node.updatedSeq, updatedSeq),
349
+ status: state,
350
+ data: { attempts, current: nextCurrent },
351
+ };
352
+ });
353
+ }
354
+ /** Resolve explicit parent links only. Cycles and excessive chains fail flat. */
355
+ export function toolNestingDepths(tools, maxDepth = 32) {
356
+ const byId = new Map(tools.map((tool) => [tool.id, tool]));
357
+ const depths = new Map();
358
+ const cyclic = new Set();
359
+ const resolve = (id, path) => {
360
+ const cached = depths.get(id);
361
+ if (cached !== undefined)
362
+ return cached;
363
+ const cycleAt = path.indexOf(id);
364
+ if (cycleAt >= 0) {
365
+ for (const key of path.slice(cycleAt))
366
+ cyclic.add(key);
367
+ return 0;
368
+ }
369
+ if (path.length >= maxDepth)
370
+ return 0;
371
+ const tool = byId.get(id);
372
+ const parent = tool?.parentToolCallId;
373
+ if (!parent || !byId.has(parent) || parent === id)
374
+ return 0;
375
+ const depth = Math.min(maxDepth, resolve(parent, [...path, id]) + 1);
376
+ return cyclic.has(id) ? 0 : depth;
377
+ };
378
+ for (const tool of tools)
379
+ depths.set(tool.id, resolve(tool.id, []));
380
+ for (const id of cyclic)
381
+ depths.set(id, 0);
382
+ return depths;
383
+ }
@@ -2,7 +2,7 @@
2
2
  * Operator Chat — governed database connection registry (2026-09-12).
3
3
  *
4
4
  * Manages NAMED database connection configs persisted in Console app-data
5
- * (`db/connections.json` under the repo namespace). Credentials NEVER live in
5
+ * (`db/connections.json` under the Console-global app-data namespace). Credentials NEVER live in
6
6
  * the config: sqlite stores an absolute file path; postgres/mysql store safe
7
7
  * host/port/database/user metadata or the NAME of a DSN environment variable.
8
8
  * Password/DSN values are never persisted, echoed, listed, or exported.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import path from "node:path";
16
16
  import os from "node:os";
17
- import { existsSync, readFileSync } from "node:fs";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { mkdir, readFile, writeFile } from "node:fs/promises";
19
19
  import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync } from "node:crypto";
20
20
  import { writeSecureJson } from "../../../infrastructure/console/app-data.js";
@@ -243,14 +243,18 @@ export class DbConnectionRegistry {
243
243
  handles = new Map();
244
244
  runtimePasswords = new Map();
245
245
  credentialVault;
246
+ legacyCredentialVault;
247
+ legacyRoot;
246
248
  loaded = false;
247
249
  write;
248
250
  mutationQueue = Promise.resolve();
249
- constructor(root, persistWriter = writeSecureJson, driverLoader = (moduleId) => import(moduleId)) {
251
+ constructor(root, persistWriter = writeSecureJson, driverLoader = (moduleId) => import(moduleId), legacyRoot) {
250
252
  this.root = root;
251
253
  this.persistWriter = persistWriter;
252
254
  this.driverLoader = driverLoader;
255
+ this.legacyRoot = legacyRoot && path.resolve(legacyRoot) !== path.resolve(root) ? legacyRoot : undefined;
253
256
  this.credentialVault = new LocalEncryptedCredentialVault(root);
257
+ this.legacyCredentialVault = this.legacyRoot ? new LocalEncryptedCredentialVault(this.legacyRoot) : undefined;
254
258
  }
255
259
  /** Wait for the latest persistence write to settle (tests / graceful stop). */
256
260
  async flushed() {
@@ -264,10 +268,25 @@ export class DbConnectionRegistry {
264
268
  * remove): the persisted file is small JSON, so a sync read on first use
265
269
  * keeps list()/get() synchronous. connect() keeps the async reader.
266
270
  */
267
- ensureLoadedSync() {
268
- if (this.loaded)
271
+ ensureLoadedSync(force = false) {
272
+ if (this.loaded && !force)
269
273
  return;
270
274
  const file = this.connectionsFile();
275
+ if (!existsSync(file) && this.legacyRoot) {
276
+ const legacyFile = path.join(this.legacyRoot, "connections.json");
277
+ if (existsSync(legacyFile)) {
278
+ try {
279
+ this.configs = normalizePersisted(JSON.parse(readFileSync(legacyFile, "utf8")));
280
+ mkdirSync(this.root, { recursive: true, mode: 0o700 });
281
+ writeFileSync(file, JSON.stringify([...this.configs.values()], null, 2) + "\n", { mode: 0o600 });
282
+ }
283
+ catch {
284
+ this.configs = new Map();
285
+ }
286
+ this.loaded = true;
287
+ return;
288
+ }
289
+ }
271
290
  if (!existsSync(file)) {
272
291
  this.configs = new Map();
273
292
  this.loaded = true;
@@ -275,11 +294,19 @@ export class DbConnectionRegistry {
275
294
  }
276
295
  try {
277
296
  const raw = JSON.parse(readFileSync(file, "utf8"));
278
- this.configs = normalizePersisted(raw);
297
+ const next = normalizePersisted(raw);
298
+ for (const [name, current] of this.configs) {
299
+ const updated = next.get(name);
300
+ if (!updated || JSON.stringify(updated) !== JSON.stringify(current))
301
+ this.closeHandle(name);
302
+ }
303
+ this.configs = next;
279
304
  }
280
305
  catch {
281
- // unreadable/corrupt file: start empty rather than crash the chat
282
- this.configs = new Map();
306
+ // A concurrent atomic replace can make the file briefly unavailable.
307
+ // Keep the last valid snapshot instead of hiding every connection.
308
+ if (!this.loaded)
309
+ this.configs = new Map();
283
310
  }
284
311
  this.loaded = true;
285
312
  }
@@ -307,7 +334,7 @@ export class DbConnectionRegistry {
307
334
  }
308
335
  async add(input) {
309
336
  return this.mutate(async () => {
310
- this.ensureLoadedSync();
337
+ this.ensureLoadedSync(true);
311
338
  const config = normalizeConnectionInput(input);
312
339
  if (config.mode === "parameters" && (config.passwordMode === "runtime" || config.passwordMode === "encrypted") && typeof input.password !== "string") {
313
340
  throw new DbConnectionError("DB_INVALID_CONFIG", "password is required for a new password connection");
@@ -330,7 +357,7 @@ export class DbConnectionRegistry {
330
357
  }
331
358
  async update(currentName, input) {
332
359
  return this.mutate(async () => {
333
- this.ensureLoadedSync();
360
+ this.ensureLoadedSync(true);
334
361
  const current = this.configs.get(currentName);
335
362
  if (!current) {
336
363
  throw new DbConnectionError("DB_CONNECTION_NOT_FOUND", `no database connection named "${currentName}"`);
@@ -372,7 +399,7 @@ export class DbConnectionRegistry {
372
399
  }
373
400
  async remove(name) {
374
401
  return this.mutate(async () => {
375
- this.ensureLoadedSync();
402
+ this.ensureLoadedSync(true);
376
403
  const current = this.configs.get(name);
377
404
  if (!current)
378
405
  return false;
@@ -388,7 +415,7 @@ export class DbConnectionRegistry {
388
415
  });
389
416
  }
390
417
  exportConfig() {
391
- this.ensureLoadedSync();
418
+ this.ensureLoadedSync(true);
392
419
  return {
393
420
  schemaVersion: 1,
394
421
  exportedAt: new Date().toISOString(),
@@ -397,7 +424,7 @@ export class DbConnectionRegistry {
397
424
  }
398
425
  async importConfig(input, conflict = "skip") {
399
426
  return this.mutate(async () => {
400
- this.ensureLoadedSync();
427
+ this.ensureLoadedSync(true);
401
428
  if (!isRecord(input) || input.schemaVersion !== 1 || !Array.isArray(input.connections)) {
402
429
  throw new DbConnectionError("DB_INVALID_CONFIG", "invalid database connection export document");
403
430
  }
@@ -470,11 +497,11 @@ export class DbConnectionRegistry {
470
497
  }
471
498
  /** Sanitized summaries — never includes any env var or password value. */
472
499
  list() {
473
- this.ensureLoadedSync();
500
+ this.ensureLoadedSync(true);
474
501
  return [...this.configs.values()].map((config) => this.summary(config));
475
502
  }
476
503
  get(name) {
477
- this.ensureLoadedSync();
504
+ this.ensureLoadedSync(true);
478
505
  const config = this.configs.get(name);
479
506
  return config ? this.summary(config) : undefined;
480
507
  }
@@ -542,7 +569,7 @@ export class DbConnectionRegistry {
542
569
  };
543
570
  }
544
571
  getConfig(name) {
545
- this.ensureLoadedSync();
572
+ this.ensureLoadedSync(true);
546
573
  const config = this.configs.get(name);
547
574
  if (!config)
548
575
  throw new DbConnectionError("DB_CONNECTION_NOT_FOUND", `no database connection named "${name}"`);
@@ -606,6 +633,11 @@ export class DbConnectionRegistry {
606
633
  }
607
634
  else if (config.passwordMode === "encrypted") {
608
635
  password = config.passwordRef ? await this.credentialVault.get(config.passwordRef) : undefined;
636
+ if (password === undefined && this.legacyCredentialVault && config.passwordRef) {
637
+ password = await this.legacyCredentialVault.get(config.passwordRef);
638
+ if (password !== undefined)
639
+ await this.credentialVault.set(config.passwordRef, password);
640
+ }
609
641
  if (password === undefined) {
610
642
  throw new DbConnectionError("DB_ENV_MISSING", `encrypted password for connection "${config.name}" is not configured; edit the connection and enter it again`);
611
643
  }