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

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 (213) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +114 -109
  3. package/README.md +2 -2
  4. package/dist/application/dag/generate-task-dag.js +6 -2
  5. package/dist/application/task-lifecycle/advance.js +12 -0
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/update/runtime-activity.js +1 -29
  8. package/dist/commands/dag-rerun.js +1 -1
  9. package/dist/executors/dag-pi-executor.js +69 -7
  10. package/dist/executors/pi-executor.js +4 -2
  11. package/dist/executors/pi-sdk-executor.js +46 -1
  12. package/dist/executors/pi-writer-tool-policy.js +57 -1
  13. package/dist/executors/shell-executor.js +107 -20
  14. package/dist/executors/workspace-write-snapshot.js +68 -0
  15. package/dist/governance/checks.js +1 -0
  16. package/dist/infrastructure/harness/atomic-write.js +23 -0
  17. package/dist/shared/dag-prompt-override.js +4 -5
  18. package/dist/shared/update/npm-client.js +33 -11
  19. package/dist/task/config-types.js +10 -0
  20. package/dist/task/contract/apply.js +11 -1
  21. package/dist/task/contract/constants.js +2 -0
  22. package/dist/task/contract/hash.js +3 -0
  23. package/dist/task/contract/observe.js +25 -4
  24. package/dist/task/contract/paths.js +2 -1
  25. package/dist/task/contract/project.js +15 -1
  26. package/dist/task/contract/schema.js +2 -0
  27. package/dist/task/contract/transaction.js +12 -0
  28. package/dist/task/source-prepare/completeness.js +188 -0
  29. package/dist/task/source-prepare/fragment-inventory.js +474 -0
  30. package/dist/task/source-prepare/index.js +3 -0
  31. package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
  32. package/dist/task/source-prepare/ledger-review.js +214 -0
  33. package/dist/task/source-prepare/ledger.js +545 -0
  34. package/dist/task/source-prepare/prepare.js +262 -1
  35. package/dist/task/source-prepare/semantic-intake.js +19 -3
  36. package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
  37. package/dist/task/source-prepare/types.js +22 -0
  38. package/dist/worker/cli.js +12 -26
  39. package/dist/worker/console/chat/chat-event-store.js +75 -5
  40. package/dist/worker/console/chat/context-panel.js +7 -0
  41. package/dist/worker/console/chat/deferred-turn.js +12 -0
  42. package/dist/worker/console/chat/pi-runtime.js +331 -18
  43. package/dist/worker/console/chat/provider-error.js +98 -0
  44. package/dist/worker/console/chat/routes.js +389 -56
  45. package/dist/worker/console/chat/session-stats.js +179 -0
  46. package/dist/worker/console/chat/session-store.js +93 -63
  47. package/dist/worker/console/chat/todos.js +115 -0
  48. package/dist/worker/console/chat/tool-preview.js +42 -0
  49. package/dist/worker/console/chat/turn-process.js +20 -55
  50. package/dist/worker/console/chat/user-questions.js +266 -0
  51. package/dist/worker/console/chat/workspace-landing.js +79 -0
  52. package/dist/worker/console/console-handoff.js +82 -0
  53. package/dist/worker/console/console-update-and-init.js +115 -0
  54. package/dist/worker/console/console-update-runtime.js +84 -0
  55. package/dist/worker/console/dag-execution-receipt.js +20 -2
  56. package/dist/worker/console/draft-store.js +49 -0
  57. package/dist/worker/console/native-directory-picker.js +13 -0
  58. package/dist/worker/console/operation-store.js +1 -1
  59. package/dist/worker/console/operator-actions.js +133 -7
  60. package/dist/worker/console/operator-surface-health.js +1 -0
  61. package/dist/worker/console/prd-intake-bridge.js +13 -0
  62. package/dist/worker/console/routes.js +312 -1
  63. package/dist/worker/console/security.js +4 -4
  64. package/dist/worker/console/server.js +583 -172
  65. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Br92EGb7.js → abnfDiagram-N423BO3Z-Bs-CDAXM.js} +1 -1
  66. package/dist/worker/console/static/assets/{arc-oulImtQq.js → arc-CAkA3We3.js} +1 -1
  67. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-tyHc6aqO.js → architectureDiagram-T3A2C74G-DEmI_zqr.js} +1 -1
  68. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CY86BSpT.js → blockDiagram-VBNYF7ZC-uDHANebE.js} +1 -1
  69. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BKKsDmNs.js → c4Diagram-5PPSVZJV-Bv97b4_B.js} +1 -1
  70. package/dist/worker/console/static/assets/channel-amxUpk7o.js +1 -0
  71. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CXZdjl3B.js → chunk-2GRJ4B5K-shmQKsFz.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-B-aIFDQ5.js → chunk-2Q5K7J3B-BAraNvcN.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BDgAW_B2.js → chunk-5RXB4S5H-BirhxUop.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DABa4UPR.js → chunk-5VM5RSS4-C9LgUJpR.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-aGbysVOx.js → chunk-6Q2QTUOP-DrU-s-t7.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-B8MSZDVs.js → chunk-GF5L2VYU-Tx-H_FA2.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CpelV73g.js → chunk-JWPE2WC7-BjLtNFrR.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BE04ty3z.js → chunk-KBJHAD2P-UNLVwSsy.js} +1 -1
  79. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BUxTkaaR.js → chunk-RYQCIY6F-CBEUX8XD.js} +1 -1
  80. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CYYBVKfX.js → chunk-XXDRQBXY-WTL7IZOq.js} +1 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +1 -0
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +1 -0
  83. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CEQF_xmQ.js → cose-bilkent-JH36ORCC-CkHG6W3s.js} +1 -1
  84. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CqpxN5IT.js → cynefin-VYW2F7L2-BRzFqTaL.js} +1 -1
  85. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-B3PzanVi.js → cynefinDiagram-MW4NZA55-2_CFp_i7.js} +1 -1
  86. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BSUGCNx1.js → dagre-VZM6K2ZE-BJtsmdkE.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CdLevU5T.js → diagram-7IWD3JNH-DNEFFo6w.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BjpF_Kok.js → diagram-B4RE2ZJO-B_IWV7eo.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C4I6NJny.js → diagram-LBJQPF4R-DvvRx2dz.js} +1 -1
  90. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C8hecCr5.js → diagram-Q27KOJAE-CsibsWLg.js} +1 -1
  91. package/dist/worker/console/static/assets/{diagram-UB23O5K3-Daod6tOS.js → diagram-UB23O5K3-les_djBQ.js} +1 -1
  92. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BvvmazGv.js → ebnfDiagram-BXEA7PRR-BEm2n7qJ.js} +1 -1
  93. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DeN_vfV4.js → erDiagram-JOGREHBK-BI7vHHVm.js} +1 -1
  94. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-D6dq1t9z.js → flowDiagram-UKHOOZJN-CUFCERrB.js} +1 -1
  95. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DziIdNY4.js → ganttDiagram-PKOTCBZU-wxyLiGWt.js} +1 -1
  96. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dy4s5CXr.js → gitGraphDiagram-DS77QQ5N-Bt897K8Z.js} +1 -1
  97. package/dist/worker/console/static/assets/index-BHhUOTri.css +1 -0
  98. package/dist/worker/console/static/assets/index-DqJbO3-p.js +407 -0
  99. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czv5eYx2.js → infoDiagram-6WML65LV-DYNx_IPv.js} +1 -1
  100. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-Bsk3nFRT.js → ishikawaDiagram-WSZJBQD7-CzpLNEu6.js} +1 -1
  101. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DxGmNj5K.js → journeyDiagram-NVQOT4AX-97f9owNU.js} +1 -1
  102. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CYCFqkO_.js → kanban-definition-27J2QSJJ-BxM9hyBn.js} +1 -1
  103. package/dist/worker/console/static/assets/{linear-B0YzRwrd.js → linear-ByuxcHvp.js} +1 -1
  104. package/dist/worker/console/static/assets/{mermaid.core-t3EZtIGh.js → mermaid.core-DwPGvpCv.js} +5 -5
  105. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BTXpTv0T.js → mindmap-definition-FAOFIHXS-DCoqG9RT.js} +1 -1
  106. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DU8UkKqA.js → pegDiagram-VL7TDLO6-BLSBmBMx.js} +1 -1
  107. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DP4y8ohp.js → pieDiagram-7S7Q4E2Y-B0t_0oJW.js} +1 -1
  108. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B07vNaYR.js → quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js} +1 -1
  109. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-xDWktb2i.js → railroadDiagram-AXF67PYL-B3jRdATC.js} +1 -1
  110. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BLNMKqeS.js → requirementDiagram-LRYGKXZP-D-cTtXEN.js} +1 -1
  111. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-n0adalxH.js → sankeyDiagram-W5VNT64P-B25igJRE.js} +1 -1
  112. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BbnS-2DL.js → sequenceDiagram-SI44F4Z6-jW_lM1_D.js} +1 -1
  113. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4uPpCLu.js → sizeCapture-X5ZJPWSS-CrzTgsHE.js} +1 -1
  114. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA--MMbB0fX.js → stateDiagram-OKZ733FA-B01MvlqE.js} +1 -1
  115. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +1 -0
  116. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BXUgp2QY.js → swimlanes-SLNWSIFB-YcF12FiU.js} +2 -2
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +8 -0
  118. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CsaMxki7.js → timeline-definition-Z64GVDOM-ClnRcg2s.js} +1 -1
  119. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-C2IuG9iH.js → vennDiagram-T6HMQDX7-B6gr_z-q.js} +1 -1
  120. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CmEWOXCW.js → wardleyDiagram-T6FBY63Y-CvSgguGH.js} +1 -1
  121. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D_9LE0MG.js → xychartDiagram-ELKLHX3M-23OtSZCb.js} +1 -1
  122. package/dist/worker/console/static/index.html +2 -2
  123. package/dist/worker/console/static-src/app/console-types.js +68 -4
  124. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  125. package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
  126. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  127. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  128. package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
  129. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  130. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
  131. package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
  132. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  133. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  134. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  135. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  136. package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
  137. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  138. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
  139. package/dist/worker/console/static-src/operator-chat/useChatStream.js +159 -44
  140. package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
  141. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  142. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  143. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  144. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  145. package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
  146. package/dist/worker/console/workspace-context.js +234 -0
  147. package/dist/worker/console/workspace-registry.js +214 -0
  148. package/dist/worker/observability/init-runtime-activity.js +26 -0
  149. package/dist/worker/observe/node-input.js +88 -2
  150. package/dist/worker/observe/routes.js +188 -0
  151. package/dist/worker/observe/server.js +4 -0
  152. package/dist/worker/observe/static/api.js +29 -9
  153. package/dist/worker/observe/static/app.js +18 -70
  154. package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
  155. package/dist/worker/observe/static/dag-node-purpose.js +203 -0
  156. package/dist/worker/observe/static/format.js +37 -0
  157. package/dist/worker/observe/static/index.html +6 -1
  158. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  159. package/dist/worker/observe/static/operator-chrome.css +145 -20
  160. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  161. package/dist/worker/observe/static/operator-chrome.js +330 -100
  162. package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
  163. package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
  164. package/dist/worker/observe/static/router.d.ts +46 -0
  165. package/dist/worker/observe/static/router.js +25 -1
  166. package/dist/worker/observe/static/state.d.ts +50 -0
  167. package/dist/worker/observe/static/state.js +174 -2
  168. package/dist/worker/observe/static/styles.css +357 -35
  169. package/dist/worker/observe/static/views/dag-graph.js +4 -2
  170. package/dist/worker/observe/static/views/dag-inspector.js +533 -79
  171. package/dist/worker/observe/static/views/dag.d.ts +13 -0
  172. package/dist/worker/observe/static/views/dag.js +6 -4
  173. package/dist/worker/observe/static/views/dashboard.js +6 -4
  174. package/dist/worker/observe/static/views/night.js +2 -2
  175. package/dist/worker/observe/static/views/pool.js +1 -0
  176. package/dist/worker/observe/static/views/run.js +3 -1
  177. package/dist/worker/observe/static/views/task.js +56 -1
  178. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +186 -8
  179. package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
  180. package/dist/workflows/dag/backend-test-pytest-collection.js +168 -16
  181. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  182. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  183. package/dist/workflows/dag/backend-test-writer-completeness.js +28 -17
  184. package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
  185. package/dist/workflows/dag/frontend-implementation-contract.js +88 -2
  186. package/dist/workflows/dag/frontend-plan-render.js +24 -0
  187. package/dist/workflows/dag/frontend-prewrite-gate.js +197 -0
  188. package/dist/workflows/dag/frontend-review-context.js +92 -10
  189. package/dist/workflows/dag/init-hybrid.js +239 -52
  190. package/dist/workflows/dag/interrupt-request.js +3 -3
  191. package/dist/workflows/dag/lifecycle.js +3 -3
  192. package/dist/workflows/dag/node-execution.js +45 -0
  193. package/dist/workflows/dag/types.js +62 -12
  194. package/docs/README.md +2 -0
  195. package/docs/architecture/evolution.md +45 -0
  196. package/docs/init-surface.manifest.json +1 -0
  197. package/docs/operations/local-development-environment.md +21 -0
  198. package/docs/templates/README.md +1 -1
  199. package/docs/templates/agent-dag.schema.json +18 -0
  200. package/docs/templates/backend-test-dag.json +267 -238
  201. package/docs/templates/frontend-implementation-contract.schema.json +681 -24
  202. package/harness.json +3 -3
  203. package/package.json +3 -1
  204. package/skills/loop-agent/references/hybrid-dag.md +7 -0
  205. package/dist/worker/console/static/assets/channel-TY36zt-i.js +0 -1
  206. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Dk7QCChM.js +0 -1
  207. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Dk7QCChM.js +0 -1
  208. package/dist/worker/console/static/assets/index-C3GgHg-g.css +0 -1
  209. package/dist/worker/console/static/assets/index-Ci8ksvBm.js +0 -406
  210. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqOigMQ4.js +0 -1
  211. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Caxz9UA2.js +0 -8
  212. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  213. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -1,32 +1,23 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
2
3
  import { existsSync } from "node:fs";
3
4
  import { createServer } from "node:http";
4
5
  import path from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
- import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
7
+ import { readPackageVersion } from "../../shared/package-metadata.js";
7
8
  import { DAG_ABSOLUTE_MAX_WALL_CLOCK_MS } from "../../shared/timeout-policy.js";
8
9
  import { openConsoleAppData } from "./app-data.js";
9
- import { DagConfirmationStore } from "./dag-confirmation.js";
10
- import { DagExecutionReceiptStore } from "./dag-execution-receipt.js";
11
- import { DraftStore } from "./draft-store.js";
12
- import { AssessmentStore } from "./interview/assessment.js";
13
- import { InterviewSessionStore } from "./interview/session.js";
14
10
  import { isLoopbackHost } from "./loopback.js";
15
- import { ChatSessionStore } from "./chat/session-store.js";
16
- import { createChatEventStore, createChatOperationLinker, } from "./chat/chat-event-store.js";
17
- import { createChatTurnExecutionRegistry } from "./chat/turn-execution-registry.js";
18
- import { ConsolePiRuntime } from "./chat/pi-runtime.js";
19
- import { handleChatRequest, recoverDeferredTurns, } from "./chat/routes.js";
20
- import { createOperationEventStore, } from "./operation-sse.js";
21
- import { OperationStore } from "./operation-store.js";
22
- import { probePiReadiness, } from "./pi-readiness.js";
23
- import { handleConsoleHealthRequest, handleConsoleOperatorRequest, sendJson, serveConsoleStatic, } from "./routes.js";
11
+ import { handleConsoleHealthRequest, handleWorkspaceRoutes, sendJson, serveConsoleStatic, } from "./routes.js";
24
12
  import { issueBootCapabilityToken, injectConfirmationTokenScript, } from "./security.js";
25
- import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
13
+ import { resolvePackageRootFromConsole } from "./sibling-controller.js";
26
14
  import { isDagRerunPromptOverrideEnabled } from "../../shared/dag-prompt-override.js";
27
- import { NightAuxTicker } from "./night-aux-ticker.js";
15
+ import { defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
28
16
  import { deriveObserveRouteCapabilities } from "../observe/health.js";
29
- import { createObserveRouteContext, defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
17
+ import { WorkspaceRegistry, } from "./workspace-registry.js";
18
+ import { createWorkspaceContext, } from "./workspace-context.js";
19
+ import { createConsoleUpdateExecutor } from "./console-update-runtime.js";
20
+ import { createConsoleHandoff } from "./console-handoff.js";
30
21
  /**
31
22
  * Default hard timeout for the Console-owned LoopAgentClient.
32
23
  *
@@ -72,17 +63,20 @@ export async function createConsoleServer(options) {
72
63
  const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? 65_000;
73
64
  const staticDir = options.staticDir ?? defaultConsoleStaticDir();
74
65
  const inspectStaticDir = options.inspectStaticDir ?? defaultObserveStaticDir();
75
- const observeRouteContext = createObserveRouteContext(repoRoot, {
76
- staticDir: inspectStaticDir,
77
- });
78
66
  const operatorSessionId = `sess_${randomBytes(16).toString("hex")}`;
79
- const appData = openConsoleAppData({
67
+ const consoleInstanceId = options.consoleInstanceId ?? `console_${randomBytes(16).toString("hex")}`;
68
+ // User-level registry root shares the user app-data root directory layout
69
+ // (workspaces.json lives at <root>/workspaces.json, per-workspace records
70
+ // carry fingerprint-isolated namespaces under <root>/repos/<fingerprint>/).
71
+ const bootstrapAppData = openConsoleAppData({
80
72
  repoRoot,
81
73
  appDataRoot: options.appDataRoot,
82
74
  });
75
+ const registry = new WorkspaceRegistry(bootstrapAppData);
76
+ const startupRecord = await registry.ensureStartupWorkspace(repoRoot);
83
77
  const instanceNonce = configuredPort === 0 ? randomBytes(16).toString("hex") : undefined;
84
78
  const bootToken = issueBootCapabilityToken({
85
- repoFingerprint: appData.fingerprint,
79
+ repoFingerprint: startupRecord.repoFingerprint,
86
80
  bindHost: host,
87
81
  configuredPort,
88
82
  ...(instanceNonce ? { instanceNonce } : {}),
@@ -91,135 +85,134 @@ export async function createConsoleServer(options) {
91
85
  // separate cookie name + confirmation token so the read-only inspect page
92
86
  // can never replay them against the general Console mutation surface.
93
87
  const inspectBootToken = issueBootCapabilityToken({
94
- repoFingerprint: appData.fingerprint,
88
+ repoFingerprint: startupRecord.repoFingerprint,
95
89
  bindHost: host,
96
90
  configuredPort,
97
91
  ...(instanceNonce ? { instanceNonce } : {}),
98
92
  }, { variant: "inspect" });
99
- const operations = new OperationStore(appData);
100
- const events = createOperationEventStore({
101
- persistenceDir: path.join(appData.operations, "events"),
102
- });
103
- const drafts = new DraftStore(appData);
104
- const confirmations = new DagConfirmationStore(appData, bootToken.confirmationToken);
105
- const dagExecutionReceipts = new DagExecutionReceiptStore(appData);
106
- const interviews = new InterviewSessionStore(appData);
107
- const assessments = new AssessmentStore(appData);
108
- const readiness = await probePiReadiness({
109
- override: options.piReadinessProbe,
110
- repoRoot,
111
- });
112
- let readinessCache = readiness;
113
- const artifactRoot = path.join(appData.root, "client-artifacts", appData.fingerprint);
114
- const client = options.createClient?.(artifactRoot) ??
115
- new LoopAgentClient({
116
- loopAgentBin: resolveSiblingLoopAgentBin(),
117
- artifactRoot,
118
- resolveIdentity: true,
119
- // See DEFAULT_CONSOLE_CLIENT_TIMEOUT_MS: dag execute / task advance
120
- // must not inherit the 120s worker-command default.
121
- defaultTimeoutMs: resolveConsoleDefaultTimeoutMs(options.defaultTimeoutMs),
93
+ let boundHost = host;
94
+ let boundPort = configuredPort;
95
+ const getConsoleOrigin = () => `http://${boundHost}:${boundPort}`;
96
+ // ---- Per-workspace lazily-created contexts -------------------------------
97
+ const workspaceContexts = new Map();
98
+ let sharedConsoleUpdateExecutor = options.consoleUpdateExecutor;
99
+ const resolveContext = async (workspaceId) => {
100
+ const id = workspaceId?.trim();
101
+ if (!id) {
102
+ return {
103
+ ok: false,
104
+ status: 400,
105
+ code: "WORKSPACE_REQUIRED",
106
+ message: "workspaceId is required",
107
+ };
108
+ }
109
+ const existing = workspaceContexts.get(id);
110
+ if (existing)
111
+ return { ok: true, context: existing };
112
+ const record = await registry.getVisible(id);
113
+ if (!record) {
114
+ return {
115
+ ok: false,
116
+ status: 404,
117
+ code: "WORKSPACE_NOT_FOUND",
118
+ message: `unknown workspace: ${id}`,
119
+ };
120
+ }
121
+ const context = await createWorkspaceContext(record, {
122
+ appDataRoot: options.appDataRoot,
123
+ staticDir,
124
+ inspectStaticDir,
125
+ bootToken,
126
+ operatorSessionId,
127
+ getConsoleOrigin,
128
+ allowNonLoopbackAccess: !loopbackBind,
129
+ observeBaseUrl: options.observeBaseUrl,
130
+ fetchImpl: options.fetchImpl,
131
+ piReadinessProbe: options.piReadinessProbe,
132
+ createClient: options.createClient,
133
+ defaultTimeoutMs: options.defaultTimeoutMs,
134
+ skipReconcile: options.skipReconcile,
122
135
  });
123
- if (!options.skipReconcile) {
124
- await operations.reconcileOnBoot();
136
+ if (sharedConsoleUpdateExecutor) {
137
+ context.actionContext.consoleUpdateExecutor = sharedConsoleUpdateExecutor;
138
+ }
139
+ workspaceContexts.set(id, context);
140
+ void context.recoverDeferredTurns().catch(() => undefined);
141
+ return { ok: true, context };
142
+ };
143
+ const startupResolution = await resolveContext(startupRecord.workspaceId);
144
+ if (!startupResolution.ok) {
145
+ throw new Error(`console serve: failed to create startup workspace context: ${startupResolution.message}`);
125
146
  }
126
- const getReadiness = () => readinessCache;
127
- const refreshReadiness = async () => {
128
- readinessCache = await probePiReadiness({
129
- override: options.piReadinessProbe,
147
+ const startup = startupResolution.context;
148
+ const requestConsoleHandoff = async (targetVersion) => {
149
+ const packageRoot = resolvePackageRootFromConsole();
150
+ const agentWorkerBin = path.join(packageRoot, "bin", "agent-worker.js");
151
+ if (readPackageVersion(packageRoot) !== targetVersion ||
152
+ !existsSync(agentWorkerBin)) {
153
+ return {
154
+ ok: false,
155
+ message: "verified global package no longer contains the target Console worker",
156
+ };
157
+ }
158
+ const { handoff, env } = createConsoleHandoff({
159
+ oldPid: process.pid,
130
160
  repoRoot,
161
+ host: boundHost,
162
+ port: boundPort,
163
+ ...(options.appDataRoot ? { appDataRoot: options.appDataRoot } : {}),
164
+ targetVersion,
131
165
  });
132
- actionContext.readiness = readinessCache;
133
- return readinessCache;
134
- };
135
- const actionContext = {
136
- repoRoot,
137
- appData,
138
- operatorSessionId,
139
- client,
140
- operations,
141
- events,
142
- drafts,
143
- confirmations,
144
- dagExecutionReceipts,
145
- humanGateSecret: bootToken.confirmationToken,
146
- interviews,
147
- assessments,
148
- readiness: readinessCache,
149
- getReadiness,
150
- refreshReadiness,
151
- observeBaseUrl: options.observeBaseUrl,
152
- fetchImpl: options.fetchImpl,
153
- getConsoleOrigin: () => `http://${boundHost}:${boundPort}`,
154
- };
155
- let boundHost = host;
156
- let boundPort = configuredPort;
157
- const getConsoleOrigin = () => `http://${boundHost}:${boundPort}`;
158
- // Phase 4: General Operator Chat runtime. The actionContext is forwarded so
159
- // custom-tool execute() calls dispatch into the operator action layer (Gate 3).
160
- // Chat session dir is dedicated (app-data/chat-sessions), NOT user default.
161
- const chatStore = new ChatSessionStore(appData);
162
- // M5.1: every Console boot owns a fresh event-log generation. A browser
163
- // reconnecting with an old SSE cursor must reconcile from snapshot rather
164
- // than silently consume a different process' event namespace.
165
- const chatEventEpoch = randomBytes(12).toString("hex");
166
- const chatEvents = createChatEventStore({ appData, epoch: chatEventEpoch });
167
- const chatTurnExecutions = createChatTurnExecutionRegistry();
168
- const chatOperationLinker = createChatOperationLinker({
169
- chatEvents,
170
- operations,
171
- operationEvents: events,
172
- });
173
- const chatRuntime = new ConsolePiRuntime({
174
- cwd: repoRoot,
175
- sessionDir: appData.chatSessions,
176
- resourcePreferencesPath: appData.resourcePreferences,
177
- actionContext,
178
- });
179
- // chatRouteDeps is a plain object literal — it cannot throw. The previous
180
- // try/catch was dead code that created a false "handled failure" impression.
181
- // If a future field can throw, scope it explicitly instead of wrapping the
182
- // literal.
183
- const chatRouteDeps = {
184
- runtime: chatRuntime,
185
- store: chatStore,
186
- events: chatEvents,
187
- turnExecutions: chatTurnExecutions,
188
- operationLinker: chatOperationLinker,
189
- appData,
190
- bootToken,
191
- getConsoleOrigin,
192
- allowNonLoopbackAccess: !loopbackBind,
166
+ try {
167
+ const child = spawn(process.execPath, [
168
+ agentWorkerBin,
169
+ "console",
170
+ "--repo",
171
+ repoRoot,
172
+ "--host",
173
+ boundHost,
174
+ "--port",
175
+ String(boundPort),
176
+ ...(options.appDataRoot
177
+ ? ["--app-data", options.appDataRoot]
178
+ : []),
179
+ "--no-open",
180
+ ], { detached: true, stdio: "ignore", env: { ...process.env, ...env } });
181
+ await new Promise((resolve, reject) => {
182
+ child.once("spawn", resolve);
183
+ child.once("error", reject);
184
+ });
185
+ child.unref();
186
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 100).unref();
187
+ return { ok: true, consoleInstanceId: handoff.consoleInstanceId };
188
+ }
189
+ catch (error) {
190
+ return {
191
+ ok: false,
192
+ message: error instanceof Error ? error.message : String(error),
193
+ };
194
+ }
193
195
  };
194
- // Durable single-slot follow-ups are server-owned. Recover them at boot even
195
- // when no browser currently has the Session open.
196
- void recoverDeferredTurns(chatRouteDeps).catch((error) => {
197
- console.error(`[console] deferred Turn recovery failed: ${error instanceof Error ? error.message : String(error)}`);
198
- });
199
- const nightAuxTicker = new NightAuxTicker({
196
+ sharedConsoleUpdateExecutor ??= createConsoleUpdateExecutor({
200
197
  repoRoot,
201
- client,
198
+ packageRoot: resolvePackageRootFromConsole(),
199
+ client: startup.client,
200
+ operations: startup.operations,
201
+ activeChatTurnCount: () => [...workspaceContexts.values()].reduce((total, context) => total + (context.chatRouteDeps.turnExecutions?.size() ?? 0), 0),
202
+ requestHandoff: requestConsoleHandoff,
202
203
  });
203
- const routeContext = {
204
- repoRoot,
205
- staticDir,
206
- bootToken,
207
- inspectBootToken,
208
- getConsoleOrigin,
209
- allowNonLoopbackAccess: !loopbackBind,
210
- inspect: {
211
- ready: existsSync(path.join(inspectStaticDir, "index.html")),
212
- routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
213
- },
214
- operator: {
215
- actionContext,
216
- operations,
217
- events,
218
- getReadiness,
219
- chat: chatRouteDeps,
220
- nightAuxTicker,
221
- },
222
- };
204
+ for (const context of workspaceContexts.values()) {
205
+ context.actionContext.consoleUpdateExecutor = sharedConsoleUpdateExecutor;
206
+ }
207
+ // Legacy surface readiness: the boot readiness report of the startup
208
+ // workspace. Kept as a process-level report for /api/health projections.
209
+ const bootReadiness = startup.getReadiness();
210
+ // Durable follow-up recovery is per-context and runs at context creation
211
+ // (see resolveContext); the startup context is created eagerly below, so its
212
+ // recovery has already been scheduled.
213
+ // Readiness is shared per-context; the legacy getReadiness() helper keeps
214
+ // pointing at the startup context report.
215
+ const getReadiness = () => bootReadiness;
223
216
  return new Promise((resolve, reject) => {
224
217
  const server = createServer((req, res) => {
225
218
  const startedAt = Date.now();
@@ -267,44 +260,87 @@ export async function createConsoleServer(options) {
267
260
  }
268
261
  });
269
262
  async function dispatch(req, res) {
270
- // Refresh readiness pointer on each request surface
271
- actionContext.readiness = getReadiness();
272
263
  const method = (req.method ?? "GET").toUpperCase();
273
- const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
264
+ const url = new URL(req.url ?? "/", "http://localhost");
265
+ const pathname = decodeURIComponent(url.pathname);
274
266
  if (method === "GET" && pathname === "/api/health") {
275
- await handleConsoleHealthRequest(req, res, routeContext);
267
+ await handleConsoleHealthRequest(req, res, {
268
+ repoRoot,
269
+ staticDir,
270
+ bootToken,
271
+ getConsoleOrigin,
272
+ allowNonLoopbackAccess: !loopbackBind,
273
+ inspect: {
274
+ ready: existsSync(path.join(inspectStaticDir, "index.html")),
275
+ routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
276
+ },
277
+ consoleInstanceId,
278
+ registry,
279
+ getStartupReadiness: getReadiness,
280
+ });
276
281
  return;
277
282
  }
278
283
  if (pathname.startsWith("/api/operator/v1/") ||
279
284
  pathname.startsWith("/api/session/")) {
280
- // Phase 4: General Operator Chat routes (mounted under chat/).
285
+ // Workspace Registry routes are process-level (no workspaceId).
286
+ if (pathname === "/api/operator/v1/workspaces" ||
287
+ pathname.startsWith("/api/operator/v1/workspaces/")) {
288
+ await handleWorkspaceRoutes(req, res, registry, {
289
+ bootToken,
290
+ getConsoleOrigin,
291
+ allowNonLoopbackAccess: !loopbackBind,
292
+ ...(options.piReadinessProbe
293
+ ? { piReadinessProbe: options.piReadinessProbe }
294
+ : {}),
295
+ ...(options.pickDirectory
296
+ ? { pickDirectory: options.pickDirectory }
297
+ : {}),
298
+ }, url, pathname, method, {
299
+ inspectReady: existsSync(path.join(inspectStaticDir, "index.html")),
300
+ });
301
+ return;
302
+ }
281
303
  if (pathname.startsWith("/api/operator/v1/chat/") ||
282
304
  pathname.startsWith("/api/operator/v1/pi/")) {
283
- await handleChatRequest(req, res, chatRouteDeps, pathname);
305
+ await handleChatDispatch(req, res, pathname, url, method);
284
306
  return;
285
307
  }
286
- await handleConsoleOperatorRequest(req, res, routeContext);
308
+ await handleOperatorDispatch(req, res, pathname, url, method);
287
309
  return;
288
310
  }
289
311
  if (method === "GET" && pathname.startsWith("/api/")) {
290
312
  const match = matchObserveGetRoute(pathname);
291
313
  if (match) {
292
- await handleMatchedObserveRoute(req, res, match, observeRouteContext);
314
+ const requestedWorkspaceId = url.searchParams.get("workspaceId")?.trim() || undefined;
315
+ // Every Observe GET is workspace data. Missing, blank, and unknown
316
+ // workspace ids fail closed; only /api/health remains process-level.
317
+ const resolution = await resolveContext(requestedWorkspaceId);
318
+ if (!resolution.ok) {
319
+ sendJson(res, resolution.status, {
320
+ ok: false,
321
+ error: {
322
+ code: resolution.code,
323
+ message: resolution.message,
324
+ },
325
+ });
326
+ return;
327
+ }
328
+ // Refresh readiness pointer per request surface.
329
+ resolution.context.actionContext.readiness =
330
+ resolution.context.getReadiness();
331
+ await handleMatchedObserveRoute(req, res, match, resolution.context.observeRouteContext);
293
332
  return;
294
333
  }
295
334
  }
296
335
  if (pathname.startsWith("/inspect")) {
297
- // Experimental prompt-override rerun: only while the internal switch
298
- // is enabled do we hand the inspect page SCOPED mutation
336
+ // Prompt-override rerun is enabled by default. Unless the emergency
337
+ // kill switch disables it, hand the inspect page SCOPED mutation
299
338
  // credentials (separate cookie + token, only strong enough for
300
- // INSPECT_MUTATION_ALLOWED_ACTIONS — see routes.ts). Default stays
301
- // strictly read-only: no token, no cookie, no tab.
339
+ // INSPECT_MUTATION_ALLOWED_ACTIONS — see routes.ts).
302
340
  const htmlEnhancer = isDagRerunPromptOverrideEnabled()
303
341
  ? (html) => ({
304
- content: injectConfirmationTokenScript(html, routeContext.inspectBootToken?.confirmationToken ??
305
- routeContext.bootToken.confirmationToken, "__INSPECT_MUTATION_TOKEN__"),
306
- setCookie: routeContext.inspectBootToken?.setCookieHeader ??
307
- routeContext.bootToken.setCookieHeader,
342
+ content: injectConfirmationTokenScript(html, inspectBootToken.confirmationToken, "__INSPECT_MUTATION_TOKEN__"),
343
+ setCookie: inspectBootToken.setCookieHeader,
308
344
  })
309
345
  : undefined;
310
346
  await serveObserveStatic(req, res, inspectStaticDir, {
@@ -317,7 +353,280 @@ export async function createConsoleServer(options) {
317
353
  sendJson(res, 404, { error: "Not found" });
318
354
  return;
319
355
  }
320
- await serveConsoleStatic(req, res, routeContext);
356
+ if (options.serveStatic) {
357
+ await options.serveStatic(req, res, {
358
+ confirmationToken: bootToken.confirmationToken,
359
+ setCookieHeader: bootToken.setCookieHeader,
360
+ consoleOrigin: getConsoleOrigin(),
361
+ });
362
+ return;
363
+ }
364
+ await serveConsoleStatic(req, res, {
365
+ repoRoot,
366
+ staticDir,
367
+ bootToken,
368
+ inspectBootToken,
369
+ getConsoleOrigin,
370
+ allowNonLoopbackAccess: !loopbackBind,
371
+ inspect: {
372
+ ready: existsSync(path.join(inspectStaticDir, "index.html")),
373
+ routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
374
+ },
375
+ consoleInstanceId,
376
+ });
377
+ }
378
+ async function handleChatDispatch(req, res, pathname, url, method,
379
+ /** Explicit owning workspaceId for POST /chat/sessions create
380
+ * (AC-R7-002); undefined for every other chat route. */
381
+ workspaceScope) {
382
+ // POST /api/operator/v1/chat/sessions — creation carries workspaceId in
383
+ // the JSON body; all other chat routes resolve via Session ownership
384
+ // (durable) with optional query workspaceId conflict check.
385
+ const { handleChatRequest: dispatchChat } = await import("./chat/routes.js");
386
+ if (method === "POST" && pathname === "/api/operator/v1/chat/sessions") {
387
+ // AC-R5-006: the mutation gate fires BEFORE body pre-read/workspace
388
+ // resolution — an unauthenticated mutation gets exactly 403, never a
389
+ // masked 400 (WORKSPACE_REQUIRED/INVALID_INPUT) that would hide the
390
+ // gate-order regression.
391
+ {
392
+ const { evaluateMutationGate } = await import("./security.js");
393
+ const failure = evaluateMutationGate(req, {
394
+ cookieName: bootToken.cookieName,
395
+ bootToken: bootToken.value,
396
+ confirmationToken: bootToken.confirmationToken,
397
+ consoleOrigin: getConsoleOrigin(),
398
+ allowNonLoopbackAccess: !loopbackBind,
399
+ });
400
+ if (failure) {
401
+ sendJson(res, failure.status, {
402
+ ok: false,
403
+ error: {
404
+ code: failure.code,
405
+ message: failure.message,
406
+ },
407
+ });
408
+ return;
409
+ }
410
+ }
411
+ const resolution = await resolveContextFromSessionCreateBody(req, res, resolveContext);
412
+ if (!resolution)
413
+ return; // response already sent
414
+ // AC-R7-002: workspaceId travels as an EXPLICIT parameter through
415
+ // dispatchChat → handleCreateChatSession → store.create. No runtime
416
+ // store.create replacement exists anymore.
417
+ await dispatchChat(req, res, resolution.context.chatRouteDeps, pathname, resolution.context.workspaceId);
418
+ return;
419
+ }
420
+ const sessionMatch = pathname.match(/^\/api\/operator\/v1\/chat\/sessions\/([^/]+)(?:\/|$)/);
421
+ if (sessionMatch) {
422
+ const sessionId = decodeURIComponent(sessionMatch[1]);
423
+ // r4 AC-R2-001: the mutation gate fires BEFORE session ownership
424
+ // resolution — an unauthenticated mutation on ANY session id
425
+ // (existing or not) gets exactly 403, never a masked 404 that
426
+ // would hide a gate-order regression.
427
+ if (method !== "GET" && method !== "HEAD") {
428
+ const { evaluateMutationGate } = await import("./security.js");
429
+ const failure = evaluateMutationGate(req, {
430
+ cookieName: bootToken.cookieName,
431
+ bootToken: bootToken.value,
432
+ confirmationToken: bootToken.confirmationToken,
433
+ consoleOrigin: getConsoleOrigin(),
434
+ allowNonLoopbackAccess: !loopbackBind,
435
+ });
436
+ if (failure) {
437
+ sendJson(res, failure.status, {
438
+ ok: false,
439
+ error: {
440
+ code: failure.code,
441
+ message: failure.message,
442
+ },
443
+ });
444
+ return;
445
+ }
446
+ }
447
+ const requestedId = url.searchParams.get("workspaceId")?.trim();
448
+ let owner;
449
+ if (requestedId) {
450
+ // The current UI always sends its selected workspace. Resolve that one
451
+ // context directly: scanning every historical registry entry first can
452
+ // fail on an old, deleted temp directory before reaching the real owner.
453
+ const resolution = await resolveContext(requestedId);
454
+ if (!resolution.ok) {
455
+ // A session-scoped workspace hint is a conflict boundary. Unknown
456
+ // workspace ids must not reveal whether the session exists elsewhere.
457
+ const workspaceConflict = resolution.code === "WORKSPACE_NOT_FOUND";
458
+ sendJson(res, workspaceConflict ? 403 : resolution.status, {
459
+ ok: false,
460
+ error: workspaceConflict
461
+ ? {
462
+ code: "WORKSPACE_CONFLICT",
463
+ message: "workspaceId does not match the session's owning workspace",
464
+ }
465
+ : { code: resolution.code, message: resolution.message },
466
+ });
467
+ return;
468
+ }
469
+ const stored = await resolution.context.chatRouteDeps.store.get(sessionId);
470
+ if (stored)
471
+ owner = resolution.context;
472
+ else {
473
+ // Do not scan another workspace and disclose whether the id exists there.
474
+ sendJson(res, 403, {
475
+ ok: false,
476
+ error: {
477
+ code: "WORKSPACE_CONFLICT",
478
+ message: "workspaceId does not match the session's owning workspace",
479
+ },
480
+ });
481
+ return;
482
+ }
483
+ }
484
+ else {
485
+ // Legacy callers may omit workspaceId. Session ids live in per-workspace
486
+ // namespaces, so scan visible workspaces but tolerate stale registry
487
+ // records whose directories were removed outside the Console.
488
+ const visible = await registry.listVisible();
489
+ for (const record of visible) {
490
+ try {
491
+ const resolution = await resolveContext(record.workspaceId);
492
+ if (!resolution.ok)
493
+ continue;
494
+ const stored = await resolution.context.chatRouteDeps.store.get(sessionId);
495
+ if (stored) {
496
+ owner = resolution.context;
497
+ break;
498
+ }
499
+ }
500
+ catch {
501
+ // An unavailable historical Workspace is not evidence that the
502
+ // requested session is missing from every other visible Workspace.
503
+ }
504
+ }
505
+ }
506
+ if (!owner) {
507
+ sendJson(res, 404, {
508
+ ok: false,
509
+ error: {
510
+ code: "NOT_FOUND",
511
+ message: `chat session not found: ${sessionId}`,
512
+ },
513
+ });
514
+ return;
515
+ }
516
+ owner.actionContext.readiness = owner.getReadiness();
517
+ // Session-scoped routes: ownership is durable on the record; no explicit
518
+ // workspace scope is needed (it lives in store record + query conflict check).
519
+ await dispatchChat(req, res, owner.chatRouteDeps, pathname, undefined);
520
+ return;
521
+ }
522
+ // Non-session chat surface (capabilities, repo browser) resolves from
523
+ // query workspaceId; fail closed when missing.
524
+ const workspaceId = url.searchParams.get("workspaceId") ?? undefined;
525
+ const resolution = await resolveContext(workspaceId);
526
+ if (!resolution.ok) {
527
+ sendJson(res, resolution.status, {
528
+ ok: false,
529
+ error: { code: resolution.code, message: resolution.message },
530
+ });
531
+ return;
532
+ }
533
+ resolution.context.actionContext.readiness =
534
+ resolution.context.getReadiness();
535
+ await dispatchChat(req, res, resolution.context.chatRouteDeps, pathname, undefined);
536
+ }
537
+ async function handleOperatorDispatch(req, res, pathname, url, method) {
538
+ const { handleConsoleOperatorRequest } = await import("./routes.js");
539
+ // POST /api/operator/v1/operations creation carries workspaceId in the
540
+ // JSON body. Operation reads carry ?workspaceId=. Non-workspace routes
541
+ // (readiness, recovery, session ping, night-aux-ticker) use the startup
542
+ // context for backward compatibility.
543
+ if (method === "POST" && pathname === "/api/operator/v1/operations") {
544
+ const resolution = await resolveContextFromOperationCreateBody(req, res, resolveContext);
545
+ if (!resolution)
546
+ return; // response already sent
547
+ await handleConsoleOperatorRequest(req, res, operatorRouteContext(resolution.context));
548
+ return;
549
+ }
550
+ const opMatch = pathname.match(/^\/api\/operator\/v1\/operations\/([^/]+)(?:\/(events))?$/);
551
+ if ((method === "GET" && pathname === "/api/operator/v1/operations") ||
552
+ opMatch) {
553
+ const workspaceId = url.searchParams.get("workspaceId") ?? undefined;
554
+ const resolution = await resolveContext(workspaceId);
555
+ if (!resolution.ok) {
556
+ sendJson(res, resolution.status, {
557
+ ok: false,
558
+ error: { code: resolution.code, message: resolution.message },
559
+ });
560
+ return;
561
+ }
562
+ resolution.context.actionContext.readiness =
563
+ resolution.context.getReadiness();
564
+ await handleConsoleOperatorRequest(req, res, operatorRouteContext(resolution.context));
565
+ return;
566
+ }
567
+ // Multi-workspace read-scope (2026-08-26, AC-005): every remaining
568
+ // operator read route (readiness, recovery candidates, night-aux-ticker
569
+ // GET and the ticker POST mutation) requires an EXPLICIT workspaceId;
570
+ // missing/unknown ids fail closed with the shared error contract. No
571
+ // route silently falls back to the startup workspace anymore. Only
572
+ // /api/session/ping stays process-level (mutation-gate probe stub).
573
+ if ((method === "GET" &&
574
+ (pathname === "/api/operator/v1/readiness" ||
575
+ pathname === "/api/operator/v1/recovery/candidates" ||
576
+ pathname === "/api/operator/v1/night-aux-ticker")) ||
577
+ (method === "POST" &&
578
+ pathname === "/api/operator/v1/night-aux-ticker")) {
579
+ const workspaceId = url.searchParams.get("workspaceId") ?? undefined;
580
+ const resolution = await resolveContext(workspaceId);
581
+ if (!resolution.ok) {
582
+ sendJson(res, resolution.status, {
583
+ ok: false,
584
+ error: { code: resolution.code, message: resolution.message },
585
+ });
586
+ return;
587
+ }
588
+ resolution.context.actionContext.readiness =
589
+ resolution.context.getReadiness();
590
+ await handleConsoleOperatorRequest(req, res, operatorRouteContext(resolution.context));
591
+ return;
592
+ }
593
+ // /api/session/ping stays process-level (mutation-gate probe stub).
594
+ if (pathname === "/api/session/ping") {
595
+ await handleConsoleOperatorRequest(req, res, operatorRouteContext(startup));
596
+ return;
597
+ }
598
+ // Unknown operator route: fail closed (never a startup-workspace proxy).
599
+ sendJson(res, 404, {
600
+ ok: false,
601
+ error: {
602
+ code: "NOT_FOUND",
603
+ message: `unknown operator route: ${pathname}`,
604
+ },
605
+ });
606
+ }
607
+ function operatorRouteContext(context) {
608
+ return {
609
+ repoRoot: context.repoRoot,
610
+ staticDir,
611
+ bootToken,
612
+ inspectBootToken,
613
+ getConsoleOrigin,
614
+ allowNonLoopbackAccess: !loopbackBind,
615
+ inspect: {
616
+ ready: existsSync(path.join(inspectStaticDir, "index.html")),
617
+ routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
618
+ },
619
+ consoleInstanceId,
620
+ operator: {
621
+ actionContext: context.actionContext,
622
+ operations: context.operations,
623
+ events: context.events,
624
+ getReadiness: context.getReadiness,
625
+ chat: context.chatRouteDeps,
626
+ nightAuxTicker: context.nightAuxTicker,
627
+ },
628
+ workspace: context,
629
+ };
321
630
  }
322
631
  server.on("error", reject);
323
632
  server.listen(configuredPort, host, () => {
@@ -339,31 +648,133 @@ export async function createConsoleServer(options) {
339
648
  url,
340
649
  host,
341
650
  port: addr.port,
342
- appData,
343
- operations,
344
- events,
651
+ appData: startup.appData,
652
+ operations: startup.operations,
653
+ events: startup.events,
345
654
  getReadiness,
346
655
  operatorSessionId,
656
+ registry,
657
+ getWorkspaceContext: (workspaceId) => workspaceContexts.get(workspaceId),
658
+ listWorkspaceContexts: () => [...workspaceContexts.values()],
347
659
  close: () => new Promise((closeResolve, closeReject) => {
348
- // Dispose all Chat Pi sessions BEFORE closing the HTTP server so
660
+ // Dispose ALL workspace contexts BEFORE closing the HTTP server so
349
661
  // no prompt is left executing and no session/SDK state/file handle
350
- // leaks across Console restarts (regression: close() previously
351
- // only closed the socket, leaving ConsolePiRuntime sessions live).
352
- try {
353
- chatOperationLinker.dispose();
354
- chatRuntime.disposeAll();
355
- nightAuxTicker.dispose();
356
- }
357
- catch (error) {
358
- // Best-effort: surface but do not block the HTTP close.
359
- process.stderr.write(`[console] chatRuntime.disposeAll() failed during close: ${error instanceof Error ? error.message : String(error)}\n`);
662
+ // leaks across Console restarts. Each context is disposed exactly
663
+ // once (dispose() is idempotent).
664
+ for (const context of workspaceContexts.values()) {
665
+ try {
666
+ context.dispose();
667
+ }
668
+ catch (error) {
669
+ process.stderr.write(`[console] workspace context dispose failed during close: ${error instanceof Error ? error.message : String(error)}\n`);
670
+ }
360
671
  }
672
+ workspaceContexts.clear();
361
673
  server.close((err) => (err ? closeReject(err) : closeResolve()));
362
674
  }),
363
675
  });
364
676
  });
365
677
  });
366
678
  }
679
+ /**
680
+ * Resolve a workspace context for POST /chat/sessions. The workspaceId comes
681
+ * from the JSON body; the body is pre-read and handed to the route handler so
682
+ * it is not consumed twice.
683
+ */
684
+ async function resolveContextFromSessionCreateBody(req, res, resolveContext) {
685
+ const body = await readBodyOnce(req);
686
+ if (!body.ok) {
687
+ sendJson(res, body.status, {
688
+ ok: false,
689
+ error: { code: body.code, message: body.message },
690
+ });
691
+ return undefined;
692
+ }
693
+ const record = body.value;
694
+ const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId : undefined;
695
+ const resolution = await resolveContext(workspaceId);
696
+ if (!resolution.ok) {
697
+ sendJson(res, resolution.status, {
698
+ ok: false,
699
+ error: { code: resolution.code, message: resolution.message },
700
+ });
701
+ return undefined;
702
+ }
703
+ // AC-R7-002 (2026-08-23): the runtime store.create wrapper
704
+ // (the former per-store patch + WeakMap cache) was fully removed;
705
+ // workspaceId now flows as an explicit parameter from this
706
+ // dispatcher through handleCreateChatSession into store.create.
707
+ return { context: resolution.context };
708
+ }
709
+ /** Resolve a workspace context for POST /operations (workspaceId in body). */
710
+ async function resolveContextFromOperationCreateBody(req, res, resolveContext) {
711
+ const body = await readBodyOnce(req);
712
+ if (!body.ok) {
713
+ sendJson(res, body.status, {
714
+ ok: false,
715
+ error: { code: body.code, message: body.message },
716
+ });
717
+ return undefined;
718
+ }
719
+ const record = body.value;
720
+ const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
721
+ if (!workspaceId) {
722
+ sendJson(res, 400, {
723
+ ok: false,
724
+ error: {
725
+ code: "WORKSPACE_REQUIRED",
726
+ message: "workspaceId is required",
727
+ },
728
+ });
729
+ return undefined;
730
+ }
731
+ const resolution = await resolveContext(workspaceId);
732
+ if (!resolution.ok) {
733
+ sendJson(res, resolution.status, {
734
+ ok: false,
735
+ error: { code: resolution.code, message: resolution.message },
736
+ });
737
+ return undefined;
738
+ }
739
+ return { context: resolution.context, preReadBody: record };
740
+ }
741
+ /** Read and buffer a JSON request body once (restored onto req for handlers). */
742
+ async function readBodyOnce(req) {
743
+ const chunks = [];
744
+ let total = 0;
745
+ for await (const chunk of req) {
746
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
747
+ total += buf.length;
748
+ if (total > 10 * 1024 * 1024) {
749
+ return {
750
+ ok: false,
751
+ status: 400,
752
+ code: "INVALID_INPUT",
753
+ message: "request body exceeds 10485760 bytes",
754
+ };
755
+ }
756
+ chunks.push(buf);
757
+ }
758
+ const raw = Buffer.concat(chunks).toString("utf8");
759
+ if (!raw.trim()) {
760
+ return { ok: true, value: {} };
761
+ }
762
+ try {
763
+ const value = JSON.parse(raw);
764
+ // Re-inject the body so downstream route handlers can read it again.
765
+ req.__preReadBody =
766
+ value;
767
+ return { ok: true, value };
768
+ }
769
+ catch (error) {
770
+ return {
771
+ ok: false,
772
+ status: 400,
773
+ code: "INVALID_INPUT",
774
+ message: `invalid JSON body: ${error instanceof Error ? error.message : String(error)}`,
775
+ };
776
+ }
777
+ }
367
778
  function requestSource(req) {
368
779
  const forwarded = req.headers["x-forwarded-for"];
369
780
  if (typeof forwarded === "string" && forwarded.trim()) {