@tea-agent/loop-agent 0.40.0-next.4 → 0.40.0-next.6

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 (134) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/shell-executor.js +4 -1
  4. package/dist/infrastructure/harness/atomic-write.js +23 -0
  5. package/dist/worker/console/chat/chat-event-store.js +62 -4
  6. package/dist/worker/console/chat/context-panel.js +7 -0
  7. package/dist/worker/console/chat/deferred-turn.js +12 -0
  8. package/dist/worker/console/chat/pi-runtime.js +264 -16
  9. package/dist/worker/console/chat/provider-error.js +98 -0
  10. package/dist/worker/console/chat/routes.js +292 -29
  11. package/dist/worker/console/chat/session-stats.js +179 -0
  12. package/dist/worker/console/chat/session-store.js +93 -63
  13. package/dist/worker/console/chat/todos.js +115 -0
  14. package/dist/worker/console/chat/tool-preview.js +42 -0
  15. package/dist/worker/console/chat/turn-process.js +20 -55
  16. package/dist/worker/console/chat/user-questions.js +266 -0
  17. package/dist/worker/console/chat/workspace-landing.js +14 -0
  18. package/dist/worker/console/native-directory-picker.js +13 -0
  19. package/dist/worker/console/routes.js +309 -1
  20. package/dist/worker/console/security.js +4 -4
  21. package/dist/worker/console/server.js +529 -186
  22. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-DGt5TRsk.js} +1 -1
  23. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-DKBSJsXm.js} +1 -1
  24. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-BnhooRBs.js} +1 -1
  25. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-Bm9qkrJP.js} +1 -1
  26. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-B2cFh4bO.js} +1 -1
  27. package/dist/worker/console/static/assets/channel-DSgFX07x.js +1 -0
  28. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DhvWRI03.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-chzTP_2w.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-SdWIR12m.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C2-8Eo_A.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-DvbE1yQW.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-CEenyPTz.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-CcaZrq0k.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-FGngOTto.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-jT_yX3sf.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-CjE9LQOM.js} +1 -1
  38. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-unJObRls.js +1 -0
  39. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-unJObRls.js +1 -0
  40. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-GQWns1tb.js} +1 -1
  41. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-CGRpxWNJ.js} +1 -1
  42. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-Cd3uFGE4.js} +1 -1
  43. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-DmDHuUW0.js} +1 -1
  44. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-kAr--hF3.js} +1 -1
  45. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-woahL2Oh.js} +1 -1
  46. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-7syNVaNf.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-BELPXiaY.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-CrPa4aXi.js} +1 -1
  49. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-B48u2VVe.js} +1 -1
  50. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-DhBTfwTF.js} +1 -1
  51. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CAeZ5nDU.js} +1 -1
  52. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-CkIFeFLN.js} +1 -1
  53. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-t0HGYDel.js} +1 -1
  54. package/dist/worker/console/static/assets/index-B4l5ag79.js +407 -0
  55. package/dist/worker/console/static/assets/index-CwVHWB71.css +1 -0
  56. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-WM0HZhe4.js} +1 -1
  57. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-CV5byHe0.js} +1 -1
  58. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-lIyWkgnw.js} +1 -1
  59. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-BIH4YNRY.js} +1 -1
  60. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-lQ0ZGrWc.js} +1 -1
  61. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-BYdXtke8.js} +5 -5
  62. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-k93vkIa4.js} +1 -1
  63. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-Bm6AqvSy.js} +1 -1
  64. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-DnJW6Xpf.js} +1 -1
  65. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-CMzm4ObL.js} +1 -1
  66. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-fwFF5dFQ.js} +1 -1
  67. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-tzl9VDUc.js} +1 -1
  68. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B26V09DD.js} +1 -1
  69. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-D2npyCeP.js} +1 -1
  70. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-CTJtkRU2.js} +1 -1
  71. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-B8lk4Lzu.js} +1 -1
  72. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BmOcMTJE.js +1 -0
  73. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-C3yP53hH.js} +2 -2
  74. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Beah-dq0.js +8 -0
  75. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-Bck7xjhe.js} +1 -1
  76. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CCntr1w7.js} +1 -1
  77. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-DsCqCBZz.js} +1 -1
  78. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-Br2l90OM.js} +1 -1
  79. package/dist/worker/console/static/index.html +2 -2
  80. package/dist/worker/console/static-src/app/console-types.js +29 -4
  81. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  82. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  83. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  84. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  85. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  86. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  87. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  88. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  89. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  90. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  91. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  92. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  93. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -17
  94. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  95. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  96. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  97. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  98. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  99. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  100. package/dist/worker/console/workspace-context.js +234 -0
  101. package/dist/worker/console/workspace-registry.js +214 -0
  102. package/dist/worker/observe/server.js +4 -0
  103. package/dist/worker/observe/static/api.js +26 -6
  104. package/dist/worker/observe/static/app.js +18 -70
  105. package/dist/worker/observe/static/index.html +1 -1
  106. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  107. package/dist/worker/observe/static/operator-chrome.css +145 -20
  108. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  109. package/dist/worker/observe/static/operator-chrome.js +330 -100
  110. package/dist/worker/observe/static/router.d.ts +46 -0
  111. package/dist/worker/observe/static/router.js +25 -1
  112. package/dist/worker/observe/static/state.d.ts +50 -0
  113. package/dist/worker/observe/static/state.js +172 -0
  114. package/dist/worker/observe/static/views/dag-inspector.js +3 -2
  115. package/dist/worker/observe/static/views/dag.js +2 -1
  116. package/dist/worker/observe/static/views/dashboard.js +6 -4
  117. package/dist/worker/observe/static/views/night.js +2 -2
  118. package/dist/worker/observe/static/views/pool.js +1 -0
  119. package/dist/worker/observe/static/views/run.js +3 -1
  120. package/dist/worker/observe/static/views/task.js +1 -1
  121. package/dist/workflows/dag/backend-test-pytest-collection.js +11 -10
  122. package/dist/workflows/dag/interrupt-request.js +3 -3
  123. package/dist/workflows/dag/lifecycle.js +3 -3
  124. package/docs/README.md +1 -0
  125. package/docs/operations/local-development-environment.md +21 -0
  126. package/harness.json +3 -3
  127. package/package.json +3 -1
  128. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  129. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  130. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  131. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  132. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  133. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  134. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
@@ -4,31 +4,18 @@ import { existsSync } from "node:fs";
4
4
  import { createServer } from "node:http";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
- import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
8
7
  import { readPackageVersion } from "../../shared/package-metadata.js";
9
8
  import { DAG_ABSOLUTE_MAX_WALL_CLOCK_MS } from "../../shared/timeout-policy.js";
10
9
  import { openConsoleAppData } from "./app-data.js";
11
- import { DagConfirmationStore } from "./dag-confirmation.js";
12
- import { DagExecutionReceiptStore } from "./dag-execution-receipt.js";
13
- import { DraftStore } from "./draft-store.js";
14
- import { AssessmentStore } from "./interview/assessment.js";
15
- import { InterviewSessionStore } from "./interview/session.js";
16
10
  import { isLoopbackHost } from "./loopback.js";
17
- import { ChatSessionStore } from "./chat/session-store.js";
18
- import { createChatEventStore, createChatOperationLinker, } from "./chat/chat-event-store.js";
19
- import { createChatTurnExecutionRegistry } from "./chat/turn-execution-registry.js";
20
- import { ConsolePiRuntime } from "./chat/pi-runtime.js";
21
- import { handleChatRequest, recoverDeferredTurns, } from "./chat/routes.js";
22
- import { createOperationEventStore, } from "./operation-sse.js";
23
- import { OperationStore } from "./operation-store.js";
24
- import { probePiReadiness, } from "./pi-readiness.js";
25
- import { handleConsoleHealthRequest, handleConsoleOperatorRequest, sendJson, serveConsoleStatic, } from "./routes.js";
11
+ import { handleConsoleHealthRequest, handleWorkspaceRoutes, sendJson, serveConsoleStatic, } from "./routes.js";
26
12
  import { issueBootCapabilityToken, injectConfirmationTokenScript, } from "./security.js";
27
- import { resolvePackageRootFromConsole, resolveSiblingLoopAgentBin, } from "./sibling-controller.js";
13
+ import { resolvePackageRootFromConsole } from "./sibling-controller.js";
28
14
  import { isDagRerunPromptOverrideEnabled } from "../../shared/dag-prompt-override.js";
29
- import { NightAuxTicker } from "./night-aux-ticker.js";
15
+ import { defaultObserveStaticDir, handleMatchedObserveRoute, matchObserveGetRoute, ROUTES, serveObserveStatic, } from "../observe/routes.js";
30
16
  import { deriveObserveRouteCapabilities } from "../observe/health.js";
31
- 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";
32
19
  import { createConsoleUpdateExecutor } from "./console-update-runtime.js";
33
20
  import { createConsoleHandoff } from "./console-handoff.js";
34
21
  /**
@@ -76,18 +63,20 @@ export async function createConsoleServer(options) {
76
63
  const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? 65_000;
77
64
  const staticDir = options.staticDir ?? defaultConsoleStaticDir();
78
65
  const inspectStaticDir = options.inspectStaticDir ?? defaultObserveStaticDir();
79
- const observeRouteContext = createObserveRouteContext(repoRoot, {
80
- staticDir: inspectStaticDir,
81
- });
82
66
  const operatorSessionId = `sess_${randomBytes(16).toString("hex")}`;
83
67
  const consoleInstanceId = options.consoleInstanceId ?? `console_${randomBytes(16).toString("hex")}`;
84
- const appData = openConsoleAppData({
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({
85
72
  repoRoot,
86
73
  appDataRoot: options.appDataRoot,
87
74
  });
75
+ const registry = new WorkspaceRegistry(bootstrapAppData);
76
+ const startupRecord = await registry.ensureStartupWorkspace(repoRoot);
88
77
  const instanceNonce = configuredPort === 0 ? randomBytes(16).toString("hex") : undefined;
89
78
  const bootToken = issueBootCapabilityToken({
90
- repoFingerprint: appData.fingerprint,
79
+ repoFingerprint: startupRecord.repoFingerprint,
91
80
  bindHost: host,
92
81
  configuredPort,
93
82
  ...(instanceNonce ? { instanceNonce } : {}),
@@ -96,86 +85,67 @@ export async function createConsoleServer(options) {
96
85
  // separate cookie name + confirmation token so the read-only inspect page
97
86
  // can never replay them against the general Console mutation surface.
98
87
  const inspectBootToken = issueBootCapabilityToken({
99
- repoFingerprint: appData.fingerprint,
88
+ repoFingerprint: startupRecord.repoFingerprint,
100
89
  bindHost: host,
101
90
  configuredPort,
102
91
  ...(instanceNonce ? { instanceNonce } : {}),
103
92
  }, { variant: "inspect" });
104
- const operations = new OperationStore(appData);
105
- const events = createOperationEventStore({
106
- persistenceDir: path.join(appData.operations, "events"),
107
- });
108
- const drafts = new DraftStore(appData);
109
- const confirmations = new DagConfirmationStore(appData, bootToken.confirmationToken);
110
- const dagExecutionReceipts = new DagExecutionReceiptStore(appData);
111
- const interviews = new InterviewSessionStore(appData);
112
- const assessments = new AssessmentStore(appData);
113
- const readiness = await probePiReadiness({
114
- override: options.piReadinessProbe,
115
- repoRoot,
116
- });
117
- let readinessCache = readiness;
118
- const artifactRoot = path.join(appData.root, "client-artifacts", appData.fingerprint);
119
- const client = options.createClient?.(artifactRoot) ??
120
- new LoopAgentClient({
121
- loopAgentBin: resolveSiblingLoopAgentBin(),
122
- artifactRoot,
123
- resolveIdentity: true,
124
- // See DEFAULT_CONSOLE_CLIENT_TIMEOUT_MS: dag execute / task advance
125
- // must not inherit the 120s worker-command default.
126
- defaultTimeoutMs: resolveConsoleDefaultTimeoutMs(options.defaultTimeoutMs),
127
- });
128
- if (!options.skipReconcile) {
129
- await operations.reconcileOnBoot();
130
- }
131
- const getReadiness = () => readinessCache;
132
- const refreshReadiness = async () => {
133
- readinessCache = await probePiReadiness({
134
- override: options.piReadinessProbe,
135
- repoRoot,
136
- });
137
- actionContext.readiness = readinessCache;
138
- return readinessCache;
139
- };
140
- const actionContext = {
141
- repoRoot,
142
- appData,
143
- operatorSessionId,
144
- client,
145
- operations,
146
- events,
147
- drafts,
148
- confirmations,
149
- dagExecutionReceipts,
150
- humanGateSecret: bootToken.confirmationToken,
151
- interviews,
152
- assessments,
153
- readiness: readinessCache,
154
- getReadiness,
155
- refreshReadiness,
156
- observeBaseUrl: options.observeBaseUrl,
157
- fetchImpl: options.fetchImpl,
158
- consoleUpdateExecutor: options.consoleUpdateExecutor,
159
- getConsoleOrigin: () => `http://${boundHost}:${boundPort}`,
160
- };
161
93
  let boundHost = host;
162
94
  let boundPort = configuredPort;
163
95
  const getConsoleOrigin = () => `http://${boundHost}:${boundPort}`;
164
- // Phase 4: General Operator Chat runtime. The actionContext is forwarded so
165
- // custom-tool execute() calls dispatch into the operator action layer (Gate 3).
166
- // Chat session dir is dedicated (app-data/chat-sessions), NOT user default.
167
- const chatStore = new ChatSessionStore(appData);
168
- // M5.1: every Console boot owns a fresh event-log generation. A browser
169
- // reconnecting with an old SSE cursor must reconcile from snapshot rather
170
- // than silently consume a different process' event namespace.
171
- const chatEventEpoch = randomBytes(12).toString("hex");
172
- const chatEvents = createChatEventStore({ appData, epoch: chatEventEpoch });
173
- const chatTurnExecutions = createChatTurnExecutionRegistry();
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,
135
+ });
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}`);
146
+ }
147
+ const startup = startupResolution.context;
174
148
  const requestConsoleHandoff = async (targetVersion) => {
175
- // The maintenance preflight proves this running Console was loaded from
176
- // the global package. npm replaces that package in place, so resolve the
177
- // replacement worker from that same package root rather than trusting the
178
- // ambient PATH (which could point at another installation).
179
149
  const packageRoot = resolvePackageRootFromConsole();
180
150
  const agentWorkerBin = path.join(packageRoot, "bin", "agent-worker.js");
181
151
  if (readPackageVersion(packageRoot) !== targetVersion ||
@@ -223,72 +193,26 @@ export async function createConsoleServer(options) {
223
193
  };
224
194
  }
225
195
  };
226
- actionContext.consoleUpdateExecutor =
227
- options.consoleUpdateExecutor ??
228
- createConsoleUpdateExecutor({
229
- repoRoot,
230
- packageRoot: resolvePackageRootFromConsole(),
231
- client,
232
- operations,
233
- activeChatTurnCount: () => chatTurnExecutions.size(),
234
- requestHandoff: requestConsoleHandoff,
235
- });
236
- const chatOperationLinker = createChatOperationLinker({
237
- chatEvents,
238
- operations,
239
- operationEvents: events,
240
- });
241
- const chatRuntime = new ConsolePiRuntime({
242
- cwd: repoRoot,
243
- sessionDir: appData.chatSessions,
244
- resourcePreferencesPath: appData.resourcePreferences,
245
- actionContext,
246
- });
247
- // chatRouteDeps is a plain object literal — it cannot throw. The previous
248
- // try/catch was dead code that created a false "handled failure" impression.
249
- // If a future field can throw, scope it explicitly instead of wrapping the
250
- // literal.
251
- const chatRouteDeps = {
252
- runtime: chatRuntime,
253
- store: chatStore,
254
- events: chatEvents,
255
- turnExecutions: chatTurnExecutions,
256
- operationLinker: chatOperationLinker,
257
- appData,
258
- bootToken,
259
- getConsoleOrigin,
260
- allowNonLoopbackAccess: !loopbackBind,
261
- };
262
- // Durable single-slot follow-ups are server-owned. Recover them at boot even
263
- // when no browser currently has the Session open.
264
- void recoverDeferredTurns(chatRouteDeps).catch((error) => {
265
- console.error(`[console] deferred Turn recovery failed: ${error instanceof Error ? error.message : String(error)}`);
266
- });
267
- const nightAuxTicker = new NightAuxTicker({
196
+ sharedConsoleUpdateExecutor ??= createConsoleUpdateExecutor({
268
197
  repoRoot,
269
- 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,
270
203
  });
271
- const routeContext = {
272
- repoRoot,
273
- staticDir,
274
- bootToken,
275
- inspectBootToken,
276
- getConsoleOrigin,
277
- allowNonLoopbackAccess: !loopbackBind,
278
- inspect: {
279
- ready: existsSync(path.join(inspectStaticDir, "index.html")),
280
- routeCapabilities: deriveObserveRouteCapabilities(ROUTES),
281
- },
282
- consoleInstanceId,
283
- operator: {
284
- actionContext,
285
- operations,
286
- events,
287
- getReadiness,
288
- chat: chatRouteDeps,
289
- nightAuxTicker,
290
- },
291
- };
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;
292
216
  return new Promise((resolve, reject) => {
293
217
  const server = createServer((req, res) => {
294
218
  const startedAt = Date.now();
@@ -336,29 +260,75 @@ export async function createConsoleServer(options) {
336
260
  }
337
261
  });
338
262
  async function dispatch(req, res) {
339
- // Refresh readiness pointer on each request surface
340
- actionContext.readiness = getReadiness();
341
263
  const method = (req.method ?? "GET").toUpperCase();
342
- 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);
343
266
  if (method === "GET" && pathname === "/api/health") {
344
- 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
+ });
345
281
  return;
346
282
  }
347
283
  if (pathname.startsWith("/api/operator/v1/") ||
348
284
  pathname.startsWith("/api/session/")) {
349
- // 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
+ }
350
303
  if (pathname.startsWith("/api/operator/v1/chat/") ||
351
304
  pathname.startsWith("/api/operator/v1/pi/")) {
352
- await handleChatRequest(req, res, chatRouteDeps, pathname);
305
+ await handleChatDispatch(req, res, pathname, url, method);
353
306
  return;
354
307
  }
355
- await handleConsoleOperatorRequest(req, res, routeContext);
308
+ await handleOperatorDispatch(req, res, pathname, url, method);
356
309
  return;
357
310
  }
358
311
  if (method === "GET" && pathname.startsWith("/api/")) {
359
312
  const match = matchObserveGetRoute(pathname);
360
313
  if (match) {
361
- 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);
362
332
  return;
363
333
  }
364
334
  }
@@ -369,10 +339,8 @@ export async function createConsoleServer(options) {
369
339
  // INSPECT_MUTATION_ALLOWED_ACTIONS — see routes.ts).
370
340
  const htmlEnhancer = isDagRerunPromptOverrideEnabled()
371
341
  ? (html) => ({
372
- content: injectConfirmationTokenScript(html, routeContext.inspectBootToken?.confirmationToken ??
373
- routeContext.bootToken.confirmationToken, "__INSPECT_MUTATION_TOKEN__"),
374
- setCookie: routeContext.inspectBootToken?.setCookieHeader ??
375
- routeContext.bootToken.setCookieHeader,
342
+ content: injectConfirmationTokenScript(html, inspectBootToken.confirmationToken, "__INSPECT_MUTATION_TOKEN__"),
343
+ setCookie: inspectBootToken.setCookieHeader,
376
344
  })
377
345
  : undefined;
378
346
  await serveObserveStatic(req, res, inspectStaticDir, {
@@ -385,7 +353,280 @@ export async function createConsoleServer(options) {
385
353
  sendJson(res, 404, { error: "Not found" });
386
354
  return;
387
355
  }
388
- 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
+ };
389
630
  }
390
631
  server.on("error", reject);
391
632
  server.listen(configuredPort, host, () => {
@@ -407,31 +648,133 @@ export async function createConsoleServer(options) {
407
648
  url,
408
649
  host,
409
650
  port: addr.port,
410
- appData,
411
- operations,
412
- events,
651
+ appData: startup.appData,
652
+ operations: startup.operations,
653
+ events: startup.events,
413
654
  getReadiness,
414
655
  operatorSessionId,
656
+ registry,
657
+ getWorkspaceContext: (workspaceId) => workspaceContexts.get(workspaceId),
658
+ listWorkspaceContexts: () => [...workspaceContexts.values()],
415
659
  close: () => new Promise((closeResolve, closeReject) => {
416
- // Dispose all Chat Pi sessions BEFORE closing the HTTP server so
660
+ // Dispose ALL workspace contexts BEFORE closing the HTTP server so
417
661
  // no prompt is left executing and no session/SDK state/file handle
418
- // leaks across Console restarts (regression: close() previously
419
- // only closed the socket, leaving ConsolePiRuntime sessions live).
420
- try {
421
- chatOperationLinker.dispose();
422
- chatRuntime.disposeAll();
423
- nightAuxTicker.dispose();
424
- }
425
- catch (error) {
426
- // Best-effort: surface but do not block the HTTP close.
427
- 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
+ }
428
671
  }
672
+ workspaceContexts.clear();
429
673
  server.close((err) => (err ? closeReject(err) : closeResolve()));
430
674
  }),
431
675
  });
432
676
  });
433
677
  });
434
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
+ }
435
778
  function requestSource(req) {
436
779
  const forwarded = req.headers["x-forwarded-for"];
437
780
  if (typeof forwarded === "string" && forwarded.trim()) {