@tea-agent/loop-agent 0.40.0-next.1 → 0.40.0-next.10

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 (208) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +106 -109
  3. package/README.md +2 -2
  4. package/dist/application/task-lifecycle/advance.js +12 -0
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/update/runtime-activity.js +1 -29
  7. package/dist/commands/dag-rerun.js +1 -1
  8. package/dist/executors/dag-pi-executor.js +69 -7
  9. package/dist/executors/pi-executor.js +4 -2
  10. package/dist/executors/pi-sdk-executor.js +46 -1
  11. package/dist/executors/pi-writer-tool-policy.js +57 -1
  12. package/dist/executors/shell-executor.js +43 -3
  13. package/dist/executors/workspace-write-snapshot.js +68 -0
  14. package/dist/infrastructure/harness/atomic-write.js +23 -0
  15. package/dist/shared/dag-prompt-override.js +4 -5
  16. package/dist/shared/update/npm-client.js +33 -11
  17. package/dist/task/config-types.js +10 -0
  18. package/dist/task/contract/apply.js +11 -1
  19. package/dist/task/contract/constants.js +2 -0
  20. package/dist/task/contract/hash.js +3 -0
  21. package/dist/task/contract/observe.js +25 -4
  22. package/dist/task/contract/paths.js +2 -1
  23. package/dist/task/contract/project.js +15 -1
  24. package/dist/task/contract/schema.js +2 -0
  25. package/dist/task/contract/transaction.js +12 -0
  26. package/dist/task/source-prepare/completeness.js +188 -0
  27. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  28. package/dist/task/source-prepare/index.js +3 -0
  29. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  30. package/dist/task/source-prepare/ledger-review.js +214 -0
  31. package/dist/task/source-prepare/ledger.js +545 -0
  32. package/dist/task/source-prepare/prepare.js +262 -1
  33. package/dist/task/source-prepare/semantic-intake.js +19 -3
  34. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  35. package/dist/task/source-prepare/types.js +22 -0
  36. package/dist/worker/cli.js +12 -26
  37. package/dist/worker/console/chat/chat-event-store.js +75 -5
  38. package/dist/worker/console/chat/context-panel.js +7 -0
  39. package/dist/worker/console/chat/deferred-turn.js +12 -0
  40. package/dist/worker/console/chat/pi-runtime.js +331 -18
  41. package/dist/worker/console/chat/provider-error.js +98 -0
  42. package/dist/worker/console/chat/routes.js +389 -56
  43. package/dist/worker/console/chat/session-stats.js +179 -0
  44. package/dist/worker/console/chat/session-store.js +93 -63
  45. package/dist/worker/console/chat/todos.js +115 -0
  46. package/dist/worker/console/chat/tool-preview.js +42 -0
  47. package/dist/worker/console/chat/turn-process.js +20 -55
  48. package/dist/worker/console/chat/user-questions.js +266 -0
  49. package/dist/worker/console/chat/workspace-landing.js +79 -0
  50. package/dist/worker/console/console-handoff.js +82 -0
  51. package/dist/worker/console/console-update-and-init.js +115 -0
  52. package/dist/worker/console/console-update-runtime.js +84 -0
  53. package/dist/worker/console/draft-store.js +49 -0
  54. package/dist/worker/console/native-directory-picker.js +13 -0
  55. package/dist/worker/console/operation-store.js +1 -1
  56. package/dist/worker/console/operator-actions.js +129 -4
  57. package/dist/worker/console/operator-surface-health.js +1 -0
  58. package/dist/worker/console/prd-intake-bridge.js +13 -0
  59. package/dist/worker/console/routes.js +312 -1
  60. package/dist/worker/console/security.js +4 -4
  61. package/dist/worker/console/server.js +583 -172
  62. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  63. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  64. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  65. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  66. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  67. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  68. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  69. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  70. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  71. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  78. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  79. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  80. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  81. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  82. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  83. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  84. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  85. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  86. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  89. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  90. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  91. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  92. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  93. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  94. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  95. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  96. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  97. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  98. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  99. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  100. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  101. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  102. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  103. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  104. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  105. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  106. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  107. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  108. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  109. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  110. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  111. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  112. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  113. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  114. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  115. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  116. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  117. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  118. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  119. package/dist/worker/console/static/index.html +2 -2
  120. package/dist/worker/console/static-src/app/console-types.js +68 -4
  121. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  122. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  123. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  124. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  125. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  126. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  127. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  128. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  129. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  130. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  131. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  132. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  133. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  134. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  135. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  136. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  137. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  138. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  139. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  140. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  141. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  142. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  143. package/dist/worker/console/workspace-context.js +234 -0
  144. package/dist/worker/console/workspace-registry.js +214 -0
  145. package/dist/worker/observability/init-runtime-activity.js +26 -0
  146. package/dist/worker/observe/node-input.js +88 -2
  147. package/dist/worker/observe/routes.js +188 -0
  148. package/dist/worker/observe/server.js +4 -0
  149. package/dist/worker/observe/static/api.js +29 -9
  150. package/dist/worker/observe/static/app.js +18 -70
  151. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  152. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  153. package/dist/worker/observe/static/format.js +37 -0
  154. package/dist/worker/observe/static/index.html +6 -1
  155. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  156. package/dist/worker/observe/static/operator-chrome.css +145 -20
  157. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  158. package/dist/worker/observe/static/operator-chrome.js +330 -100
  159. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  160. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  161. package/dist/worker/observe/static/router.d.ts +46 -0
  162. package/dist/worker/observe/static/router.js +25 -1
  163. package/dist/worker/observe/static/state.d.ts +50 -0
  164. package/dist/worker/observe/static/state.js +174 -2
  165. package/dist/worker/observe/static/styles.css +357 -35
  166. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  167. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  168. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  169. package/dist/worker/observe/static/views/dag.js +6 -4
  170. package/dist/worker/observe/static/views/dashboard.js +6 -4
  171. package/dist/worker/observe/static/views/night.js +2 -2
  172. package/dist/worker/observe/static/views/pool.js +1 -0
  173. package/dist/worker/observe/static/views/run.js +3 -1
  174. package/dist/worker/observe/static/views/task.js +56 -1
  175. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +29 -1
  176. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  177. package/dist/workflows/dag/backend-test-pytest-collection.js +98 -14
  178. package/dist/workflows/dag/backend-test-writer-completeness.js +17 -17
  179. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  180. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  181. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  182. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  183. package/dist/workflows/dag/frontend-review-context.js +92 -10
  184. package/dist/workflows/dag/init-hybrid.js +238 -51
  185. package/dist/workflows/dag/interrupt-request.js +3 -3
  186. package/dist/workflows/dag/lifecycle.js +3 -3
  187. package/dist/workflows/dag/node-execution.js +45 -0
  188. package/dist/workflows/dag/types.js +62 -12
  189. package/docs/README.md +2 -0
  190. package/docs/architecture/evolution.md +45 -0
  191. package/docs/init-surface.manifest.json +1 -0
  192. package/docs/operations/local-development-environment.md +21 -0
  193. package/docs/templates/README.md +1 -1
  194. package/docs/templates/agent-dag.schema.json +18 -0
  195. package/docs/templates/backend-test-dag.json +265 -237
  196. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  197. package/harness.json +3 -3
  198. package/package.json +3 -1
  199. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  200. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  201. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  202. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  203. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  204. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  205. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  206. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  207. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  208. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -53,6 +53,9 @@ export function hydrateSessionThread(raw) {
53
53
  role: "assistant",
54
54
  text: item.text,
55
55
  createdAt,
56
+ ...(item.kind === "provider-error" && item.providerError
57
+ ? { kind: item.kind, providerError: item.providerError }
58
+ : {}),
56
59
  ...(content && content.length > 0 ? { content } : {}),
57
60
  });
58
61
  flushPendingToAssistant(item.id);
@@ -154,9 +157,23 @@ function appendAssistantUnmatchedTools(group, assistant, processItems, usedToolI
154
157
  function fillProcessDetails(group) {
155
158
  const usedToolIds = new Set();
156
159
  const processItems = [];
157
- const prior = group.assistants.slice(0, -1);
158
- const finalAssistant = group.assistants.at(-1);
159
- for (const assistant of prior) {
160
+ const lastAssistant = group.assistants.at(-1);
161
+ const finalAssistant = lastAssistant?.kind === "provider-error" ? undefined : lastAssistant;
162
+ const finalIndex = finalAssistant
163
+ ? group.assistants.lastIndexOf(finalAssistant)
164
+ : -1;
165
+ for (const [index, assistant] of group.assistants.entries()) {
166
+ if (index === finalIndex)
167
+ continue;
168
+ if (assistant.kind === "provider-error" && assistant.providerError) {
169
+ emitProcessBlocks(getDisplayableAssistantBlocks(assistant.content ?? []), group, processItems, usedToolIds);
170
+ appendAssistantUnmatchedTools(group, assistant, processItems, usedToolIds);
171
+ processItems.push({
172
+ kind: "provider-error",
173
+ error: assistant.providerError,
174
+ });
175
+ continue;
176
+ }
160
177
  emitProcessBlocks(getDisplayableAssistantBlocks(assistant.content ?? []), group, processItems, usedToolIds);
161
178
  appendAssistantUnmatchedTools(group, assistant, processItems, usedToolIds);
162
179
  }
@@ -292,55 +309,3 @@ export function selectRecentTurnGroups(groups, limit) {
292
309
  return [...groups];
293
310
  return groups.slice(-limit);
294
311
  }
295
- export function processGroupSummary(tools, options) {
296
- const count = tools.length;
297
- const thinkingCount = options?.thinkingCount ?? 0;
298
- const textCount = options?.textCount ?? 0;
299
- const activityCount = options?.activityCount ?? 0;
300
- const stepCount = thinkingCount + textCount + activityCount + count;
301
- const errorCount = tools.filter((t) => t.status === "error").length +
302
- (options?.activityErrorCount ?? 0);
303
- const runningCount = tools.filter((t) => t.status === "running").length +
304
- (options?.activityRunningCount ?? 0);
305
- const parts = [`处理详情 · ${stepCount} 步`];
306
- if (count > 0)
307
- parts.push(`${count} 工具`);
308
- if (activityCount > 0)
309
- parts.push(`${activityCount} 进展`);
310
- if (errorCount > 0)
311
- parts.push(`${errorCount} 出错`);
312
- else if (runningCount > 0)
313
- parts.push("运行中");
314
- return { count, errorCount, runningCount, stepCount, label: parts.join(" · ") };
315
- }
316
- /** Summary over rendered process items. */
317
- export function summarizeProcessItems(items) {
318
- const tools = [];
319
- let thinkingCount = 0;
320
- let textCount = 0;
321
- let activityCount = 0;
322
- let activityErrorCount = 0;
323
- let activityRunningCount = 0;
324
- for (const item of items) {
325
- if (item.kind === "thinking")
326
- thinkingCount += 1;
327
- else if (item.kind === "text")
328
- textCount += 1;
329
- else if (item.kind === "activity") {
330
- activityCount += 1;
331
- if (item.activity.kind === "error")
332
- activityErrorCount += 1;
333
- else if (item.activity.kind === "progress")
334
- activityRunningCount += 1;
335
- }
336
- else
337
- tools.push(item.tool);
338
- }
339
- return processGroupSummary(tools, {
340
- thinkingCount,
341
- textCount,
342
- activityCount,
343
- activityErrorCount,
344
- activityRunningCount,
345
- });
346
- }
@@ -0,0 +1,266 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import path from "node:path";
3
+ import { readJsonIfExists, writeSecureJson } from "../app-data.js";
4
+ function questionFile(root, sessionId) {
5
+ const safe = sessionId.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 180);
6
+ return path.join(root, `${safe}.json`);
7
+ }
8
+ function isRecord(value) {
9
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
10
+ }
11
+ function normalizeQuestions(value) {
12
+ if (!Array.isArray(value) || value.length === 0 || value.length > 20) {
13
+ throw new Error("ask_user_question requires 1-20 questions");
14
+ }
15
+ const ids = new Set();
16
+ return value.map((raw, index) => {
17
+ if (!isRecord(raw))
18
+ throw new Error(`question ${index + 1} must be an object`);
19
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
20
+ const question = typeof raw.question === "string" ? raw.question.trim() : "";
21
+ if (!id || !question)
22
+ throw new Error(`question ${index + 1} requires id and question`);
23
+ if (ids.has(id))
24
+ throw new Error(`duplicate question id: ${id}`);
25
+ ids.add(id);
26
+ const options = raw.options === undefined ? undefined : normalizeOptions(raw.options, id);
27
+ return {
28
+ id,
29
+ question,
30
+ ...(typeof raw.header === "string" && raw.header.trim()
31
+ ? { header: raw.header.trim().slice(0, 80) }
32
+ : {}),
33
+ ...(typeof raw.detail === "string" && raw.detail.trim()
34
+ ? { detail: raw.detail.trim().slice(0, 4000) }
35
+ : {}),
36
+ ...(options ? { options } : {}),
37
+ ...(raw.multi_select === true ? { multiSelect: true } : {}),
38
+ };
39
+ });
40
+ }
41
+ function normalizeOptions(value, questionId) {
42
+ if (!Array.isArray(value) || value.length === 0 || value.length > 12) {
43
+ throw new Error(`question ${questionId} options must contain 1-12 items`);
44
+ }
45
+ const labels = new Set();
46
+ return value.map((raw, index) => {
47
+ if (!isRecord(raw) || typeof raw.label !== "string" || !raw.label.trim()) {
48
+ throw new Error(`question ${questionId} option ${index + 1} requires a label`);
49
+ }
50
+ const label = raw.label.trim();
51
+ if (labels.has(label))
52
+ throw new Error(`duplicate option label: ${label}`);
53
+ labels.add(label);
54
+ return {
55
+ label,
56
+ ...(typeof raw.description === "string" && raw.description.trim()
57
+ ? { description: raw.description.trim().slice(0, 300) }
58
+ : {}),
59
+ };
60
+ });
61
+ }
62
+ function normalizeAnswer(value, questions) {
63
+ if (!isRecord(value) || !Array.isArray(value.answers))
64
+ throw new Error("answers must be an array");
65
+ const byId = new Map(questions.map((question) => [question.id, question]));
66
+ const seen = new Set();
67
+ const answers = value.answers.map((raw) => {
68
+ if (!isRecord(raw) || typeof raw.id !== "string")
69
+ throw new Error("answer id is required");
70
+ const id = raw.id.trim();
71
+ const question = byId.get(id);
72
+ if (!question || seen.has(id))
73
+ throw new Error(`answer does not match question: ${id}`);
74
+ seen.add(id);
75
+ const selected = Array.isArray(raw.selected)
76
+ ? raw.selected.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean)
77
+ : [];
78
+ const allowed = new Set((question.options ?? []).map((option) => option.label));
79
+ if (selected.some((item) => !allowed.has(item)))
80
+ throw new Error(`answer contains an unknown option for ${id}`);
81
+ if (!question.multiSelect && selected.length > 1)
82
+ throw new Error(`question ${id} accepts one option`);
83
+ const custom = typeof raw.custom === "string" ? raw.custom.trim().slice(0, 4000) : "";
84
+ return { id, selected, ...(custom ? { custom } : {}) };
85
+ });
86
+ if (seen.size !== questions.length)
87
+ throw new Error("every question requires an answer or skip entry");
88
+ return { answers };
89
+ }
90
+ export class UserQuestionRegistry {
91
+ root;
92
+ pending = new Map();
93
+ /** Serialize first-write and resolve/cancel writes for one session. */
94
+ persistence = new Map();
95
+ waiters = new Map();
96
+ turnIds = new Map();
97
+ listeners = new Set();
98
+ constructor(root) {
99
+ this.root = root;
100
+ }
101
+ setTurn(sessionId, turnId) {
102
+ this.turnIds.set(sessionId, turnId);
103
+ }
104
+ clearTurn(sessionId) {
105
+ this.turnIds.delete(sessionId);
106
+ }
107
+ subscribe(listener) {
108
+ this.listeners.add(listener);
109
+ return () => this.listeners.delete(listener);
110
+ }
111
+ async get(sessionId) {
112
+ const memory = this.pending.get(sessionId);
113
+ if (memory?.status === "pending")
114
+ return structuredClone(memory);
115
+ const disk = await readJsonIfExists(questionFile(this.root, sessionId));
116
+ if (disk?.status === "pending") {
117
+ this.pending.set(sessionId, disk);
118
+ return structuredClone(disk);
119
+ }
120
+ return undefined;
121
+ }
122
+ async ask(input) {
123
+ const questions = normalizeQuestions(input.questions);
124
+ if (input.signal?.aborted)
125
+ throw new Error("USER_QUESTION_ABORTED");
126
+ if (await this.get(input.sessionId))
127
+ throw new Error("USER_QUESTION_ALREADY_PENDING");
128
+ // Two tool calls can finish the disk lookup in the same turn. Re-check the
129
+ // in-memory lease after that await so only the first one can publish.
130
+ if (this.pending.get(input.sessionId)?.status === "pending") {
131
+ throw new Error("USER_QUESTION_ALREADY_PENDING");
132
+ }
133
+ const now = new Date().toISOString();
134
+ const question = {
135
+ schemaVersion: 1,
136
+ requestId: `question_${randomBytes(12).toString("hex")}`,
137
+ sessionId: input.sessionId,
138
+ toolCallId: input.toolCallId,
139
+ ...(this.turnIds.get(input.sessionId) ? { turnId: this.turnIds.get(input.sessionId) } : {}),
140
+ questions,
141
+ createdAt: now,
142
+ updatedAt: now,
143
+ status: "pending",
144
+ version: 1,
145
+ };
146
+ this.pending.set(input.sessionId, question);
147
+ // Register the waiter before the first persistence await. A turn abort can
148
+ // arrive while the request is being written; registering afterwards leaves
149
+ // cancel() with nobody to reject and the tool promise would hang forever.
150
+ let persisted = false;
151
+ let abortRequested = false;
152
+ const waiting = new Promise((resolve, reject) => {
153
+ const onAbort = () => {
154
+ abortRequested = true;
155
+ if (persisted) {
156
+ void this.cancel(input.sessionId, question.requestId, "TURN_ABORTED").catch(reject);
157
+ }
158
+ };
159
+ this.waiters.set(input.sessionId, {
160
+ resolve: (answer) => {
161
+ input.signal?.removeEventListener("abort", onAbort);
162
+ resolve(answer);
163
+ },
164
+ reject: (error) => {
165
+ input.signal?.removeEventListener("abort", onAbort);
166
+ reject(error);
167
+ },
168
+ });
169
+ if (input.signal?.aborted)
170
+ onAbort();
171
+ else
172
+ input.signal?.addEventListener("abort", onAbort, { once: true });
173
+ });
174
+ try {
175
+ await this.persist(input.sessionId, question);
176
+ persisted = true;
177
+ if (abortRequested) {
178
+ await this.cancel(input.sessionId, question.requestId, "TURN_ABORTED");
179
+ return waiting;
180
+ }
181
+ this.emit({ type: "requested", question });
182
+ }
183
+ catch (error) {
184
+ const waiter = this.waiters.get(input.sessionId);
185
+ this.pending.delete(input.sessionId);
186
+ this.waiters.delete(input.sessionId);
187
+ const failure = error instanceof Error ? error : new Error(String(error));
188
+ if (waiter) {
189
+ waiter.reject(failure);
190
+ return waiting;
191
+ }
192
+ throw failure;
193
+ }
194
+ return waiting;
195
+ }
196
+ async answer(sessionId, requestId, rawAnswer) {
197
+ await this.persistence.get(sessionId);
198
+ const current = await this.get(sessionId);
199
+ if (!current)
200
+ throw new Error("USER_QUESTION_NOT_FOUND");
201
+ if (current.requestId !== requestId)
202
+ throw new Error("USER_QUESTION_STALE");
203
+ const answer = normalizeAnswer(rawAnswer, current.questions);
204
+ const resolved = {
205
+ ...current,
206
+ status: "resolved",
207
+ answer,
208
+ updatedAt: new Date().toISOString(),
209
+ version: current.version + 1,
210
+ };
211
+ this.pending.delete(sessionId);
212
+ await this.persist(sessionId, resolved);
213
+ this.emit({ type: "resolved", question: resolved });
214
+ const waiter = this.waiters.get(sessionId);
215
+ this.waiters.delete(sessionId);
216
+ waiter?.resolve(answer);
217
+ return resolved;
218
+ }
219
+ async cancel(sessionId, requestId, reason = "USER_CANCELLED") {
220
+ await this.persistence.get(sessionId);
221
+ const current = await this.get(sessionId);
222
+ if (!current)
223
+ throw new Error("USER_QUESTION_NOT_FOUND");
224
+ if (current.requestId !== requestId)
225
+ throw new Error("USER_QUESTION_STALE");
226
+ const cancelled = {
227
+ ...current,
228
+ status: "cancelled",
229
+ cancelReason: reason,
230
+ updatedAt: new Date().toISOString(),
231
+ version: current.version + 1,
232
+ };
233
+ this.pending.delete(sessionId);
234
+ await this.persist(sessionId, cancelled);
235
+ this.emit({ type: "cancelled", question: cancelled });
236
+ const waiter = this.waiters.get(sessionId);
237
+ this.waiters.delete(sessionId);
238
+ waiter?.reject(new Error(reason));
239
+ return cancelled;
240
+ }
241
+ async cancelForSession(sessionId, reason = "SESSION_DISPOSED") {
242
+ const current = await this.get(sessionId);
243
+ if (current)
244
+ await this.cancel(sessionId, current.requestId, reason);
245
+ }
246
+ emit(change) {
247
+ for (const listener of this.listeners) {
248
+ try {
249
+ listener(change);
250
+ }
251
+ catch { /* observers never break the waiter */ }
252
+ }
253
+ }
254
+ async persist(sessionId, question) {
255
+ const write = writeSecureJson(questionFile(this.root, sessionId), question);
256
+ this.persistence.set(sessionId, write);
257
+ try {
258
+ await write;
259
+ }
260
+ finally {
261
+ if (this.persistence.get(sessionId) === write) {
262
+ this.persistence.delete(sessionId);
263
+ }
264
+ }
265
+ }
266
+ }
@@ -1,5 +1,70 @@
1
1
  export const ACTIVE_CHAT_SESSION_STORAGE_KEY = "loop-console.operator-chat.active-session.v1";
2
+ export const ACTIVE_CHAT_SESSION_BY_WORKSPACE_STORAGE_KEY = "loop-console.operator-chat.active-session-by-workspace.v1";
2
3
  export const DEFAULT_LANDING_STORAGE_KEY = "loop-console.operator-chat.default-landing.v1";
4
+ function readWorkspaceSessionMap(storage) {
5
+ try {
6
+ const parsed = JSON.parse(storage.getItem(ACTIVE_CHAT_SESSION_BY_WORKSPACE_STORAGE_KEY) ?? "{}");
7
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
8
+ return {};
9
+ return Object.fromEntries(Object.entries(parsed).filter((entry) => Boolean(entry[0].trim()) &&
10
+ typeof entry[1] === "string" &&
11
+ Boolean(entry[1].trim())));
12
+ }
13
+ catch {
14
+ return {};
15
+ }
16
+ }
17
+ /** Resolve the selected Chat session from client state. The workspace map is
18
+ * authoritative; the former global key is only a safe, server-validated
19
+ * migration fallback for users upgrading from the single-selection layout. */
20
+ export function readActiveChatSessionId(storage, workspaceId) {
21
+ const scope = workspaceId?.trim();
22
+ if (scope) {
23
+ const map = readWorkspaceSessionMap(storage);
24
+ const scoped = map[scope]?.trim();
25
+ if (scoped)
26
+ return scoped;
27
+ // Once the map exists, an absent workspace means "no selected session";
28
+ // never fall back to another workspace's global legacy selection.
29
+ if (Object.keys(map).length > 0)
30
+ return null;
31
+ }
32
+ return storage.getItem(ACTIVE_CHAT_SESSION_STORAGE_KEY)?.trim() || null;
33
+ }
34
+ /** Remember the active session for its owning workspace while retaining the
35
+ * global key for existing non-Chat consumers that refer to the visible session. */
36
+ export function rememberActiveChatSession(storage, workspaceId, sessionId) {
37
+ const id = sessionId.trim();
38
+ if (!id)
39
+ return;
40
+ storage.setItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, id);
41
+ const scope = workspaceId?.trim();
42
+ if (!scope)
43
+ return;
44
+ const map = readWorkspaceSessionMap(storage);
45
+ map[scope] = id;
46
+ storage.setItem(ACTIVE_CHAT_SESSION_BY_WORKSPACE_STORAGE_KEY, JSON.stringify(map));
47
+ }
48
+ /** Forget one workspace's invalid/deleted selection without disturbing the
49
+ * last session remembered for any other workspace. */
50
+ export function forgetActiveChatSession(storage, workspaceId, sessionId) {
51
+ const scope = workspaceId?.trim();
52
+ if (scope) {
53
+ const map = readWorkspaceSessionMap(storage);
54
+ if (!sessionId || map[scope] === sessionId)
55
+ delete map[scope];
56
+ if (Object.keys(map).length > 0) {
57
+ storage.setItem(ACTIVE_CHAT_SESSION_BY_WORKSPACE_STORAGE_KEY, JSON.stringify(map));
58
+ }
59
+ else {
60
+ storage.removeItem(ACTIVE_CHAT_SESSION_BY_WORKSPACE_STORAGE_KEY);
61
+ }
62
+ }
63
+ const global = storage.getItem(ACTIVE_CHAT_SESSION_STORAGE_KEY)?.trim();
64
+ if (!sessionId || global === sessionId) {
65
+ storage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
66
+ }
67
+ }
3
68
  function configuredLanding(value) {
4
69
  const normalized = value?.trim().toLowerCase();
5
70
  if (normalized === "chat")
@@ -28,6 +93,20 @@ function readWorkspaceParams(query) {
28
93
  ...(read("fromNodeId") ? { fromNodeId: read("fromNodeId") } : {}),
29
94
  ...(read("featureId") ? { featureId: read("featureId") } : {}),
30
95
  ...(read("intent") ? { intent: read("intent") } : {}),
96
+ ...(read("workspaceId") ? { workspaceId: read("workspaceId") } : {}),
97
+ ...(read("sessionId") ? { sessionId: read("sessionId") } : {}),
98
+ };
99
+ }
100
+ export function resolveLegacyWorkspaceSelection(location, knownWorkspaceIds) {
101
+ const parsed = parseConsoleRoute(location);
102
+ const requested = parsed?.workspaceId?.trim();
103
+ if (parsed && requested && !knownWorkspaceIds.includes(requested)) {
104
+ return { status: "unknown-workspace", workspaceId: requested };
105
+ }
106
+ return {
107
+ status: "ok",
108
+ workspace: parsed?.workspace ?? resolveDefaultLanding({ location }),
109
+ params: parsed ?? {},
31
110
  };
32
111
  }
33
112
  /** Parse `#/tasks?taskId=…` hash routes (Observe-style virtual paths). */
@@ -0,0 +1,82 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ const HANDOFF_ENV = "LOOP_CONSOLE_HANDOFF_V1";
3
+ const HANDOFF_HASH_ENV = "LOOP_CONSOLE_HANDOFF_SHA256";
4
+ function canonicalJson(value) {
5
+ return JSON.stringify(value);
6
+ }
7
+ function digest(value) {
8
+ return createHash("sha256").update(value, "utf8").digest("hex");
9
+ }
10
+ export function createConsoleHandoff(input) {
11
+ const handoff = {
12
+ schemaVersion: 1,
13
+ consoleInstanceId: `console_${randomBytes(16).toString("hex")}`,
14
+ ...input,
15
+ };
16
+ const encoded = canonicalJson(handoff);
17
+ return {
18
+ handoff,
19
+ env: {
20
+ [HANDOFF_ENV]: Buffer.from(encoded, "utf8").toString("base64url"),
21
+ [HANDOFF_HASH_ENV]: digest(encoded),
22
+ },
23
+ };
24
+ }
25
+ /** Parse an exact, hash-bound handoff contract inherited by the replacement process. */
26
+ export function readConsoleHandoff(env) {
27
+ const encoded = env[HANDOFF_ENV];
28
+ const expectedDigest = env[HANDOFF_HASH_ENV];
29
+ if (!encoded && !expectedDigest)
30
+ return undefined;
31
+ if (!encoded || !expectedDigest)
32
+ throw new Error("console handoff contract is incomplete");
33
+ let raw;
34
+ try {
35
+ raw = Buffer.from(encoded, "base64url").toString("utf8");
36
+ }
37
+ catch {
38
+ throw new Error("console handoff payload is invalid");
39
+ }
40
+ if (digest(raw) !== expectedDigest)
41
+ throw new Error("console handoff hash mismatch");
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ throw new Error("console handoff payload is not JSON");
48
+ }
49
+ const value = parsed;
50
+ if (value.schemaVersion !== 1 ||
51
+ !Number.isInteger(value.oldPid) ||
52
+ value.oldPid <= 0 ||
53
+ typeof value.repoRoot !== "string" ||
54
+ typeof value.host !== "string" ||
55
+ !Number.isInteger(value.port) ||
56
+ value.port < 0 ||
57
+ typeof value.targetVersion !== "string" ||
58
+ typeof value.consoleInstanceId !== "string") {
59
+ throw new Error("console handoff payload has invalid fields");
60
+ }
61
+ return value;
62
+ }
63
+ export async function waitForOldConsoleExit(oldPid, options = {}) {
64
+ const isAlive = options.isAlive ?? ((pid) => {
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ });
73
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
74
+ const timeoutMs = options.timeoutMs ?? 15_000;
75
+ const started = Date.now();
76
+ while (isAlive(oldPid)) {
77
+ if (Date.now() - started >= timeoutMs) {
78
+ throw new Error(`old Console PID ${oldPid} did not exit before handoff timeout`);
79
+ }
80
+ await sleep(50);
81
+ }
82
+ }
@@ -0,0 +1,115 @@
1
+ import semver from "semver";
2
+ function notRunMaintenance(channel) {
3
+ return {
4
+ package: { status: "failed", channel },
5
+ initialization: { status: "not-run", pendingPaths: [] },
6
+ handoff: { status: "not-needed" },
7
+ };
8
+ }
9
+ function failed(code, message, maintenance = notRunMaintenance("latest")) {
10
+ return {
11
+ state: "failed",
12
+ maintenance,
13
+ errorCode: code,
14
+ errorMessage: message,
15
+ };
16
+ }
17
+ /**
18
+ * Deterministic core for the Console-only maintenance operation. It contains
19
+ * no HTTP, model, or process-global calls, which makes the safety ordering
20
+ * independently testable and keeps the caller responsible for privileged
21
+ * handoff mechanics.
22
+ */
23
+ export async function performConsoleUpdateAndInit(deps, channel = "latest") {
24
+ const preflight = await deps.preflight();
25
+ if (!preflight.ok) {
26
+ return failed(preflight.code, preflight.message, notRunMaintenance(channel));
27
+ }
28
+ const target = await deps.resolveVersion(channel);
29
+ if (!target.ok) {
30
+ return failed("REGISTRY_UNAVAILABLE", `could not verify npm ${channel} (${target.reason})`, notRunMaintenance(channel));
31
+ }
32
+ if (!semver.valid(deps.currentVersion) || !semver.valid(target.version)) {
33
+ return failed("INVALID_VERSION", "installed or selected channel version is invalid", notRunMaintenance(channel));
34
+ }
35
+ if (channel === "latest" && semver.lt(target.version, deps.currentVersion)) {
36
+ return failed("REGISTRY_VERSION_REGRESSION", `npm latest ${target.version} is older than installed ${deps.currentVersion}`, notRunMaintenance(channel));
37
+ }
38
+ const release = await deps.acquireLock();
39
+ if (!release) {
40
+ return failed("UPDATE_LOCKED", "another loop-agent update is already in progress for this user", notRunMaintenance(channel));
41
+ }
42
+ try {
43
+ const packageChanged = target.version !== deps.currentVersion;
44
+ const maintenance = {
45
+ package: {
46
+ status: packageChanged ? "updated" : "current",
47
+ channel,
48
+ targetVersion: target.version,
49
+ },
50
+ initialization: { status: "not-run", pendingPaths: [] },
51
+ handoff: { status: "not-needed" },
52
+ };
53
+ if (packageChanged) {
54
+ const proof = await deps.canAutoInstall();
55
+ if (!proof.ok) {
56
+ maintenance.package = { status: "failed", channel, reason: proof.reason };
57
+ return failed("GLOBAL_INSTALL_UNPROVEN", proof.reason, maintenance);
58
+ }
59
+ const installed = await deps.installExact(target.version, channel);
60
+ if (!installed.ok) {
61
+ maintenance.package = { status: "failed", channel, reason: installed.reason };
62
+ return failed("INSTALL_FAILED", installed.reason, maintenance);
63
+ }
64
+ const verified = await deps.verifyInstalled(target.version);
65
+ if (!verified.ok) {
66
+ maintenance.package = { status: "failed", channel, reason: verified.reason };
67
+ return failed("INSTALL_VERIFICATION_FAILED", verified.reason, maintenance);
68
+ }
69
+ }
70
+ const initResult = await deps.applySafeInit();
71
+ maintenance.initialization = initResult.ok
72
+ ? { status: "clean", pendingPaths: initResult.pendingPaths }
73
+ : {
74
+ status: "needs-reconcile",
75
+ pendingPaths: initResult.pendingPaths,
76
+ message: initResult.message,
77
+ };
78
+ if (!packageChanged) {
79
+ return {
80
+ state: initResult.ok ? "succeeded" : "needs-reconcile",
81
+ maintenance,
82
+ ...(initResult.ok
83
+ ? {}
84
+ : {
85
+ errorCode: "INIT_NEEDS_RECONCILE",
86
+ errorMessage: initResult.message,
87
+ }),
88
+ };
89
+ }
90
+ const handoff = await deps.requestHandoff({
91
+ targetVersion: target.version,
92
+ initResult,
93
+ });
94
+ if (!handoff.ok) {
95
+ maintenance.handoff = {
96
+ status: "needs-reconcile",
97
+ message: handoff.message,
98
+ };
99
+ return {
100
+ state: "needs-reconcile",
101
+ maintenance,
102
+ errorCode: "HANDOFF_FAILED",
103
+ errorMessage: handoff.message,
104
+ };
105
+ }
106
+ maintenance.handoff = {
107
+ status: "requested",
108
+ consoleInstanceId: handoff.consoleInstanceId,
109
+ };
110
+ return { state: "restarting", maintenance };
111
+ }
112
+ finally {
113
+ await release().catch(() => undefined);
114
+ }
115
+ }