@tea-agent/loop-agent 0.40.0-next.9 → 0.41.0-beta.1

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 (218) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +130 -3
  3. package/README.md +2 -2
  4. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  5. package/dist/adapters/context-transfer/pi-session.js +61 -0
  6. package/dist/application/dag/generate-task-dag.js +6 -2
  7. package/dist/build-stamp.json +3 -3
  8. package/dist/cli/command-definitions.js +21 -5
  9. package/dist/cli/program.js +61 -4
  10. package/dist/commands/client-recovery.js +8 -36
  11. package/dist/commands/dag-artifact.js +284 -0
  12. package/dist/commands/dag-context.js +184 -0
  13. package/dist/commands/dag-rerun.js +206 -1
  14. package/dist/commands/init-model-catalog.js +22 -0
  15. package/dist/commands/task-advance.js +23 -1
  16. package/dist/commands/task-source-prepare.js +5 -0
  17. package/dist/commands/task-status.js +1 -0
  18. package/dist/executors/dag-pi-executor.js +39 -12
  19. package/dist/executors/pi-sdk-executor.js +36 -5
  20. package/dist/executors/shell-executor.js +64 -17
  21. package/dist/governance/checks.js +1 -0
  22. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  23. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  24. package/dist/infrastructure/console/context-export-store.js +160 -0
  25. package/dist/infrastructure/console/dir-lock.js +132 -0
  26. package/dist/{worker → infrastructure}/console/operation-store.js +28 -0
  27. package/dist/infrastructure/harness/artifact-store.js +10 -1
  28. package/dist/infrastructure/harness/atomic-write.js +12 -2
  29. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  30. package/dist/shared/context-transfer.js +418 -0
  31. package/dist/shared/operator/capabilities.js +23 -1
  32. package/dist/shared/operator/safe-run-summary.js +1 -0
  33. package/dist/shared/path-safety.js +93 -0
  34. package/dist/shared/pi-context-pressure/checkpoint.js +116 -0
  35. package/dist/shared/pi-context-pressure/compaction-policy.js +151 -0
  36. package/dist/shared/pi-context-pressure/env.js +58 -0
  37. package/dist/shared/pi-context-pressure/extension.js +100 -0
  38. package/dist/shared/pi-context-pressure/index.js +7 -0
  39. package/dist/shared/pi-context-pressure/overflow.js +252 -0
  40. package/dist/shared/pi-context-pressure/sift-bridge.js +386 -0
  41. package/dist/shared/pi-context-pressure/telemetry.js +51 -0
  42. package/dist/shared/preview.js +28 -4
  43. package/dist/task/config-types.js +2 -1
  44. package/dist/task/task-demand-routing.js +3 -0
  45. package/dist/worker/console/chat/assistant-content.js +7 -0
  46. package/dist/worker/console/chat/chat-event-store.js +84 -13
  47. package/dist/worker/console/chat/goal-round-driver.js +72 -0
  48. package/dist/worker/console/chat/goals.js +300 -0
  49. package/dist/worker/console/chat/pi-runtime.js +285 -8
  50. package/dist/worker/console/chat/provider-error.js +2 -1
  51. package/dist/worker/console/chat/repo-browser.js +94 -12
  52. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  53. package/dist/worker/console/chat/routes.js +487 -30
  54. package/dist/worker/console/chat/session-catalog.js +48 -0
  55. package/dist/worker/console/chat/session-stats.js +96 -0
  56. package/dist/worker/console/chat/session-store.js +28 -19
  57. package/dist/worker/console/chat/shortcuts.js +7 -0
  58. package/dist/worker/console/chat/sift-bridge.js +1 -0
  59. package/dist/worker/console/chat/turn-process.js +32 -3
  60. package/dist/worker/console/chat/user-questions.js +1 -1
  61. package/dist/worker/console/console-update-runtime.js +1 -1
  62. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  63. package/dist/worker/console/dag-confirmation.js +1 -1
  64. package/dist/worker/console/dag-execution-receipt.js +21 -3
  65. package/dist/worker/console/doctor.js +1 -1
  66. package/dist/worker/console/draft-store.js +1 -1
  67. package/dist/worker/console/human-gate-token.js +1 -1
  68. package/dist/worker/console/index.js +3 -3
  69. package/dist/worker/console/interview/assessment.js +1 -1
  70. package/dist/worker/console/interview/session.js +1 -1
  71. package/dist/worker/console/operation-runner.js +45 -4
  72. package/dist/worker/console/operation-sse.js +1 -1
  73. package/dist/worker/console/operation-wait.js +1 -1
  74. package/dist/worker/console/operator-actions.js +124 -8
  75. package/dist/worker/console/operator-selection.js +6 -2
  76. package/dist/worker/console/operator-surface-health.js +1 -1
  77. package/dist/worker/console/operator-user-error.js +169 -0
  78. package/dist/worker/console/pi-plugins.catalog.json +73658 -0
  79. package/dist/worker/console/pi-plugins.js +179 -0
  80. package/dist/worker/console/routes.js +864 -13
  81. package/dist/worker/console/security.js +35 -0
  82. package/dist/worker/console/server.js +177 -44
  83. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Bs-CDAXM.js → abnfDiagram-N423BO3Z-DSVcg1YO.js} +1 -1
  84. package/dist/worker/console/static/assets/{arc-CAkA3We3.js → arc-BtXhIqui.js} +1 -1
  85. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-DEmI_zqr.js → architectureDiagram-T3A2C74G-B-nh-ilJ.js} +1 -1
  86. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-uDHANebE.js → blockDiagram-VBNYF7ZC-BIUSVdZI.js} +1 -1
  87. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-Bv97b4_B.js → c4Diagram-5PPSVZJV-Cmw3L4qM.js} +1 -1
  88. package/dist/worker/console/static/assets/channel-BhZFNefx.js +1 -0
  89. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-shmQKsFz.js → chunk-2GRJ4B5K-CGpMMjGm.js} +1 -1
  90. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-BAraNvcN.js → chunk-2Q5K7J3B-BNBkEpEJ.js} +1 -1
  91. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BirhxUop.js → chunk-5RXB4S5H-ubln3eaU.js} +1 -1
  92. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C9LgUJpR.js → chunk-5VM5RSS4-_XcBTt1i.js} +1 -1
  93. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DrU-s-t7.js → chunk-6Q2QTUOP-Ln30Pgrh.js} +1 -1
  94. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Tx-H_FA2.js → chunk-GF5L2VYU-ESH3lhSx.js} +1 -1
  95. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BjLtNFrR.js → chunk-JWPE2WC7-DAJt-qXB.js} +1 -1
  96. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-UNLVwSsy.js → chunk-KBJHAD2P-Cx10LIcH.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CBEUX8XD.js → chunk-RYQCIY6F-CENeOxoJ.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-WTL7IZOq.js → chunk-XXDRQBXY-DXqK-D38.js} +1 -1
  99. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-Sbv-NJni.js +1 -0
  100. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-Sbv-NJni.js +1 -0
  101. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CkHG6W3s.js → cose-bilkent-JH36ORCC-CE4dmvGY.js} +1 -1
  102. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BRzFqTaL.js → cynefin-VYW2F7L2-CRacN1zK.js} +1 -1
  103. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-2_CFp_i7.js → cynefinDiagram-MW4NZA55-Cneukjrt.js} +1 -1
  104. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BJtsmdkE.js → dagre-VZM6K2ZE-DPFboRoL.js} +1 -1
  105. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-DNEFFo6w.js → diagram-7IWD3JNH-DIAQgcOP.js} +1 -1
  106. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B_IWV7eo.js → diagram-B4RE2ZJO-VrYMKxuy.js} +1 -1
  107. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DvvRx2dz.js → diagram-LBJQPF4R-CLHvBghn.js} +1 -1
  108. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-CsibsWLg.js → diagram-Q27KOJAE-DVCWBif8.js} +1 -1
  109. package/dist/worker/console/static/assets/{diagram-UB23O5K3-les_djBQ.js → diagram-UB23O5K3-BTMmbBzJ.js} +1 -1
  110. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BEm2n7qJ.js → ebnfDiagram-BXEA7PRR-BUZsKJ56.js} +1 -1
  111. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BI7vHHVm.js → erDiagram-JOGREHBK-BsBZ5NXC.js} +1 -1
  112. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CUFCERrB.js → flowDiagram-UKHOOZJN-D7fH_kDa.js} +1 -1
  113. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-wxyLiGWt.js → ganttDiagram-PKOTCBZU-Bza5OohY.js} +1 -1
  114. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bt897K8Z.js → gitGraphDiagram-DS77QQ5N-DpkoGR_Y.js} +1 -1
  115. package/dist/worker/console/static/assets/index-Ius3vxhf.css +1 -0
  116. package/dist/worker/console/static/assets/index-yASzcbBr.js +451 -0
  117. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DYNx_IPv.js → infoDiagram-6WML65LV-BYm3g6LT.js} +1 -1
  118. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-CzpLNEu6.js → ishikawaDiagram-WSZJBQD7-C2RS_H4v.js} +1 -1
  119. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-97f9owNU.js → journeyDiagram-NVQOT4AX-B4slKpCo.js} +1 -1
  120. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BxM9hyBn.js → kanban-definition-27J2QSJJ-B7orRNpI.js} +1 -1
  121. package/dist/worker/console/static/assets/{linear-ByuxcHvp.js → linear-D1BeBxLQ.js} +1 -1
  122. package/dist/worker/console/static/assets/{mermaid.core-DwPGvpCv.js → mermaid.core-0TtMP8k4.js} +5 -5
  123. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-DCoqG9RT.js → mindmap-definition-FAOFIHXS-XL5EbCtT.js} +1 -1
  124. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BLSBmBMx.js → pegDiagram-VL7TDLO6-CwF7uas5.js} +1 -1
  125. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B0t_0oJW.js → pieDiagram-7S7Q4E2Y-A_Jo-72f.js} +1 -1
  126. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-sTzDk7Nt.js → quadrantDiagram-CIZ2JOQS-CxOa4gKL.js} +1 -1
  127. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-B3jRdATC.js → railroadDiagram-AXF67PYL-DoAoswIH.js} +1 -1
  128. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-D-cTtXEN.js → requirementDiagram-LRYGKXZP-C1Lc1MlD.js} +1 -1
  129. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B25igJRE.js → sankeyDiagram-W5VNT64P-DFXOkq0U.js} +1 -1
  130. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-jW_lM1_D.js → sequenceDiagram-SI44F4Z6-B0ufUCuW.js} +1 -1
  131. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CrzTgsHE.js → sizeCapture-X5ZJPWSS-iPbW55g3.js} +1 -1
  132. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-B01MvlqE.js → stateDiagram-OKZ733FA-CHiXzEKP.js} +1 -1
  133. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BtMgzo_H.js +1 -0
  134. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-YcF12FiU.js → swimlanes-SLNWSIFB-LyFs7TpX.js} +2 -2
  135. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Cb-OVd9Y.js +8 -0
  136. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ClnRcg2s.js → timeline-definition-Z64GVDOM-tNkJnb6z.js} +1 -1
  137. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-B6gr_z-q.js → vennDiagram-T6HMQDX7-DFm7Oo8o.js} +1 -1
  138. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CvSgguGH.js → wardleyDiagram-T6FBY63Y-tpblTp9i.js} +1 -1
  139. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-23OtSZCb.js → xychartDiagram-ELKLHX3M-BCDLWR_D.js} +1 -1
  140. package/dist/worker/console/static/index.html +7 -2
  141. package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
  142. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +95 -25
  143. package/dist/worker/console/static-src/operator-chat/compaction-message.js +35 -1
  144. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +3 -1
  145. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +26 -19
  146. package/dist/worker/console/static-src/operator-chat/useChatStream.js +0 -11
  147. package/dist/worker/console/static-src/operator-chat/useChatThread.js +14 -1
  148. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +34 -4
  149. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +28 -3
  150. package/dist/worker/console/static-src/operator-chat/workspace-reorder.js +18 -0
  151. package/dist/worker/console/static-src/shell/console-update-reload.js +25 -0
  152. package/dist/worker/console/static-src/shell/useWorkspaces.js +150 -34
  153. package/dist/worker/console/static-src/shell/workspace-route.js +3 -1
  154. package/dist/worker/console/workspace-context.js +56 -5
  155. package/dist/worker/console/workspace-registry.js +335 -62
  156. package/dist/worker/continuation/worker-continuation.js +278 -0
  157. package/dist/worker/observe/health.js +1 -1
  158. package/dist/worker/observe/node-transparency.js +572 -0
  159. package/dist/worker/observe/routes.js +51 -1
  160. package/dist/worker/observe/static/api.js +69 -5
  161. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  162. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  163. package/dist/worker/observe/static/inspect-bootstrap.d.ts +8 -0
  164. package/dist/worker/observe/static/inspect-bootstrap.js +57 -0
  165. package/dist/worker/observe/static/inspect-workspace.js +54 -10
  166. package/dist/worker/observe/static/operator-chrome.css +23 -4
  167. package/dist/worker/observe/static/operator-chrome.js +7 -2
  168. package/dist/worker/observe/static/state.js +3 -1
  169. package/dist/worker/observe/static/styles.css +1108 -135
  170. package/dist/worker/observe/static/views/dag-inspector.js +1257 -59
  171. package/dist/worker/observe/static/views/dags.js +7 -5
  172. package/dist/worker/observe/static/views/session-timeline.js +210 -10
  173. package/dist/worker/outcomes/adapters.js +19 -0
  174. package/dist/worker/outcomes/types.js +1 -0
  175. package/dist/worker/pool/attempt-lease.js +97 -66
  176. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  177. package/dist/worker/pool/reconcile.js +46 -1
  178. package/dist/worker/pool/run-store.js +2 -0
  179. package/dist/worker/pool/state-projection.js +2 -0
  180. package/dist/worker/task-spec/workflow-routing.js +8 -3
  181. package/dist/workflows/dag/artifact-bindings.js +149 -0
  182. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  183. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +162 -8
  184. package/dist/workflows/dag/backend-test-pytest-collection.js +70 -2
  185. package/dist/workflows/dag/backend-test-result-contract.js +4 -0
  186. package/dist/workflows/dag/backend-test-scenario-param.js +339 -53
  187. package/dist/workflows/dag/backend-test-writer-completeness.js +11 -0
  188. package/dist/workflows/dag/budget-enforcement.js +10 -0
  189. package/dist/workflows/dag/context-receipt.js +305 -0
  190. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  191. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  192. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  193. package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
  194. package/dist/workflows/dag/init-hybrid.js +151 -4
  195. package/dist/workflows/dag/node-execution.js +112 -27
  196. package/dist/workflows/dag/path-safety.js +1 -0
  197. package/dist/workflows/dag/prompt.js +29 -4
  198. package/dist/workflows/dag/rerun-plan.js +555 -1
  199. package/dist/workflows/dag/rerun-run.js +601 -15
  200. package/dist/workflows/dag/runner.js +20 -1
  201. package/dist/workflows/dag/skill-snapshot.js +17 -0
  202. package/dist/workflows/dag/types.js +183 -7
  203. package/docs/architecture/runtime-boundaries.md +8 -7
  204. package/docs/architecture/worker-and-feature.md +1 -1
  205. package/docs/templates/agent-dag.schema.json +32 -0
  206. package/docs/templates/backend-test-dag.json +6 -5
  207. package/docs/templates/init-managed-agents.md +1 -1
  208. package/docs/templates/product-line/task.yaml +1 -1
  209. package/package.json +5 -2
  210. package/skills/loop-agent/references/command-reference.md +21 -1
  211. package/dist/worker/console/static/assets/channel-amxUpk7o.js +0 -1
  212. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-bejwBVIz.js +0 -1
  213. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-bejwBVIz.js +0 -1
  214. package/dist/worker/console/static/assets/index-BHhUOTri.css +0 -1
  215. package/dist/worker/console/static/assets/index-DqJbO3-p.js +0 -407
  216. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BvFKEtm2.js +0 -1
  217. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DIB2eVg-.js +0 -8
  218. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -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
- import { readJsonIfExists, writeSecureJson, } from "./app-data.js";
22
- import { normalizeWorktreeRealpath, repoFingerprintV1, } from "./repo-fingerprint.js";
23
- export const WORKSPACE_REGISTRY_SCHEMA_VERSION = 1;
22
+ import { readJsonIfExists, writeSecureJson, } from "../../infrastructure/console/app-data.js";
23
+ import { normalizeWorktreeRealpath, repoFingerprintV1, } from "../../infrastructure/console/repo-fingerprint.js";
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. */