@tea-agent/loop-agent 0.40.0-next.8 → 0.40.0

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 (150) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -20
  3. package/README.md +2 -2
  4. package/dist/application/dag/generate-task-dag.js +6 -2
  5. package/dist/build-stamp.json +3 -3
  6. package/dist/cli/command-definitions.js +9 -5
  7. package/dist/cli/program.js +38 -3
  8. package/dist/commands/client-recovery.js +8 -36
  9. package/dist/commands/init-model-catalog.js +22 -0
  10. package/dist/commands/task-advance.js +23 -1
  11. package/dist/commands/task-source-prepare.js +5 -0
  12. package/dist/commands/task-status.js +1 -0
  13. package/dist/executors/dag-pi-executor.js +3 -0
  14. package/dist/executors/pi-executor.js +4 -2
  15. package/dist/executors/pi-sdk-executor.js +79 -6
  16. package/dist/executors/shell-executor.js +94 -20
  17. package/dist/governance/checks.js +1 -0
  18. package/dist/shared/operator/capabilities.js +9 -1
  19. package/dist/shared/pi-context-pressure/checkpoint.js +116 -0
  20. package/dist/shared/pi-context-pressure/compaction-policy.js +151 -0
  21. package/dist/shared/pi-context-pressure/env.js +58 -0
  22. package/dist/shared/pi-context-pressure/extension.js +100 -0
  23. package/dist/shared/pi-context-pressure/index.js +7 -0
  24. package/dist/shared/pi-context-pressure/overflow.js +252 -0
  25. package/dist/shared/pi-context-pressure/sift-bridge.js +386 -0
  26. package/dist/shared/pi-context-pressure/telemetry.js +51 -0
  27. package/dist/worker/console/chat/assistant-content.js +7 -0
  28. package/dist/worker/console/chat/chat-event-store.js +82 -11
  29. package/dist/worker/console/chat/goal-round-driver.js +72 -0
  30. package/dist/worker/console/chat/goals.js +300 -0
  31. package/dist/worker/console/chat/pi-runtime.js +285 -8
  32. package/dist/worker/console/chat/provider-error.js +2 -1
  33. package/dist/worker/console/chat/repo-browser.js +94 -12
  34. package/dist/worker/console/chat/routes.js +495 -30
  35. package/dist/worker/console/chat/session-catalog.js +48 -0
  36. package/dist/worker/console/chat/session-stats.js +96 -0
  37. package/dist/worker/console/chat/session-store.js +27 -18
  38. package/dist/worker/console/chat/shortcuts.js +7 -0
  39. package/dist/worker/console/chat/sift-bridge.js +1 -0
  40. package/dist/worker/console/chat/turn-process.js +42 -5
  41. package/dist/worker/console/dag-execution-receipt.js +20 -2
  42. package/dist/worker/console/operator-actions.js +20 -5
  43. package/dist/worker/console/operator-selection.js +6 -2
  44. package/dist/worker/console/pi-plugins.catalog.json +73658 -0
  45. package/dist/worker/console/pi-plugins.js +179 -0
  46. package/dist/worker/console/routes.js +246 -6
  47. package/dist/worker/console/server.js +173 -42
  48. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BYBmDfPW.js → abnfDiagram-N423BO3Z-Dy71QJp3.js} +1 -1
  49. package/dist/worker/console/static/assets/{arc-BKMmxpGF.js → arc-M25fNMTE.js} +1 -1
  50. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DJpwxr1d.js → architectureDiagram-T3A2C74G-CUWlVl1a.js} +1 -1
  51. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BaPj_ZlY.js → blockDiagram-VBNYF7ZC-Dd0Whpx2.js} +1 -1
  52. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8iGmHlm.js → c4Diagram-5PPSVZJV-BdfeCkI5.js} +1 -1
  53. package/dist/worker/console/static/assets/channel-Ba0AKiEZ.js +1 -0
  54. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DaxXQayc.js → chunk-2GRJ4B5K-D_1sUkyD.js} +1 -1
  55. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DqQKGA7q.js → chunk-2Q5K7J3B-Cxnxf36E.js} +1 -1
  56. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CgMKpJBB.js → chunk-5RXB4S5H-D9RG3G_x.js} +1 -1
  57. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BpToUlaW.js → chunk-5VM5RSS4-Zz9jgjLX.js} +1 -1
  58. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BsEWOHxv.js → chunk-6Q2QTUOP-BCggDRCH.js} +1 -1
  59. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CKGSCe71.js → chunk-GF5L2VYU-CFg-5iAS.js} +1 -1
  60. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-Bm1U5299.js → chunk-JWPE2WC7-DRQTE8x_.js} +1 -1
  61. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BI0ov870.js → chunk-KBJHAD2P-C_Dw7uZ8.js} +1 -1
  62. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-BmnQo0iJ.js → chunk-RYQCIY6F-BjIiApsk.js} +1 -1
  63. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BJBqa6Xc.js → chunk-XXDRQBXY-B5XQD4KG.js} +1 -1
  64. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BU3IjVEe.js +1 -0
  65. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BU3IjVEe.js +1 -0
  66. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BuN9-7AR.js → cose-bilkent-JH36ORCC-huHqH6WQ.js} +1 -1
  67. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bw22jc2k.js → cynefin-VYW2F7L2-BSi6yZqP.js} +1 -1
  68. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BHnSH8Dr.js → cynefinDiagram-MW4NZA55-Bs_Jp31y.js} +1 -1
  69. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BoXXIegj.js → dagre-VZM6K2ZE-CYxavikT.js} +1 -1
  70. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CU8qyPhT.js → diagram-7IWD3JNH-B6TErl71.js} +1 -1
  71. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-lrjhF0S8.js → diagram-B4RE2ZJO-CvH4J2ii.js} +1 -1
  72. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DL0QDEpo.js → diagram-LBJQPF4R-v1NBAqXS.js} +1 -1
  73. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DxVMJMBH.js → diagram-Q27KOJAE-CUNHVAn2.js} +1 -1
  74. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CFg9FzvG.js → diagram-UB23O5K3-Dzz1gUu-.js} +1 -1
  75. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CQgP9fTG.js → ebnfDiagram-BXEA7PRR-CCF8_LjK.js} +1 -1
  76. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-A5vQXx9U.js → erDiagram-JOGREHBK-DObdPVLa.js} +1 -1
  77. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-ytXnLyXD.js → flowDiagram-UKHOOZJN-DlN8Pxrj.js} +1 -1
  78. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CYuksfnS.js → ganttDiagram-PKOTCBZU-BJKIPTGX.js} +1 -1
  79. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-f_jW3zgU.js → gitGraphDiagram-DS77QQ5N-XcSSnObi.js} +1 -1
  80. package/dist/worker/console/static/assets/index-CIYzS16i.css +1 -0
  81. package/dist/worker/console/static/assets/index-CS3QOTfl.js +451 -0
  82. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-C3HLE-xV.js → infoDiagram-6WML65LV-BKrRKMww.js} +1 -1
  83. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DwpgejS6.js → ishikawaDiagram-WSZJBQD7-CN5dk-yb.js} +1 -1
  84. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-ll0YWX1y.js → journeyDiagram-NVQOT4AX-B4P4KZ-Y.js} +1 -1
  85. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Hq3rchvU.js → kanban-definition-27J2QSJJ-BRlteP32.js} +1 -1
  86. package/dist/worker/console/static/assets/{linear-BaGjJx8z.js → linear-CG8D9fuL.js} +1 -1
  87. package/dist/worker/console/static/assets/{mermaid.core-CKGQHWX_.js → mermaid.core-BuB6P2rX.js} +5 -5
  88. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DM1FA6Dr.js → mindmap-definition-FAOFIHXS-DYXGl_DR.js} +1 -1
  89. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Xa9fHkE2.js → pegDiagram-VL7TDLO6-BM48yEVG.js} +1 -1
  90. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-pCd4PDqJ.js → pieDiagram-7S7Q4E2Y-mT8NL-Ow.js} +1 -1
  91. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-KqjpEBLk.js → quadrantDiagram-CIZ2JOQS-C4pZ6DH_.js} +1 -1
  92. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DgvR7LZD.js → railroadDiagram-AXF67PYL-BKAcr70m.js} +1 -1
  93. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-Bys1UCWA.js → requirementDiagram-LRYGKXZP-CRxcKpoO.js} +1 -1
  94. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Ms9npnZt.js → sankeyDiagram-W5VNT64P-Ds4LxQDI.js} +1 -1
  95. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DEIWrtQZ.js → sequenceDiagram-SI44F4Z6-CPLwPf_p.js} +1 -1
  96. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DELJ0AhZ.js → sizeCapture-X5ZJPWSS-CL65uij0.js} +1 -1
  97. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-BpHt9wKh.js → stateDiagram-OKZ733FA-CO0Mgd1W.js} +1 -1
  98. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BUewAVYS.js +1 -0
  99. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-GKRsBluT.js → swimlanes-SLNWSIFB-Cxv3TSNw.js} +2 -2
  100. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bhe67Wjl.js +8 -0
  101. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cwsq1DxL.js → timeline-definition-Z64GVDOM-Dw85f9L2.js} +1 -1
  102. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-CfaqtKx0.js → vennDiagram-T6HMQDX7-B4QLCqCy.js} +1 -1
  103. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BlEUtK_g.js → wardleyDiagram-T6FBY63Y-B_yKCWwn.js} +1 -1
  104. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CHSRXPvO.js → xychartDiagram-ELKLHX3M-nniBEGv2.js} +1 -1
  105. package/dist/worker/console/static/index.html +2 -2
  106. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +95 -25
  107. package/dist/worker/console/static-src/operator-chat/compaction-message.js +35 -1
  108. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +3 -1
  109. package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
  110. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +26 -19
  111. package/dist/worker/console/static-src/operator-chat/useChatStream.js +0 -11
  112. package/dist/worker/console/static-src/operator-chat/useChatThread.js +14 -1
  113. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +34 -4
  114. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +28 -3
  115. package/dist/worker/console/static-src/operator-chat/workspace-reorder.js +18 -0
  116. package/dist/worker/console/static-src/shell/useWorkspaces.js +150 -34
  117. package/dist/worker/console/static-src/shell/workspace-route.js +3 -1
  118. package/dist/worker/console/workspace-context.js +28 -3
  119. package/dist/worker/console/workspace-registry.js +333 -60
  120. package/dist/worker/observe/static/inspect-bootstrap.d.ts +8 -0
  121. package/dist/worker/observe/static/inspect-bootstrap.js +57 -0
  122. package/dist/worker/observe/static/inspect-workspace.js +54 -10
  123. package/dist/worker/observe/static/operator-chrome.css +23 -4
  124. package/dist/worker/observe/static/operator-chrome.js +7 -2
  125. package/dist/worker/observe/static/styles.css +87 -25
  126. package/dist/worker/observe/static/views/dag-inspector.js +42 -1
  127. package/dist/worker/observe/static/views/dags.js +7 -5
  128. package/dist/worker/observe/static/views/session-timeline.js +135 -0
  129. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +162 -8
  130. package/dist/workflows/dag/backend-test-pytest-collection.js +70 -2
  131. package/dist/workflows/dag/backend-test-result-contract.js +5 -1
  132. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  133. package/dist/workflows/dag/backend-test-writer-completeness.js +18 -12
  134. package/dist/workflows/dag/init-hybrid.js +32 -9
  135. package/dist/workflows/dag/types.js +13 -0
  136. package/docs/architecture/runtime-boundaries.md +2 -1
  137. package/docs/templates/agent-dag.schema.json +18 -0
  138. package/docs/templates/backend-test-dag.json +27 -9
  139. package/docs/templates/init-managed-agents.md +1 -1
  140. package/package.json +3 -2
  141. package/skills/loop-agent/references/command-reference.md +15 -1
  142. package/dist/worker/console/static/assets/channel-CrzAa3FA.js +0 -1
  143. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-B7QwCCb4.js +0 -1
  144. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-B7QwCCb4.js +0 -1
  145. package/dist/worker/console/static/assets/index-BB6KxwT_.css +0 -1
  146. package/dist/worker/console/static/assets/index-D_wxbadT.js +0 -407
  147. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BEeSswlv.js +0 -1
  148. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-B2m_wca8.js +0 -8
  149. package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
  150. package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
@@ -16,11 +16,12 @@
16
16
  * byte-identical (same ids and order).
17
17
  */
18
18
  import { createHash, randomBytes } from "node:crypto";
19
- import { realpathSync, statSync } from "node:fs";
19
+ import { realpathSync, statSync, unwatchFile, watchFile } from "node:fs";
20
+ import { mkdir, open, readFile, rm, stat } from "node:fs/promises";
20
21
  import path from "node:path";
21
22
  import { readJsonIfExists, writeSecureJson, } from "./app-data.js";
22
23
  import { normalizeWorktreeRealpath, repoFingerprintV1, } from "./repo-fingerprint.js";
23
- export const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1;
24
+ export const WORKSPACE_REGISTRY_SCHEMA_VERSION = 3;
24
25
  /** Stable workspaceId: sha256("loop-console-workspace-v1\0" + normalizedRealpath). */
25
26
  export function workspaceIdForRealpath(normalizedRealpath) {
26
27
  return createHash("sha256")
@@ -31,11 +32,19 @@ export class WorkspaceRegistry {
31
32
  appData;
32
33
  options;
33
34
  file;
34
- cache;
35
+ lockFile;
36
+ mutationTail = Promise.resolve();
37
+ listeners = new Set();
38
+ watching = false;
39
+ lastObservedRevision = 0;
40
+ onRegistryFileChanged = () => {
41
+ void this.load().then((registry) => this.emitRevision(registry.revision));
42
+ };
35
43
  constructor(appData, options = {}) {
36
44
  this.appData = appData;
37
45
  this.options = options;
38
46
  this.file = path.join(appData.root, "workspaces.json");
47
+ this.lockFile = path.join(appData.root, "workspaces.lock");
39
48
  }
40
49
  /** Auto-register the startup repoRoot (idempotent). */
41
50
  async ensureStartupWorkspace(repoRoot) {
@@ -94,56 +103,72 @@ export class WorkspaceRegistry {
94
103
  };
95
104
  }
96
105
  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
106
  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();
107
+ return this.mutate((registry) => {
108
+ const existing = registry.workspaces.find((workspace) => workspace.displayPath === normalized);
109
+ if (existing && !existing.hidden) {
110
+ return {
111
+ changed: false,
112
+ value: {
113
+ ok: false,
114
+ code: "DUPLICATE",
115
+ message: `workspace already registered: ${existing.displayPath}`,
116
+ },
117
+ };
113
118
  }
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 };
119
+ if (existing) {
120
+ // Restore the soft-removed record; identity and order are preserved.
121
+ existing.hidden = undefined;
122
+ existing.lastAccessedAt = nowIso;
123
+ if (options.name?.trim())
124
+ existing.name = options.name.trim();
125
+ return {
126
+ changed: true,
127
+ value: { ok: true, record: existing, registryUnchanged: false },
128
+ };
129
+ }
130
+ const record = {
131
+ workspaceId: workspaceIdForRealpath(normalized),
132
+ name: options.name?.trim() || path.basename(normalized) || normalized,
133
+ displayPath: normalized,
134
+ repoRoot: real,
135
+ repoFingerprint: repoFingerprintV1(real),
136
+ createdAt: nowIso,
137
+ lastAccessedAt: nowIso,
138
+ sessionIds: [],
139
+ };
140
+ registry.workspaces.push(record);
141
+ return {
142
+ changed: true,
143
+ value: { ok: true, record, registryUnchanged: false },
144
+ };
145
+ });
131
146
  }
132
- /** Visible list in canonical insertion order. */
147
+ /** Visible list in canonical persisted order. */
133
148
  async listVisible() {
149
+ return (await this.snapshotVisible()).workspaces;
150
+ }
151
+ /** Complete durable registry order, including soft-removed Workspaces. */
152
+ async listAll() {
153
+ return (await this.load()).workspaces;
154
+ }
155
+ async snapshotVisible() {
134
156
  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
- });
157
+ return {
158
+ revision: registry.revision,
159
+ workspaces: registry.workspaces.filter((workspace) => {
160
+ if (workspace.hidden)
161
+ return false;
162
+ try {
163
+ return statSync(workspace.repoRoot).isDirectory();
164
+ }
165
+ catch {
166
+ // Keep historical registry data, but do not project stale test roots
167
+ // into the Workspace browser after their directories disappear.
168
+ return false;
169
+ }
170
+ }),
171
+ };
147
172
  }
148
173
  async get(workspaceId) {
149
174
  const registry = await this.load();
@@ -163,13 +188,159 @@ export class WorkspaceRegistry {
163
188
  }
164
189
  /** Soft-remove: hides the record; never touches directories or AppData. */
165
190
  async remove(workspaceId) {
166
- const registry = await this.load();
167
- const record = registry.workspaces.find((w) => w.workspaceId === workspaceId);
168
- if (!record || record.hidden)
191
+ return this.mutate((registry) => {
192
+ const record = registry.workspaces.find((workspace) => workspace.workspaceId === workspaceId);
193
+ if (!record || record.hidden)
194
+ return { changed: false, value: false };
195
+ record.hidden = true;
196
+ return { changed: true, value: true };
197
+ });
198
+ }
199
+ /** Move one visible workspace before another; a null anchor moves it last. */
200
+ async insertBefore(workspaceId, beforeWorkspaceId) {
201
+ return this.mutate((registry) => {
202
+ const source = registry.workspaces.find((workspace) => workspace.workspaceId === workspaceId && !workspace.hidden);
203
+ if (!source) {
204
+ return {
205
+ changed: false,
206
+ value: { ok: false, code: "WORKSPACE_NOT_FOUND" },
207
+ };
208
+ }
209
+ if (beforeWorkspaceId === workspaceId) {
210
+ return {
211
+ changed: false,
212
+ value: { ok: true, changed: false },
213
+ };
214
+ }
215
+ const anchor = beforeWorkspaceId
216
+ ? registry.workspaces.find((workspace) => workspace.workspaceId === beforeWorkspaceId && !workspace.hidden)
217
+ : undefined;
218
+ if (beforeWorkspaceId && !anchor) {
219
+ return {
220
+ changed: false,
221
+ value: { ok: false, code: "ANCHOR_NOT_FOUND" },
222
+ };
223
+ }
224
+ const visibleBefore = registry.workspaces
225
+ .filter((workspace) => !workspace.hidden)
226
+ .map((workspace) => workspace.workspaceId);
227
+ const visibleAfter = visibleBefore.filter((id) => id !== workspaceId);
228
+ const insertAt = beforeWorkspaceId
229
+ ? visibleAfter.indexOf(beforeWorkspaceId)
230
+ : visibleAfter.length;
231
+ visibleAfter.splice(insertAt, 0, workspaceId);
232
+ if (visibleAfter.every((id, index) => id === visibleBefore[index])) {
233
+ return {
234
+ changed: false,
235
+ value: { ok: true, changed: false },
236
+ };
237
+ }
238
+ registry.workspaces.splice(registry.workspaces.indexOf(source), 1);
239
+ const rawAnchorIndex = anchor
240
+ ? registry.workspaces.indexOf(anchor)
241
+ : registry.workspaces.length;
242
+ registry.workspaces.splice(rawAnchorIndex, 0, source);
243
+ return {
244
+ changed: true,
245
+ value: { ok: true, changed: true },
246
+ };
247
+ });
248
+ }
249
+ /** Attach one Session to exactly one Workspace and prepend it to manual order. */
250
+ async attachSession(workspaceId, sessionId) {
251
+ const id = sessionId.trim();
252
+ if (!id)
253
+ return false;
254
+ return this.mutate((registry) => {
255
+ const target = registry.workspaces.find((workspace) => workspace.workspaceId === workspaceId && !workspace.hidden);
256
+ if (!target)
257
+ return { changed: false, value: false };
258
+ const alreadyAttached = target.sessionIds.includes(id);
259
+ let changed = false;
260
+ for (const workspace of registry.workspaces) {
261
+ if (workspace === target)
262
+ continue;
263
+ const without = workspace.sessionIds.filter((candidate) => candidate !== id);
264
+ if (without.length !== workspace.sessionIds.length) {
265
+ workspace.sessionIds = without;
266
+ changed = true;
267
+ }
268
+ }
269
+ if (!alreadyAttached) {
270
+ target.sessionIds.unshift(id);
271
+ changed = true;
272
+ }
273
+ return { changed, value: true };
274
+ });
275
+ }
276
+ /** Remove one Session membership without touching the Session record itself. */
277
+ async detachSession(workspaceId, sessionId) {
278
+ const id = sessionId.trim();
279
+ if (!id)
169
280
  return false;
170
- record.hidden = true;
171
- await this.persist(registry);
172
- return true;
281
+ return this.mutate((registry) => {
282
+ const target = registry.workspaces.find((workspace) => workspace.workspaceId === workspaceId);
283
+ if (!target)
284
+ return { changed: false, value: false };
285
+ const next = target.sessionIds.filter((candidate) => candidate !== id);
286
+ if (next.length === target.sessionIds.length) {
287
+ return { changed: false, value: false };
288
+ }
289
+ target.sessionIds = next;
290
+ return { changed: true, value: true };
291
+ });
292
+ }
293
+ /**
294
+ * Reconcile a Workspace with its authoritative per-repo Session store.
295
+ * Existing manual order is retained; newly discovered legacy Sessions are
296
+ * prepended in discovery order, and missing records are removed from the
297
+ * membership projection only (their files are never touched).
298
+ */
299
+ async reconcileSessions(workspaceId, discoveredSessionIds) {
300
+ const discovered = [...new Set(discoveredSessionIds.map((id) => id.trim()).filter(Boolean))];
301
+ return this.mutate((registry) => {
302
+ const target = registry.workspaces.find((workspace) => workspace.workspaceId === workspaceId && !workspace.hidden);
303
+ if (!target)
304
+ return { changed: false, value: false };
305
+ const discoveredSet = new Set(discovered);
306
+ let changed = false;
307
+ for (const workspace of registry.workspaces) {
308
+ if (workspace === target)
309
+ continue;
310
+ const next = workspace.sessionIds.filter((id) => !discoveredSet.has(id));
311
+ if (next.length !== workspace.sessionIds.length) {
312
+ workspace.sessionIds = next;
313
+ changed = true;
314
+ }
315
+ }
316
+ const retained = target.sessionIds.filter((id) => discoveredSet.has(id));
317
+ const retainedSet = new Set(retained);
318
+ const additions = discovered.filter((id) => !retainedSet.has(id));
319
+ const next = [...additions, ...retained];
320
+ if (next.length !== target.sessionIds.length ||
321
+ next.some((id, index) => id !== target.sessionIds[index])) {
322
+ target.sessionIds = next;
323
+ changed = true;
324
+ }
325
+ return { changed, value: changed };
326
+ });
327
+ }
328
+ /** Notify same-Host clients and watch for revisions written by sibling Hosts. */
329
+ subscribe(listener) {
330
+ this.listeners.add(listener);
331
+ if (!this.watching) {
332
+ this.watching = true;
333
+ watchFile(this.file, { interval: 500, persistent: false }, this.onRegistryFileChanged);
334
+ }
335
+ return () => {
336
+ this.listeners.delete(listener);
337
+ if (this.listeners.size === 0)
338
+ this.stopWatching();
339
+ };
340
+ }
341
+ close() {
342
+ this.listeners.clear();
343
+ this.stopWatching();
173
344
  }
174
345
  async findByRealpath(rawPath) {
175
346
  let normalized;
@@ -186,26 +357,128 @@ export class WorkspaceRegistry {
186
357
  return registry.workspaces.find((w) => w.displayPath === normalized);
187
358
  }
188
359
  async load() {
189
- if (this.cache)
190
- return this.cache;
191
360
  const raw = await readJsonIfExists(this.file).catch(() => undefined);
192
361
  const registry = {
193
- schemaVersion: 1,
362
+ schemaVersion: WORKSPACE_REGISTRY_SCHEMA_VERSION,
363
+ revision: raw && Number.isSafeInteger(raw.revision) && (raw.revision ?? -1) >= 0
364
+ ? raw.revision
365
+ : 0,
194
366
  workspaces: raw && Array.isArray(raw.workspaces)
195
367
  ? raw.workspaces.filter((w) => Boolean(w) &&
196
368
  typeof w === "object" &&
197
369
  typeof w.workspaceId === "string" &&
198
370
  typeof w.displayPath === "string" &&
199
- typeof w.repoRoot === "string")
371
+ typeof w.repoRoot === "string").map((workspace) => ({
372
+ ...workspace,
373
+ sessionIds: Array.isArray(workspace.sessionIds)
374
+ ? workspace.sessionIds.filter((id) => typeof id === "string" && Boolean(id.trim()))
375
+ : [],
376
+ }))
200
377
  : [],
201
378
  };
202
- this.cache = registry;
203
379
  return registry;
204
380
  }
205
381
  async persist(registry) {
206
382
  // writeSecureJson is atomic; a reader mid-write sees the previous bytes.
207
383
  await writeSecureJson(this.file, registry);
208
- this.cache = registry;
384
+ this.emitRevision(registry.revision);
385
+ }
386
+ mutate(op) {
387
+ return this.serializeMutation(() => this.withFileLock(async () => {
388
+ // Always reload while holding the cross-process lock. This is the
389
+ // read-modify-write boundary that prevents stale-cache lost updates.
390
+ const registry = await this.load();
391
+ const result = op(registry);
392
+ if (result.changed) {
393
+ registry.revision += 1;
394
+ await this.persist(registry);
395
+ }
396
+ return result.value;
397
+ }));
398
+ }
399
+ serializeMutation(op) {
400
+ const next = this.mutationTail.then(op, op);
401
+ this.mutationTail = next.then(() => undefined, () => undefined);
402
+ return next;
403
+ }
404
+ async withFileLock(op) {
405
+ await mkdir(path.dirname(this.lockFile), { recursive: true, mode: 0o700 });
406
+ const deadline = Date.now() + 15_000;
407
+ for (let attempt = 0;; attempt += 1) {
408
+ try {
409
+ const handle = await open(this.lockFile, "wx", 0o600);
410
+ try {
411
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), "utf8");
412
+ return await op();
413
+ }
414
+ finally {
415
+ await handle.close().catch(() => undefined);
416
+ await rm(this.lockFile, { force: true }).catch(() => undefined);
417
+ }
418
+ }
419
+ catch (error) {
420
+ if (error.code !== "EEXIST")
421
+ throw error;
422
+ if (await this.removeAbandonedLock())
423
+ continue;
424
+ if (Date.now() >= deadline) {
425
+ throw new Error("workspace registry is busy in another Console process");
426
+ }
427
+ await new Promise((resolve) => setTimeout(resolve, Math.min(25 * (attempt + 1), 250)));
428
+ }
429
+ }
430
+ }
431
+ async removeAbandonedLock() {
432
+ try {
433
+ const [body, info] = await Promise.all([
434
+ readFile(this.lockFile, "utf8"),
435
+ stat(this.lockFile),
436
+ ]);
437
+ const ageMs = Date.now() - info.mtimeMs;
438
+ if (ageMs >= 60_000) {
439
+ await rm(this.lockFile, { force: true });
440
+ return true;
441
+ }
442
+ const pid = Number(JSON.parse(body).pid);
443
+ if (Number.isSafeInteger(pid) && pid > 0) {
444
+ try {
445
+ process.kill(pid, 0);
446
+ return false;
447
+ }
448
+ catch (error) {
449
+ const code = error.code;
450
+ if (code !== "ESRCH")
451
+ return false;
452
+ }
453
+ }
454
+ if (ageMs < 10_000 && !Number.isSafeInteger(pid)) {
455
+ return false;
456
+ }
457
+ await rm(this.lockFile, { force: true });
458
+ return true;
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ emitRevision(revision) {
465
+ if (revision <= this.lastObservedRevision)
466
+ return;
467
+ this.lastObservedRevision = revision;
468
+ for (const listener of this.listeners) {
469
+ try {
470
+ listener(revision);
471
+ }
472
+ catch {
473
+ /* Projection listeners must never invalidate a durable write. */
474
+ }
475
+ }
476
+ }
477
+ stopWatching() {
478
+ if (!this.watching)
479
+ return;
480
+ unwatchFile(this.file, this.onRegistryFileChanged);
481
+ this.watching = false;
209
482
  }
210
483
  }
211
484
  /** Deterministic-looking nonce reserved for tests requiring unique roots. */
@@ -0,0 +1,8 @@
1
+ export function extractInspectMutationToken(
2
+ html: string | null | undefined,
3
+ ): string | null;
4
+ export function applyInspectMutationToken(
5
+ token: string | null | undefined,
6
+ ): boolean;
7
+ export function inspectAssetHref(href: string | null | undefined): string | null;
8
+ export function isInspectShellHref(href: string | null | undefined): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure helpers for hosted Inspect bootstrap. Safe to import from Node tests.
3
+ * No DOM, no view modules.
4
+ */
5
+
6
+ const INSPECT_MUTATION_TOKEN_RE =
7
+ /window\.__INSPECT_MUTATION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/;
8
+
9
+ /** Parse the scoped inspect token injected into `/inspect/` HTML. */
10
+ export function extractInspectMutationToken(html) {
11
+ const match = String(html ?? "").match(INSPECT_MUTATION_TOKEN_RE);
12
+ if (!match) return null;
13
+ try {
14
+ const value = JSON.parse(match[1]);
15
+ return typeof value === "string" && value.length > 0 ? value : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function applyInspectMutationToken(token) {
22
+ if (typeof globalThis === "undefined") return false;
23
+ if (typeof token === "string" && token.length > 0) {
24
+ globalThis.__INSPECT_MUTATION_TOKEN__ = token;
25
+ return true;
26
+ }
27
+ delete globalThis.__INSPECT_MUTATION_TOKEN__;
28
+ return false;
29
+ }
30
+
31
+ /** Resolve inspect index.html stylesheet hrefs against `/inspect/` on the Console origin. */
32
+ export function inspectAssetHref(href) {
33
+ const raw = String(href ?? "").trim();
34
+ if (!raw) return null;
35
+ if (
36
+ /^[a-z][a-z0-9+.-]*:/i.test(raw) ||
37
+ raw.startsWith("//") ||
38
+ raw.startsWith("/")
39
+ ) {
40
+ return raw;
41
+ }
42
+ return `/inspect/${raw.replace(/^\.\//, "")}`;
43
+ }
44
+
45
+ export function isInspectShellHref(href) {
46
+ const raw = String(href ?? "").trim();
47
+ if (!raw) return false;
48
+ if (raw.startsWith("#/") || raw.startsWith("/#/")) return true;
49
+ if (
50
+ raw === "/inspect" ||
51
+ raw.startsWith("/inspect/") ||
52
+ raw.startsWith("/inspect#")
53
+ ) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from "./router.js";
14
14
  import {
15
15
  isPageVisible,
16
+ isWorkspaceActive,
16
17
  pauseForegroundTimers,
17
18
  setWorkspaceActive,
18
19
  setWorkspaceScope,
@@ -33,6 +34,12 @@ import { startDagPolling } from "./views/dag.js";
33
34
  import { renderNightJobs } from "./views/night.js";
34
35
  import { closeDagInspectorOnOutsideClick } from "./views/dag-inspector.js";
35
36
  import { mountOperatorChrome } from "./operator-chrome.js";
37
+ import {
38
+ applyInspectMutationToken,
39
+ extractInspectMutationToken,
40
+ inspectAssetHref,
41
+ isInspectShellHref,
42
+ } from "./inspect-bootstrap.js";
36
43
 
37
44
  let instance = null;
38
45
  let lastInspectHash = "";
@@ -73,6 +80,31 @@ export function navigateInspectPath(path) {
73
80
  return href;
74
81
  }
75
82
 
83
+ function onInspectRootClick(event) {
84
+ if (event.defaultPrevented || event.button !== 0) return;
85
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
86
+ const target = event.target;
87
+ if (!(target instanceof Element)) return;
88
+ const anchor = target.closest("a[href]");
89
+ if (!anchor) return;
90
+ const href = anchor.getAttribute("href");
91
+ if (!isInspectShellHref(href)) return;
92
+ event.preventDefault();
93
+ navigateInspectPath(href);
94
+ }
95
+
96
+ function resumeInspectPolling() {
97
+ if (!isPageVisible() || !isWorkspaceActive()) return;
98
+ const r = lastInspectHash
99
+ ? parseHashRouteWithFilters(lastInspectHash)
100
+ : parseRoute();
101
+ if (r.view === "dashboard") startDashboardPolling();
102
+ if (r.view === "run") startRunPolling(r.workerRunId, false);
103
+ if (r.view === "dag") startDagPolling(r.dagRunId, false);
104
+ if (r.view === "pool" && uiState.poolAutoRefresh) startPoolView();
105
+ if (r.view === "task") startTaskPolling(r.featureId, r.taskId);
106
+ }
107
+
76
108
  function startView(routeValue) {
77
109
  if (routeValue.view === "dashboard") {
78
110
  startDashboardPolling(routeValue.scrollTo);
@@ -131,7 +163,12 @@ export async function prepareInspectWorkspace(root) {
131
163
  throw new Error(`Inspect markup HTTP ${res.status}`);
132
164
  }
133
165
  const html = await res.text();
166
+ applyInspectMutationToken(extractInspectMutationToken(html));
134
167
  const doc = new DOMParser().parseFromString(html, "text/html");
168
+ for (const link of doc.querySelectorAll('link[rel="stylesheet"]')) {
169
+ const resolved = inspectAssetHref(link.getAttribute("href"));
170
+ if (resolved) ensureStylesheet(resolved);
171
+ }
135
172
  const app = doc.getElementById("app");
136
173
  if (!app) throw new Error("Inspect markup missing #app");
137
174
  root.appendChild(document.importNode(app, true));
@@ -143,15 +180,7 @@ function bindStandaloneListeners() {
143
180
  window.addEventListener("hashchange", () => route());
144
181
  window.addEventListener("load", () => route());
145
182
  document.addEventListener("pointerdown", closeDagInspectorOnOutsideClick);
146
- document.addEventListener("visibilitychange", () => {
147
- if (!isPageVisible()) return;
148
- const r = parseRoute();
149
- if (r.view === "dashboard") startDashboardPolling();
150
- if (r.view === "run") startRunPolling(r.workerRunId, false);
151
- if (r.view === "dag") startDagPolling(r.dagRunId, false);
152
- if (r.view === "pool" && uiState.poolAutoRefresh) startPoolView();
153
- if (r.view === "task") startTaskPolling(r.featureId, r.taskId);
154
- });
183
+ document.addEventListener("visibilitychange", resumeInspectPolling);
155
184
  }
156
185
 
157
186
  export function startStandaloneInspect() {
@@ -204,7 +233,6 @@ export function mountInspectWorkspace(root, options = {}) {
204
233
  // workspaceId via mount options/hash and postMessage updates; every observe
205
234
  // GET is then scoped via scopedApiUrl.
206
235
  setWorkspaceScope(options.workspaceId ?? workspaceIdFromHash(options.hash));
207
- window.addEventListener("message", onWorkspaceScopeMessage);
208
236
 
209
237
  const hosted = options.hosted === true || options.hosted !== false;
210
238
  const onNavigate =
@@ -216,6 +244,20 @@ export function mountInspectWorkspace(root, options = {}) {
216
244
  lastInspectHash = options.hash || (typeof location !== "undefined" ? location.hash : "");
217
245
  setWorkspaceActive(options.active !== false);
218
246
 
247
+ const listenerAbort =
248
+ typeof AbortController !== "undefined" ? new AbortController() : null;
249
+ const listenerOpts = listenerAbort ? { signal: listenerAbort.signal } : undefined;
250
+ window.addEventListener("message", onWorkspaceScopeMessage, listenerOpts);
251
+ if (hosted && typeof document !== "undefined") {
252
+ document.addEventListener(
253
+ "pointerdown",
254
+ closeDagInspectorOnOutsideClick,
255
+ listenerOpts,
256
+ );
257
+ document.addEventListener("visibilitychange", resumeInspectPolling, listenerOpts);
258
+ root.addEventListener("click", onInspectRootClick, listenerOpts);
259
+ }
260
+
219
261
  const host = {
220
262
  root,
221
263
  hosted,
@@ -250,6 +292,8 @@ export function mountInspectWorkspace(root, options = {}) {
250
292
  host.destroyed = true;
251
293
  pauseForegroundTimers();
252
294
  setNavigateHandler(null);
295
+ listenerAbort?.abort();
296
+ if (hosted) applyInspectMutationToken(null);
253
297
  if (instance === host) instance = null;
254
298
  },
255
299
  };