@tea-agent/loop-agent 0.40.0-next.3 → 0.40.0-next.5

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 (139) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +46 -5
  4. package/dist/executors/shell-executor.js +28 -0
  5. package/dist/executors/workspace-write-snapshot.js +68 -0
  6. package/dist/infrastructure/harness/atomic-write.js +23 -0
  7. package/dist/task/config-types.js +10 -0
  8. package/dist/worker/console/chat/chat-event-store.js +62 -4
  9. package/dist/worker/console/chat/context-panel.js +7 -0
  10. package/dist/worker/console/chat/deferred-turn.js +12 -0
  11. package/dist/worker/console/chat/pi-runtime.js +264 -16
  12. package/dist/worker/console/chat/provider-error.js +98 -0
  13. package/dist/worker/console/chat/routes.js +292 -29
  14. package/dist/worker/console/chat/session-stats.js +179 -0
  15. package/dist/worker/console/chat/session-store.js +93 -63
  16. package/dist/worker/console/chat/todos.js +115 -0
  17. package/dist/worker/console/chat/tool-preview.js +42 -0
  18. package/dist/worker/console/chat/turn-process.js +20 -55
  19. package/dist/worker/console/chat/user-questions.js +266 -0
  20. package/dist/worker/console/chat/workspace-landing.js +14 -0
  21. package/dist/worker/console/native-directory-picker.js +13 -0
  22. package/dist/worker/console/routes.js +309 -1
  23. package/dist/worker/console/security.js +4 -4
  24. package/dist/worker/console/server.js +529 -186
  25. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-CTx1TrNa.js → abnfDiagram-N423BO3Z-CGZrpj0K.js} +1 -1
  26. package/dist/worker/console/static/assets/{arc-B4i9cVxX.js → arc-CwEyA8iq.js} +1 -1
  27. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BAXE8_Rl.js → architectureDiagram-T3A2C74G-DLTkrEQ3.js} +1 -1
  28. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-C5Xh59at.js → blockDiagram-VBNYF7ZC-4w12zR3t.js} +1 -1
  29. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B_asOzAu.js → c4Diagram-5PPSVZJV-BppqLAjk.js} +1 -1
  30. package/dist/worker/console/static/assets/channel-TzuwQha5.js +1 -0
  31. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-wRO_iRNc.js → chunk-2GRJ4B5K-DO6U26Pm.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-ByOaSh_G.js → chunk-2Q5K7J3B-DC7YEdOy.js} +1 -1
  33. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLzudan.js → chunk-5RXB4S5H-DtMYVQBw.js} +1 -1
  34. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-JCNGkXYT.js → chunk-5VM5RSS4-C5Ed-57N.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BhrPXXMZ.js → chunk-6Q2QTUOP-BUY4oIzT.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-TJ5JuEBT.js → chunk-GF5L2VYU-VnWHALIV.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-VVthn_mD.js → chunk-JWPE2WC7-COV-RQk4.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D60-Mny4.js → chunk-KBJHAD2P-dSnLtUiz.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-zXv7aVye.js → chunk-RYQCIY6F-CX6FDzWh.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-By9VQoi1.js → chunk-XXDRQBXY-D3mEgb6W.js} +1 -1
  41. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DCu69b3S.js +1 -0
  42. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DCu69b3S.js +1 -0
  43. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C4zQLe-M.js → cose-bilkent-JH36ORCC-QQBMUnAf.js} +1 -1
  44. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-wh5N2ReN.js → cynefin-VYW2F7L2-BxSD-LVV.js} +1 -1
  45. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Dlu5wIqs.js → cynefinDiagram-MW4NZA55-DxHNiNbk.js} +1 -1
  46. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CrDgthc6.js → dagre-VZM6K2ZE-C83bYy8E.js} +1 -1
  47. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-zN1clg27.js → diagram-7IWD3JNH-BhtZu2YY.js} +1 -1
  48. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BN-o7J7-.js → diagram-B4RE2ZJO-K0P_ntRE.js} +1 -1
  49. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-C8SuPt0d.js → diagram-LBJQPF4R-CXYy1Nib.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BoizuHbZ.js → diagram-Q27KOJAE-D-CtIFkU.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BzUzXixG.js → diagram-UB23O5K3-BEK9-_7c.js} +1 -1
  52. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Ev3lA4Cf.js → ebnfDiagram-BXEA7PRR-L0EX-9wn.js} +1 -1
  53. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-GALUzRbs.js → erDiagram-JOGREHBK-9pt0fc_5.js} +1 -1
  54. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CZJN65Sb.js → flowDiagram-UKHOOZJN-CZVeM_n1.js} +1 -1
  55. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-JLB9OESl.js → ganttDiagram-PKOTCBZU-DqUKkAZP.js} +1 -1
  56. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CcvCr4GD.js → gitGraphDiagram-DS77QQ5N-BYUuXEgp.js} +1 -1
  57. package/dist/worker/console/static/assets/index-B0jGzQj6.js +407 -0
  58. package/dist/worker/console/static/assets/index-BiYbPiTw.css +1 -0
  59. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-HG5z7aVg.js → infoDiagram-6WML65LV-DNzKk7ah.js} +1 -1
  60. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BHEu9chR.js → ishikawaDiagram-WSZJBQD7-Clbyg5IP.js} +1 -1
  61. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-Cp_Up8-a.js → journeyDiagram-NVQOT4AX-zDO_gZNO.js} +1 -1
  62. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-DvVeY-em.js → kanban-definition-27J2QSJJ-x3LpRYek.js} +1 -1
  63. package/dist/worker/console/static/assets/{linear-aGmcjgTn.js → linear-CTpwZqKR.js} +1 -1
  64. package/dist/worker/console/static/assets/{mermaid.core-Biyo-tqA.js → mermaid.core-D_WXK6C7.js} +5 -5
  65. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DyxY2pIb.js → mindmap-definition-FAOFIHXS-CWK-OYP5.js} +1 -1
  66. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-CkdGuc50.js → pegDiagram-VL7TDLO6-B9gtUPsM.js} +1 -1
  67. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-NNQI9xVR.js → pieDiagram-7S7Q4E2Y-Dj2lzceR.js} +1 -1
  68. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-PAJkuz_G.js → quadrantDiagram-CIZ2JOQS-Nwljut7m.js} +1 -1
  69. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-ZwrQod0O.js → railroadDiagram-AXF67PYL-BY-KNt93.js} +1 -1
  70. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D3ZfrzM2.js → requirementDiagram-LRYGKXZP-Vx0wvpPy.js} +1 -1
  71. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DDGFmWk5.js → sankeyDiagram-W5VNT64P-B6grNXUk.js} +1 -1
  72. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DO6mmXTA.js → sequenceDiagram-SI44F4Z6-CteidBvh.js} +1 -1
  73. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DMbCQiJt.js → sizeCapture-X5ZJPWSS-Du4W8mFK.js} +1 -1
  74. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Di_YUEfC.js → stateDiagram-OKZ733FA-jyI6Yh81.js} +1 -1
  75. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DyQblISl.js +1 -0
  76. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Bn2PphNJ.js → swimlanes-SLNWSIFB-DYWs_fRv.js} +2 -2
  77. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DiygmgYP.js +8 -0
  78. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DPh8pRsp.js → timeline-definition-Z64GVDOM-DO5LLy47.js} +1 -1
  79. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-GMfl26pW.js → vennDiagram-T6HMQDX7-CLF2p1_L.js} +1 -1
  80. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DTlmVrmF.js → wardleyDiagram-T6FBY63Y-AwTaYBxA.js} +1 -1
  81. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YGBjKAS5.js → xychartDiagram-ELKLHX3M-DzbblQRw.js} +1 -1
  82. package/dist/worker/console/static/index.html +2 -2
  83. package/dist/worker/console/static-src/app/console-types.js +29 -4
  84. package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
  85. package/dist/worker/console/static-src/app/useOperatorActions.js +36 -6
  86. package/dist/worker/console/static-src/app/useRecoveryConsole.js +10 -11
  87. package/dist/worker/console/static-src/app/useRunProgress.js +2 -1
  88. package/dist/worker/console/static-src/app/useTaskWizard.js +2 -2
  89. package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
  90. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +140 -8
  91. package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
  92. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  93. package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
  94. package/dist/worker/console/static-src/operator-chat/turn-submission.js +3 -2
  95. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +168 -72
  96. package/dist/worker/console/static-src/operator-chat/useChatStream.js +78 -17
  97. package/dist/worker/console/static-src/operator-chat/useChatThread.js +184 -4
  98. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
  99. package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
  100. package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
  101. package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
  102. package/dist/worker/console/static-src/shell/workspace-route.js +337 -0
  103. package/dist/worker/console/workspace-context.js +234 -0
  104. package/dist/worker/console/workspace-registry.js +214 -0
  105. package/dist/worker/observe/server.js +4 -0
  106. package/dist/worker/observe/static/api.js +26 -6
  107. package/dist/worker/observe/static/app.js +18 -70
  108. package/dist/worker/observe/static/index.html +1 -1
  109. package/dist/worker/observe/static/inspect-workspace.js +307 -0
  110. package/dist/worker/observe/static/operator-chrome.css +145 -20
  111. package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
  112. package/dist/worker/observe/static/operator-chrome.js +330 -100
  113. package/dist/worker/observe/static/router.d.ts +46 -0
  114. package/dist/worker/observe/static/router.js +25 -1
  115. package/dist/worker/observe/static/state.d.ts +50 -0
  116. package/dist/worker/observe/static/state.js +172 -0
  117. package/dist/worker/observe/static/styles.css +20 -1
  118. package/dist/worker/observe/static/views/dag-inspector.js +4 -3
  119. package/dist/worker/observe/static/views/dag.js +2 -1
  120. package/dist/worker/observe/static/views/dashboard.js +6 -4
  121. package/dist/worker/observe/static/views/night.js +2 -2
  122. package/dist/worker/observe/static/views/pool.js +1 -0
  123. package/dist/worker/observe/static/views/run.js +3 -1
  124. package/dist/worker/observe/static/views/task.js +1 -1
  125. package/dist/workflows/dag/init-hybrid.js +19 -0
  126. package/dist/workflows/dag/interrupt-request.js +3 -3
  127. package/dist/workflows/dag/lifecycle.js +3 -3
  128. package/dist/workflows/dag/types.js +3 -1
  129. package/docs/README.md +1 -0
  130. package/docs/operations/local-development-environment.md +21 -0
  131. package/harness.json +3 -3
  132. package/package.json +3 -1
  133. package/dist/worker/console/static/assets/channel-Dj_4Moei.js +0 -1
  134. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C0YYAyCa.js +0 -1
  135. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C0YYAyCa.js +0 -1
  136. package/dist/worker/console/static/assets/index-CW88Fjbw.js +0 -406
  137. package/dist/worker/console/static/assets/index-DP0ZJl39.css +0 -1
  138. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrdDvela.js +0 -1
  139. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DPkEAULl.js +0 -8
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Repo-scoped Workspace Context (2026-08-22 multi-workspace foundation, W1).
3
+ *
4
+ * One lazily-created ConsoleContext per workspaceId. Each context INDEPENDENTLY
5
+ * owns the full operator runtime for its repo root: fingerprint-isolated
6
+ * appData (via openConsoleAppData), operation/event stores, draft /
7
+ * confirmation / receipt / interview / assessment stores, Chat session store +
8
+ * events + deferred Turn registry + ConsolePiRuntime(cwd=repoRoot),
9
+ * OperatorActionContext, Observe route context, and readiness.
10
+ *
11
+ * Invariants:
12
+ * - Selecting (viewing) another workspace NEVER disposes a context; running
13
+ * Turns / Operations / DAGs continue in the background.
14
+ * - dispose() is invoked exactly once per context by the owning server's
15
+ * close().
16
+ * - bootToken is shared per Console boot: it binds the startup fingerprint
17
+ * and bind host, and its confirmationToken signs human-gate tokens for all
18
+ * workspaces in this process (single Console security domain).
19
+ */
20
+ import { randomBytes } from "node:crypto";
21
+ import { existsSync } from "node:fs";
22
+ import path from "node:path";
23
+ import { LoopAgentClient } from "../loop-agent/loop-agent-client.js";
24
+ import { openConsoleAppData } from "./app-data.js";
25
+ import { DagConfirmationStore } from "./dag-confirmation.js";
26
+ import { DagExecutionReceiptStore } from "./dag-execution-receipt.js";
27
+ import { DraftStore } from "./draft-store.js";
28
+ import { AssessmentStore } from "./interview/assessment.js";
29
+ import { InterviewSessionStore } from "./interview/session.js";
30
+ import { ChatSessionStore } from "./chat/session-store.js";
31
+ import { createChatEventStore, createChatOperationLinker, } from "./chat/chat-event-store.js";
32
+ import { createChatTurnExecutionRegistry } from "./chat/turn-execution-registry.js";
33
+ import { ConsolePiRuntime } from "./chat/pi-runtime.js";
34
+ import { UserQuestionRegistry } from "./chat/user-questions.js";
35
+ import { TodoListRegistry } from "./chat/todos.js";
36
+ import { createOperationEventStore, } from "./operation-sse.js";
37
+ import { OperationStore } from "./operation-store.js";
38
+ import { probePiReadiness, } from "./pi-readiness.js";
39
+ import { resolveSiblingLoopAgentBin } from "./sibling-controller.js";
40
+ import { NightAuxTicker } from "./night-aux-ticker.js";
41
+ import { createObserveRouteContext, } from "../observe/routes.js";
42
+ import { WorkspaceRegistry } from "./workspace-registry.js";
43
+ import { resolveConsoleDefaultTimeoutMs } from "./server.js";
44
+ /**
45
+ * Lazily create the full repo-scoped Console context for a registered
46
+ * workspace record. Pure factory — no global registry; the caller owns
47
+ * lifecycle (single creation per workspaceId, dispose on server close).
48
+ */
49
+ export async function createWorkspaceContext(record, deps) {
50
+ const repoRoot = record.repoRoot;
51
+ const appData = openConsoleAppData({
52
+ repoRoot,
53
+ ...(deps.appDataRoot ? { appDataRoot: deps.appDataRoot } : {}),
54
+ });
55
+ const registry = new WorkspaceRegistry(appData);
56
+ const readiness = await probePiReadiness({
57
+ override: deps.piReadinessProbe,
58
+ repoRoot,
59
+ });
60
+ let readinessCache = readiness;
61
+ const artifactRoot = path.join(appData.root, "client-artifacts", appData.fingerprint);
62
+ const client = deps.createClient?.(artifactRoot) ??
63
+ new LoopAgentClient({
64
+ loopAgentBin: resolveSiblingLoopAgentBin(),
65
+ artifactRoot,
66
+ resolveIdentity: true,
67
+ defaultTimeoutMs: resolveConsoleDefaultTimeoutMs(deps.defaultTimeoutMs),
68
+ });
69
+ const operations = new OperationStore(appData);
70
+ const events = createOperationEventStore({
71
+ persistenceDir: path.join(appData.operations, "events"),
72
+ });
73
+ const drafts = new DraftStore(appData);
74
+ const confirmations = new DagConfirmationStore(appData, deps.bootToken.confirmationToken);
75
+ const dagExecutionReceipts = new DagExecutionReceiptStore(appData);
76
+ const interviews = new InterviewSessionStore(appData);
77
+ const assessments = new AssessmentStore(appData);
78
+ if (!deps.skipReconcile) {
79
+ await operations.reconcileOnBoot();
80
+ }
81
+ const getReadiness = () => readinessCache;
82
+ const refreshReadiness = async () => {
83
+ readinessCache = await probePiReadiness({
84
+ override: deps.piReadinessProbe,
85
+ repoRoot,
86
+ });
87
+ actionContext.readiness = readinessCache;
88
+ return readinessCache;
89
+ };
90
+ const actionContext = {
91
+ repoRoot,
92
+ appData,
93
+ operatorSessionId: deps.operatorSessionId,
94
+ client,
95
+ operations,
96
+ events,
97
+ drafts,
98
+ confirmations,
99
+ dagExecutionReceipts,
100
+ humanGateSecret: deps.bootToken.confirmationToken,
101
+ interviews,
102
+ assessments,
103
+ readiness: readinessCache,
104
+ getReadiness,
105
+ refreshReadiness,
106
+ observeBaseUrl: deps.observeBaseUrl,
107
+ fetchImpl: deps.fetchImpl,
108
+ getConsoleOrigin: deps.getConsoleOrigin,
109
+ };
110
+ const chatStore = new ChatSessionStore(appData);
111
+ const chatEventEpoch = randomBytes(12).toString("hex");
112
+ const chatEvents = createChatEventStore({
113
+ appData,
114
+ epoch: chatEventEpoch,
115
+ });
116
+ const chatTurnExecutions = createChatTurnExecutionRegistry();
117
+ const questionRegistry = new UserQuestionRegistry(path.join(appData.chats, "user-questions"));
118
+ const todoRegistry = new TodoListRegistry();
119
+ questionRegistry.subscribe((change) => {
120
+ const question = change.question;
121
+ chatEvents.append(question.sessionId, question.turnId ?? `${question.sessionId}:question`, {
122
+ kind: `question_${change.type}`,
123
+ data: { question },
124
+ });
125
+ });
126
+ todoRegistry.subscribe((change) => {
127
+ chatEvents.append(change.sessionId, change.turnId ?? `${change.sessionId}:todo`, {
128
+ kind: change.type === "updated" ? "todo_updated" : "todo_cleared",
129
+ data: {
130
+ todos: change.todos,
131
+ ...(change.toolCallId ? { toolCallId: change.toolCallId } : {}),
132
+ },
133
+ });
134
+ });
135
+ const chatOperationLinker = createChatOperationLinker({
136
+ chatEvents,
137
+ operations,
138
+ operationEvents: events,
139
+ });
140
+ const chatRuntime = new ConsolePiRuntime({
141
+ cwd: repoRoot,
142
+ sessionDir: appData.chatSessions,
143
+ resourcePreferencesPath: appData.resourcePreferences,
144
+ actionContext,
145
+ questionRegistry,
146
+ todoRegistry,
147
+ });
148
+ const chatRouteDeps = {
149
+ runtime: chatRuntime,
150
+ store: chatStore,
151
+ events: chatEvents,
152
+ turnExecutions: chatTurnExecutions,
153
+ questionRegistry,
154
+ todoRegistry,
155
+ operationLinker: chatOperationLinker,
156
+ appData,
157
+ bootToken: deps.bootToken,
158
+ getConsoleOrigin: deps.getConsoleOrigin,
159
+ allowNonLoopbackAccess: deps.allowNonLoopbackAccess,
160
+ };
161
+ const { recoverDeferredTurns } = await import("./chat/routes.js");
162
+ const nightAuxTicker = new NightAuxTicker({ repoRoot, client });
163
+ const observeRouteContext = createObserveRouteContext(repoRoot, {
164
+ staticDir: deps.inspectStaticDir,
165
+ });
166
+ let disposed = false;
167
+ const context = {
168
+ workspaceId: record.workspaceId,
169
+ record,
170
+ repoRoot,
171
+ appData,
172
+ registry,
173
+ client,
174
+ operations,
175
+ events,
176
+ drafts,
177
+ confirmations,
178
+ dagExecutionReceipts,
179
+ interviews,
180
+ assessments,
181
+ chatRouteDeps,
182
+ chatOperationLinker,
183
+ chatRuntime,
184
+ nightAuxTicker,
185
+ actionContext,
186
+ observeRouteContext,
187
+ getReadiness,
188
+ refreshReadiness,
189
+ recoverDeferredTurns: () => recoverDeferredTurns(chatRouteDeps),
190
+ dispose: () => {
191
+ if (disposed)
192
+ return;
193
+ disposed = true;
194
+ try {
195
+ chatOperationLinker.dispose();
196
+ }
197
+ catch {
198
+ // best-effort
199
+ }
200
+ try {
201
+ chatRuntime.disposeAll();
202
+ }
203
+ catch {
204
+ // best-effort
205
+ }
206
+ try {
207
+ nightAuxTicker.dispose();
208
+ }
209
+ catch {
210
+ // best-effort
211
+ }
212
+ },
213
+ };
214
+ return context;
215
+ }
216
+ /** Inspect-facing readiness/capability summary for a registry record. */
217
+ export async function summarizeWorkspaceRecord(record, deps) {
218
+ let state;
219
+ try {
220
+ const report = await probePiReadiness({
221
+ override: deps.piReadinessProbe,
222
+ repoRoot: record.repoRoot,
223
+ });
224
+ state = report.state;
225
+ }
226
+ catch {
227
+ state = "setup-required";
228
+ }
229
+ return {
230
+ state,
231
+ summary: `pi=${state}; inspect=${deps.inspectReady ? "ready" : "unavailable"}`,
232
+ };
233
+ }
234
+ void existsSync;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * User-level Workspace Registry (2026-08-22 multi-workspace foundation, W1).
3
+ *
4
+ * Persists the set of registered Console workspaces at
5
+ * `<appDataRoot>/workspaces.json` (user level, shared across Console boots),
6
+ * independent of per-repo fingerprint namespaces.
7
+ *
8
+ * Invariants:
9
+ * - workspaceId is stable per normalized realpath: it is derived from
10
+ * sha256("loop-console-workspace-v1\0" + realpath) and never minted twice.
11
+ * - REMOVE is soft (hidden=true); on-disk directories, AppData namespaces,
12
+ * Sessions, Operations and DAGs are never deleted.
13
+ * - Re-adding a previously removed workspace RESTORES the original record
14
+ * (same workspaceId + repoFingerprint; only lastAccessedAt advances).
15
+ * - Duplicate visible realpath is rejected; add failures leave the registry
16
+ * byte-identical (same ids and order).
17
+ */
18
+ import { createHash, randomBytes } from "node:crypto";
19
+ import { realpathSync, statSync } from "node:fs";
20
+ import path from "node:path";
21
+ import { readJsonIfExists, writeSecureJson, } from "./app-data.js";
22
+ import { normalizeWorktreeRealpath, repoFingerprintV1, } from "./repo-fingerprint.js";
23
+ export const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1;
24
+ /** Stable workspaceId: sha256("loop-console-workspace-v1\0" + normalizedRealpath). */
25
+ export function workspaceIdForRealpath(normalizedRealpath) {
26
+ return createHash("sha256")
27
+ .update(`loop-console-workspace-v1\0${normalizedRealpath}`, "utf8")
28
+ .digest("hex");
29
+ }
30
+ export class WorkspaceRegistry {
31
+ appData;
32
+ options;
33
+ file;
34
+ cache;
35
+ constructor(appData, options = {}) {
36
+ this.appData = appData;
37
+ this.options = options;
38
+ this.file = path.join(appData.root, "workspaces.json");
39
+ }
40
+ /** Auto-register the startup repoRoot (idempotent). */
41
+ async ensureStartupWorkspace(repoRoot) {
42
+ const record = await this.add(repoRoot);
43
+ if (record.ok)
44
+ return record.record;
45
+ // Startup root may already be visible (duplicate) or previously hidden —
46
+ // re-adding a hidden record restores it via add(); the only expected
47
+ // failure left is DUPLICATE (already visible), which returns the existing.
48
+ const existing = await this.findByRealpath(repoRoot);
49
+ if (existing)
50
+ return existing;
51
+ throw new Error(`workspace registry: startup root rejected: ${record.code}`);
52
+ }
53
+ /**
54
+ * Register (or restore) a workspace directory.
55
+ * Failures never mutate the persisted registry.
56
+ * `options.name` overrides the default directory-name label.
57
+ */
58
+ async add(rawPath, options = {}) {
59
+ const trimmed = rawPath.trim();
60
+ if (!trimmed) {
61
+ return {
62
+ ok: false,
63
+ code: "INVALID_PATH",
64
+ message: "path is required",
65
+ };
66
+ }
67
+ let real;
68
+ try {
69
+ real = realpathSync(path.resolve(trimmed));
70
+ }
71
+ catch {
72
+ return {
73
+ ok: false,
74
+ code: "NOT_A_DIRECTORY",
75
+ message: `path does not exist: ${trimmed}`,
76
+ };
77
+ }
78
+ let st;
79
+ try {
80
+ st = statSync(real);
81
+ }
82
+ catch {
83
+ return {
84
+ ok: false,
85
+ code: "UNREADABLE",
86
+ message: `path is not readable: ${trimmed}`,
87
+ };
88
+ }
89
+ if (!st.isDirectory()) {
90
+ return {
91
+ ok: false,
92
+ code: "NOT_A_DIRECTORY",
93
+ message: `path is not a directory: ${trimmed}`,
94
+ };
95
+ }
96
+ const normalized = normalizeWorktreeRealpath(real);
97
+ const existing = await this.findByRealpathNormalized(normalized);
98
+ if (existing && !existing.hidden) {
99
+ return {
100
+ ok: false,
101
+ code: "DUPLICATE",
102
+ message: `workspace already registered: ${existing.displayPath}`,
103
+ };
104
+ }
105
+ const nowIso = (this.options.now ?? (() => new Date()))().toISOString();
106
+ if (existing) {
107
+ // Restore the soft-removed record; identity (workspaceId,
108
+ // repoFingerprint, createdAt, order) is preserved.
109
+ existing.hidden = undefined;
110
+ existing.lastAccessedAt = nowIso;
111
+ if (options.name && options.name.trim()) {
112
+ existing.name = options.name.trim();
113
+ }
114
+ const registry = await this.load();
115
+ await this.persist(registry);
116
+ return { ok: true, record: existing, registryUnchanged: false };
117
+ }
118
+ const record = {
119
+ workspaceId: workspaceIdForRealpath(normalized),
120
+ name: options.name?.trim() || path.basename(normalized) || normalized,
121
+ displayPath: normalized,
122
+ repoRoot: real,
123
+ repoFingerprint: repoFingerprintV1(real),
124
+ createdAt: nowIso,
125
+ lastAccessedAt: nowIso,
126
+ };
127
+ const registry = await this.load();
128
+ registry.workspaces.push(record);
129
+ await this.persist(registry);
130
+ return { ok: true, record, registryUnchanged: false };
131
+ }
132
+ /** Visible list in canonical insertion order. */
133
+ async listVisible() {
134
+ const registry = await this.load();
135
+ return registry.workspaces.filter((workspace) => {
136
+ if (workspace.hidden)
137
+ return false;
138
+ try {
139
+ return statSync(workspace.repoRoot).isDirectory();
140
+ }
141
+ catch {
142
+ // Keep historical registry data, but do not project stale test roots
143
+ // into the Workspace browser after their directories disappear.
144
+ return false;
145
+ }
146
+ });
147
+ }
148
+ async get(workspaceId) {
149
+ const registry = await this.load();
150
+ return registry.workspaces.find((w) => w.workspaceId === workspaceId);
151
+ }
152
+ /** Get a VISIBLE workspace by id (removed workspaces are not operable). */
153
+ async getVisible(workspaceId) {
154
+ const record = await this.get(workspaceId);
155
+ if (!record || record.hidden)
156
+ return undefined;
157
+ try {
158
+ return statSync(record.repoRoot).isDirectory() ? record : undefined;
159
+ }
160
+ catch {
161
+ return undefined;
162
+ }
163
+ }
164
+ /** Soft-remove: hides the record; never touches directories or AppData. */
165
+ async remove(workspaceId) {
166
+ const registry = await this.load();
167
+ const record = registry.workspaces.find((w) => w.workspaceId === workspaceId);
168
+ if (!record || record.hidden)
169
+ return false;
170
+ record.hidden = true;
171
+ await this.persist(registry);
172
+ return true;
173
+ }
174
+ async findByRealpath(rawPath) {
175
+ let normalized;
176
+ try {
177
+ normalized = normalizeWorktreeRealpath(path.resolve(rawPath.trim()));
178
+ }
179
+ catch {
180
+ return undefined;
181
+ }
182
+ return this.findByRealpathNormalized(normalized);
183
+ }
184
+ async findByRealpathNormalized(normalized) {
185
+ const registry = await this.load();
186
+ return registry.workspaces.find((w) => w.displayPath === normalized);
187
+ }
188
+ async load() {
189
+ if (this.cache)
190
+ return this.cache;
191
+ const raw = await readJsonIfExists(this.file).catch(() => undefined);
192
+ const registry = {
193
+ schemaVersion: 1,
194
+ workspaces: raw && Array.isArray(raw.workspaces)
195
+ ? raw.workspaces.filter((w) => Boolean(w) &&
196
+ typeof w === "object" &&
197
+ typeof w.workspaceId === "string" &&
198
+ typeof w.displayPath === "string" &&
199
+ typeof w.repoRoot === "string")
200
+ : [],
201
+ };
202
+ this.cache = registry;
203
+ return registry;
204
+ }
205
+ async persist(registry) {
206
+ // writeSecureJson is atomic; a reader mid-write sees the previous bytes.
207
+ await writeSecureJson(this.file, registry);
208
+ this.cache = registry;
209
+ }
210
+ }
211
+ /** Deterministic-looking nonce reserved for tests requiring unique roots. */
212
+ export function mintWorkspaceNonce() {
213
+ return randomBytes(8).toString("hex");
214
+ }
@@ -83,6 +83,10 @@ export function createObserveServer(options) {
83
83
  url: `http://${host}:${addr.port}`,
84
84
  close: () => new Promise((closeResolve, closeReject) => {
85
85
  server.close((err) => (err ? closeReject(err) : closeResolve()));
86
+ // Explicit shutdown must not wait for the 65s keep-alive window.
87
+ // This also closes active SSE clients so process/test teardown is
88
+ // deterministic instead of timing out behind pooled connections.
89
+ server.closeAllConnections();
86
90
  }),
87
91
  });
88
92
  });
@@ -1,8 +1,12 @@
1
1
  /** Fetch and artifact helpers (Observe UI R5). */
2
+ import {
3
+ fetchWithWorkspaceScope,
4
+ workspaceScope,
5
+ } from "./state.js";
2
6
 
3
7
  export async function fetchJson(url) {
4
8
  try {
5
- const res = await fetch(url);
9
+ const res = await fetchWithWorkspaceScope(url);
6
10
  if (!res.ok) return null;
7
11
  return await res.json();
8
12
  } catch {
@@ -18,7 +22,10 @@ export async function fetchJson(url) {
18
22
  export async function fetchJsonResult(url, options = {}) {
19
23
  const signal = options?.signal;
20
24
  try {
21
- const res = await fetch(url, signal ? { signal } : undefined);
25
+ const res = await fetchWithWorkspaceScope(
26
+ url,
27
+ signal ? { signal } : undefined,
28
+ );
22
29
  let body = null;
23
30
  try {
24
31
  body = await res.json();
@@ -92,7 +99,7 @@ export async function fetchDagNodeProcess(runId, nodeId, key) {
92
99
  */
93
100
  export async function fetchSpecEvidenceFile(runId, nodeId, source, relPath) {
94
101
  const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence/file?source=${encodeURIComponent(source)}&path=${encodeURIComponent(relPath)}`;
95
- const res = await fetch(url);
102
+ const res = await fetchWithWorkspaceScope(url);
96
103
  let body = null;
97
104
  try {
98
105
  body = await res.json();
@@ -130,15 +137,28 @@ export async function postOperatorAction(action, actionParams = {}) {
130
137
  body: { error: { code: "PROMPT_OVERRIDE_DISABLED", message: "提示词重启未启用(服务端未注入确认 token)" } },
131
138
  };
132
139
  }
140
+ const workspaceId = workspaceScope();
141
+ if (!workspaceId) {
142
+ return {
143
+ ok: false,
144
+ status: 400,
145
+ body: {
146
+ error: {
147
+ code: "WORKSPACE_REQUIRED",
148
+ message: "workspaceId is required",
149
+ },
150
+ },
151
+ };
152
+ }
133
153
  try {
134
- const res = await fetch("/api/operator/v1/operations", {
154
+ const res = await fetchWithWorkspaceScope("/api/operator/v1/operations", {
135
155
  method: "POST",
136
156
  credentials: "same-origin",
137
157
  headers: {
138
158
  "Content-Type": "application/json",
139
159
  "x-loop-console-confirmation": token,
140
160
  },
141
- body: JSON.stringify({ action, actionParams }),
161
+ body: JSON.stringify({ workspaceId, action, actionParams }),
142
162
  });
143
163
  let body = null;
144
164
  try {
@@ -155,7 +175,7 @@ export async function postOperatorAction(action, actionParams = {}) {
155
175
  /** Poll a Console operation snapshot (read-only GET). */
156
176
  export async function fetchOperatorOperation(operationId) {
157
177
  try {
158
- const res = await fetch(
178
+ const res = await fetchWithWorkspaceScope(
159
179
  `/api/operator/v1/operations/${encodeURIComponent(operationId)}`,
160
180
  { credentials: "same-origin" },
161
181
  );
@@ -1,9 +1,8 @@
1
- /** Observe UI bootstrap: route wiring, listeners, compatibility re-exports. */
1
+ /** Observe UI bootstrap: standalone compatibility entry + re-exports. */
2
2
  import {
3
3
  parseHashRoute,
4
4
  parseHashRouteWithFilters,
5
5
  buildHashPath,
6
- parseRoute,
7
6
  } from "./router.js";
8
7
  import { parseArtifactPreviewResponse } from "./api.js";
9
8
  import {
@@ -15,7 +14,6 @@ import {
15
14
  } from "./relations.js";
16
15
  import { copyText, renderRecommendedCommand } from "./copy.js";
17
16
  import {
18
- isPageVisible,
19
17
  detailPollDelay,
20
18
  updateDagGraphViewportState,
21
19
  captureDagGraphViewportState,
@@ -25,11 +23,9 @@ import {
25
23
  shouldReuseDagDetailShell,
26
24
  shouldPreserveDiagnosticsOpen,
27
25
  isInteractiveGestureActive,
28
- stopTimers,
29
26
  renderViewState,
30
27
  mountViewState,
31
28
  viewStateDescriptor,
32
- uiState,
33
29
  } from "./state.js";
34
30
  import { sortPoolTasks, poolStatusCounts } from "./format-pool.js";
35
31
  import {
@@ -45,34 +41,22 @@ import {
45
41
  dashboardVisibleFeatures,
46
42
  findDagForRun,
47
43
  } from "./dag-model.js";
48
-
49
- import { renderFailures } from "./views/failures.js";
50
- import { startPoolView } from "./views/pool.js";
51
- import { startDashboardPolling } from "./views/dashboard.js";
52
- import { startDagsView } from "./views/dags.js";
53
- import { renderFeatureDetail } from "./views/feature.js";
54
- import { renderBatch } from "./views/batch.js";
55
- import { startTaskPolling } from "./views/task.js";
56
- import { startRunPolling } from "./views/run.js";
57
- import { startDagPolling } from "./views/dag.js";
58
44
  import { renderDagGraph } from "./views/dag-graph.js";
59
- import { renderNightJobs } from "./views/night.js";
60
45
  import {
61
46
  selectDagNode,
62
47
  closeDagInspector,
63
- closeDagInspectorOnOutsideClick,
64
48
  renderDagInspector,
65
49
  } from "./views/dag-inspector.js";
66
50
  import {
67
51
  mergeSessionEvents,
68
52
  renderSessionTimeline,
69
53
  } from "./views/session-timeline.js";
70
- import { mountOperatorChrome } from "./operator-chrome.js";
54
+ import {
55
+ route,
56
+ startStandaloneInspect,
57
+ tryCanonicalizeStandaloneInspect,
58
+ } from "./inspect-workspace.js";
71
59
 
72
- // Compatibility re-exports for poll-stable UI helpers (owned by state.js / views):
73
- // reuse-dag-detail-shell, reuse-dag-graph-wrap, existingGraphWrap?.parentNode === ranksEl,
74
- // existingDetails?.parentNode === metaEl, let pre = existingPre, computeFollowLatest,
75
- // updateDagOutputViewportState, isInteractiveGestureActive.
76
60
  export {
77
61
  detailPollDelay,
78
62
  parseMarkdownBlocks,
@@ -114,56 +98,20 @@ export {
114
98
  renderDagInspector,
115
99
  renderSessionTimeline,
116
100
  mergeSessionEvents,
101
+ route,
102
+ tryCanonicalizeStandaloneInspect,
117
103
  };
118
104
 
119
- function route() {
120
- stopTimers();
121
- const r = parseRoute();
122
- if (r.view === "dashboard") {
123
- startDashboardPolling(r.scrollTo);
124
- } else if (r.view === "dags") {
125
- void startDagsView();
126
- } else if (r.view === "batch") {
127
- void renderBatch(r.batchRunId);
128
- } else if (r.view === "run") {
129
- startRunPolling(r.workerRunId);
130
- } else if (r.view === "dag") {
131
- startDagPolling(r.dagRunId);
132
- } else if (r.view === "failures") {
133
- void renderFailures();
134
- } else if (r.view === "pool") {
135
- startPoolView();
136
- } else if (r.view === "night") {
137
- void renderNightJobs(r.scheduleId);
138
- } else if (r.view === "task") {
139
- startTaskPolling(r.featureId, r.taskId);
140
- } else if (r.view === "feature") {
141
- void renderFeatureDetail(r.featureId);
142
- }
143
- }
144
-
145
105
  if (typeof window !== "undefined") {
146
- // 先同步挂载共享壳,再启动路由;首个 snapshot 的新鲜度不会因异步挂载丢失。
147
- const chromeHost = document.getElementById("operator-chrome");
148
- if (chromeHost) {
149
- mountOperatorChrome(chromeHost, {
150
- mode: "inspect",
151
- liveLocation: true,
152
- healthUrl: "/api/health",
153
- readinessTitle: "Pi 状态(只读展示)。模型与认证管理在操作侧,点击进入。",
154
- });
106
+ // Compatibility listeners remain owned by inspect-workspace.js:
107
+ // window.addEventListener("hashchange", route)
108
+ // window.addEventListener("load", route)
109
+ // document.addEventListener("pointerdown", closeDagInspectorOnOutsideClick)
110
+ // document.addEventListener("visibilitychange", ...)
111
+ // reuse-dag-detail-shell, reuse-dag-graph-wrap, existingGraphWrap?.parentNode === ranksEl,
112
+ // existingDetails?.parentNode === metaEl, let pre = existingPre, computeFollowLatest,
113
+ // updateDagOutputViewportState, isInteractiveGestureActive.
114
+ if (!tryCanonicalizeStandaloneInspect(window.location)) {
115
+ startStandaloneInspect();
155
116
  }
156
- window.addEventListener("hashchange", route);
157
- window.addEventListener("load", route);
158
- document.addEventListener("pointerdown", closeDagInspectorOnOutsideClick);
159
- document.addEventListener("visibilitychange", () => {
160
- if (!isPageVisible()) return;
161
- const r = parseRoute();
162
- if (r.view === "dashboard") startDashboardPolling();
163
- // History page is manual-refresh only (no 5s dashboard polling).
164
- if (r.view === "run") startRunPolling(r.workerRunId, false);
165
- if (r.view === "dag") startDagPolling(r.dagRunId, false);
166
- if (r.view === "pool" && uiState.poolAutoRefresh) startPoolView();
167
- if (r.view === "task") startTaskPolling(r.featureId, r.taskId);
168
- });
169
117
  }
@@ -210,7 +210,7 @@
210
210
  >
211
211
  刷新
212
212
  </button>
213
- <a class="page-action-btn" href="/#/night">在操作台管理 →</a>
213
+ <a class="page-action-btn" href="/#/operate/night">在操作台管理 →</a>
214
214
  </div>
215
215
  </header>
216
216
  <section