@piagent/platform 1.37.1 → 1.38.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 (283) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +5 -3
  3. package/README.vi.md +2 -2
  4. package/catalog/capabilities.json +2 -2
  5. package/docs/command-reference-vietnamese.md +4 -3
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/en/maintainer-guide.md +8 -0
  8. package/docs/en/workspace-editor.md +222 -0
  9. package/docs/languages.json +13 -4
  10. package/docs/operator-manual-vietnamese.md +7 -7
  11. package/docs/publishing-for-teams.md +2 -0
  12. package/docs/release-install-policy.md +2 -2
  13. package/docs/research/ide-m0-spike.md +101 -0
  14. package/docs/team-onboarding.md +2 -2
  15. package/docs/vi/maintainer-guide.md +8 -0
  16. package/docs/vi/workspace-editor.md +217 -0
  17. package/docs-site/architecture.html +1 -1
  18. package/docs-site/benchmark.html +1 -1
  19. package/docs-site/commands.html +1 -1
  20. package/docs-site/content/en/index.html +4 -4
  21. package/docs-site/content/en/whats-new.html +17 -0
  22. package/docs-site/content/index.html +4 -4
  23. package/docs-site/content/whats-new.html +17 -0
  24. package/docs-site/context-engine.html +1 -1
  25. package/docs-site/ecosystem.html +1 -1
  26. package/docs-site/en/architecture.html +1 -1
  27. package/docs-site/en/benchmark.html +1 -1
  28. package/docs-site/en/commands.html +1 -1
  29. package/docs-site/en/context-engine.html +1 -1
  30. package/docs-site/en/ecosystem.html +1 -1
  31. package/docs-site/en/index.html +5 -5
  32. package/docs-site/en/mcp-approval.html +1 -1
  33. package/docs-site/en/mcp-auth.html +1 -1
  34. package/docs-site/en/mcp-servers.html +1 -1
  35. package/docs-site/en/mcp.html +1 -1
  36. package/docs-site/en/permissions.html +1 -1
  37. package/docs-site/en/profiles.html +1 -1
  38. package/docs-site/en/quickstart.html +1 -1
  39. package/docs-site/en/runtime.html +1 -1
  40. package/docs-site/en/scope.html +1 -1
  41. package/docs-site/en/security.html +1 -1
  42. package/docs-site/en/team.html +1 -1
  43. package/docs-site/en/whats-new.html +18 -1
  44. package/docs-site/en/windows.html +1 -1
  45. package/docs-site/en/workflows.html +1 -1
  46. package/docs-site/index.html +5 -5
  47. package/docs-site/mcp-approval.html +1 -1
  48. package/docs-site/mcp-auth.html +1 -1
  49. package/docs-site/mcp-servers.html +1 -1
  50. package/docs-site/mcp.html +1 -1
  51. package/docs-site/permissions.html +1 -1
  52. package/docs-site/profiles.html +1 -1
  53. package/docs-site/quickstart.html +1 -1
  54. package/docs-site/runtime.html +1 -1
  55. package/docs-site/scope.html +1 -1
  56. package/docs-site/security.html +1 -1
  57. package/docs-site/team.html +1 -1
  58. package/docs-site/whats-new.html +18 -1
  59. package/docs-site/windows.html +1 -1
  60. package/docs-site/workflows.html +1 -1
  61. package/evals/fixtures/piagent-webui/checkpoint-v1.invalid.json +21 -0
  62. package/evals/fixtures/piagent-webui/checkpoint-v1.valid.json +20 -0
  63. package/evals/fixtures/piagent-webui/terminal-v1.invalid.json +17 -0
  64. package/evals/fixtures/piagent-webui/terminal-v1.valid.json +16 -0
  65. package/evals/fixtures/piagent-webui/workspace-file-v1.invalid.json +24 -0
  66. package/evals/fixtures/piagent-webui/workspace-file-v1.valid.json +23 -0
  67. package/evals/fixtures/piagent-webui/workspace-paths-v1.invalid.json +24 -0
  68. package/evals/fixtures/piagent-webui/workspace-paths-v1.valid.json +24 -0
  69. package/evals/fixtures/piagent-webui/workspace-search-v1.invalid.json +26 -0
  70. package/evals/fixtures/piagent-webui/workspace-search-v1.valid.json +26 -0
  71. package/evals/fixtures/piagent-webui/workspace-tree-v1.invalid.json +28 -0
  72. package/evals/fixtures/piagent-webui/workspace-tree-v1.valid.json +27 -0
  73. package/evals/fixtures/piagent-webui/workspace-write-v1.invalid.json +19 -0
  74. package/evals/fixtures/piagent-webui/workspace-write-v1.valid.json +19 -0
  75. package/evals/long-task-soak-v1/README.md +17 -0
  76. package/evals/long-task-soak-v1/lib/session-loop.mjs +19 -3
  77. package/evals/long-task-soak-v1/scenarios/ide-editor-terminal.json +19 -0
  78. package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +17 -2
  79. package/evals/long-task-soak-v1/surfaces/workspace-actions.mjs +95 -0
  80. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  81. package/package.json +7 -3
  82. package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
  83. package/packages/piagent-core/extensions/guard-types.ts +3 -1
  84. package/packages/piagent-core/extensions/path-suggestions.ts +137 -0
  85. package/packages/piagent-core/extensions/piagent-guard.ts +85 -7
  86. package/packages/piagent-core/extensions/workspace-inspection.ts +145 -0
  87. package/packages/piagent-core/extensions/workspace-policy.d.ts +7 -0
  88. package/packages/piagent-core/extensions/workspace-policy.js +3 -0
  89. package/packages/piagent-core/extensions/workspace-search-index.mjs +8 -0
  90. package/packages/piagent-core/extensions/workspace-search.ts +74 -0
  91. package/packages/piagent-core/extensions/workspace-write.ts +116 -0
  92. package/packages/piagent-core/managed/background-process.mjs +9 -4
  93. package/packages/piagent-core/managed/boundary-package-proxy.mjs +21 -0
  94. package/packages/piagent-core/managed/boundary-queue.mjs +24 -0
  95. package/packages/piagent-core/managed/checkpoint-git.mjs +16 -0
  96. package/packages/piagent-core/managed/checkpoints.mjs +17 -64
  97. package/packages/piagent-core/managed/command-tools.mjs +6 -6
  98. package/packages/piagent-core/managed/continuation.mjs +3 -0
  99. package/packages/piagent-core/managed/dist/workspace/assets/adapters/backend-api/profile.json +88 -0
  100. package/packages/piagent-core/managed/dist/workspace/assets/adapters/be-readonly-fe/profile.json +130 -0
  101. package/packages/piagent-core/managed/dist/workspace/assets/adapters/data/profile.json +88 -0
  102. package/packages/piagent-core/managed/dist/workspace/assets/adapters/devops/profile.json +90 -0
  103. package/packages/piagent-core/managed/dist/workspace/assets/adapters/docs/profile.json +74 -0
  104. package/packages/piagent-core/managed/dist/workspace/assets/adapters/fullstack/profile.json +92 -0
  105. package/packages/piagent-core/managed/dist/workspace/assets/adapters/generic/profile.json +95 -0
  106. package/packages/piagent-core/managed/dist/workspace/assets/adapters/mobile/profile.json +86 -0
  107. package/packages/piagent-core/managed/dist/workspace/assets/adapters/node-typescript/profile.json +74 -0
  108. package/packages/piagent-core/managed/dist/workspace/assets/adapters/python/profile.json +75 -0
  109. package/packages/piagent-core/managed/dist/workspace/assets/adapters/web-frontend/profile.json +99 -0
  110. package/packages/piagent-core/managed/dist/workspace/assets/local-state-path-BrmghxEZ.js +284 -0
  111. package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +351 -0
  112. package/packages/piagent-core/managed/dist/workspace/manifest.json +65 -0
  113. package/packages/piagent-core/managed/dist/workspace/worker.mjs +7374 -0
  114. package/packages/piagent-core/managed/dist/workspace/workspace-search-index.mjs +1007 -0
  115. package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
  116. package/packages/piagent-core/managed/permission.mjs +29 -6
  117. package/packages/piagent-core/managed/repository-operation.mjs +2 -1
  118. package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
  119. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
  120. package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
  121. package/packages/piagent-core/managed/session.mjs +2 -2
  122. package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
  123. package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
  124. package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
  125. package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
  126. package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
  127. package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
  128. package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
  129. package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
  130. package/packages/piagent-core/managed/tool-worker.mjs +18 -3
  131. package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
  132. package/packages/piagent-core/package.json +1 -1
  133. package/packages/piagent-core/policies/base-policy.json +2 -1
  134. package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
  135. package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
  136. package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
  137. package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
  138. package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
  139. package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
  140. package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
  141. package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
  142. package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
  143. package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
  144. package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
  145. package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
  146. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
  147. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
  148. package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
  149. package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
  150. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
  151. package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
  152. package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
  153. package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
  154. package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
  155. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
  156. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
  157. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
  158. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
  159. package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
  160. package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
  161. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
  162. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
  163. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
  164. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
  165. package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
  166. package/packages/piagent-core/runtime/process-owner.mjs +20 -0
  167. package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
  168. package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
  169. package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
  170. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  171. package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
  172. package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
  173. package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
  174. package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
  175. package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
  176. package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
  177. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
  178. package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
  179. package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
  180. package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
  181. package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
  182. package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
  183. package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
  184. package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
  185. package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
  186. package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
  187. package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
  188. package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
  189. package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
  190. package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
  191. package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
  192. package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
  193. package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
  194. package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
  195. package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
  196. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
  197. package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
  198. package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
  199. package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
  200. package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
  201. package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
  202. package/packages/piagent-webui/dist/client/index.html +10 -2
  203. package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
  204. package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
  205. package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
  206. package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
  207. package/packages/piagent-webui/gateway/company-relay.ts +5 -0
  208. package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
  209. package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
  210. package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
  211. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
  212. package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
  213. package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
  214. package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
  215. package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
  216. package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
  217. package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
  218. package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
  219. package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
  220. package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
  221. package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
  222. package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
  223. package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
  224. package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
  225. package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
  226. package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
  227. package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
  228. package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
  229. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
  230. package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
  231. package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
  232. package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
  233. package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
  234. package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
  235. package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
  236. package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
  237. package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
  238. package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
  239. package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
  240. package/packages/piagent-webui/package.json +14 -2
  241. package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
  242. package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
  243. package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
  244. package/packages/piagent-webui/server/control-command-digest.ts +5 -0
  245. package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
  246. package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
  247. package/packages/piagent-webui/server/loopback-server.ts +9 -0
  248. package/packages/piagent-webui/server/read-model-provider.ts +3 -0
  249. package/packages/piagent-webui/server/read-only-router.ts +3 -0
  250. package/packages/piagent-webui/server/session-auth.ts +4 -1
  251. package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
  252. package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
  253. package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
  254. package/packages/piagent-webui/server/terminal-command.ts +35 -0
  255. package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
  256. package/packages/piagent-webui/server/transcript-projection.ts +2 -1
  257. package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
  258. package/packages/piagent-webui/server/workspace-reader.ts +23 -0
  259. package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
  260. package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
  261. package/packages/piagent-webui/shared/terminal-message.ts +23 -0
  262. package/schemas/piagent-webui/catalog-v1.json +63 -0
  263. package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
  264. package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
  265. package/schemas/piagent-webui/diff-v1.schema.json +4 -0
  266. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
  267. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  268. package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
  269. package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
  270. package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
  271. package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
  272. package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
  273. package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
  274. package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
  275. package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
  276. package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
  277. package/schemas/project-profile.schema.json +8 -3
  278. package/scripts/company-terminal.mjs +16 -11
  279. package/scripts/pi-auto.sh +5 -1
  280. package/scripts/team-doctor.sh +8 -0
  281. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
  282. package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
  283. package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
@@ -1,212 +1,18 @@
1
- import { createHash } from "node:crypto";
1
+ import type { SourceRevertAuthority } from "../../piagent-core/runtime/inspection/source-action-contract.ts";
2
+ import { appendSourceMutationEvidence, readSourceMutationEvidence } from "../../piagent-core/runtime/inspection/source-mutation-store.ts";
3
+ import { SourceRevertController as SharedSourceRevertController } from "../server/source-revert-controller.ts";
2
4
 
3
- import { appendSourceMutationEvidence, readSourceMutationEvidence, type SourceMutationEvidenceRecord } from "../../piagent-core/runtime/inspection/source-mutation-store.ts";
4
- import { canonicalReviewValue, reviewDigest } from "../../piagent-core/runtime/inspection/review-state-contract.ts";
5
- import type { SourceRevertAuthority, SourceRevertProjection, SourceRevertTarget } from "../../piagent-core/runtime/inspection/source-revert-projection.ts";
6
- import type { SourceIndexTransactionResult } from "../../piagent-core/runtime/policy/source-index-transaction.ts";
7
- import { controlActionDigest, type BridgeIdentity, type BridgeRevisions, type SameSessionPiBridge } from "./same-session-bridge.ts";
5
+ export type { SourceRevertReceipt } from "../server/source-revert-controller.ts";
8
6
 
9
- const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/, PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/,
10
- REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/,
11
- WORKSPACE = /^wt-content-v2:[a-f0-9]{64}$/, IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}$/;
12
- const REVISION_KEYS = ["runtimeRevision", "taskRevision", "controlRevision", "workspaceRevision", "indexRevision", "approvalRevision", "sessionOptionRevision", "queueRevision"];
13
- type RevertCommand = { schemaVersion: 1; version: "piagent-webui-control-v1"; messageType: "command"; commandId: string; idempotencyKey: string;
14
- requestedAt: string; expiresAt: string; capabilityScope: "reviewActions"; action: "source.revert"; actionDigest: string; identity: BridgeIdentity;
15
- expectedRevisions: BridgeRevisions & { workspacePreimage: string; indexPreimage: string; patchPreimage: string };
16
- payload: { fileRef: string; hunkRefs: string[]; previewRef: string; confirmedPreviewDigest: string; contentDigest: string } };
17
- type RevertMutate = (input: { identity: { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string; taskRunId: string };
18
- action: "source.revert"; expectedTaskRevision: string; expectedControlRevision: string; expectedIndexPreimage: string;
19
- expectedWorkspacePreimage: string; previewRef: string; confirmedPreviewDigest: string;
20
- authority: SourceRevertAuthority }) => Promise<SourceIndexTransactionResult>;
21
- export type SourceRevertReceipt = { schemaVersion: 1; version: "piagent-webui-control-v1"; messageType: "receipt"; commandId: string;
22
- idempotencyKeyDigest: string; action: "source.revert"; actionDigest: string; identity: BridgeIdentity; phase: "settled" | "rejected" | "uncertain";
23
- resultCode: "reverted" | "effect-unknown" | "stale-revision" | "identity-mismatch" | "capability-unavailable" | "idempotency-payload-mismatch"
24
- | "expired" | "invalid-command" | "resync-required" | "terminal-task"; requestedAt: string; settledAt: string;
25
- observedRevisionsBefore: BridgeRevisions; observedRevisionsAfter: BridgeRevisions; deduplicated: boolean; auditRef: string | null;
26
- settlementEvidenceRef: string | null; error: { code: string; message: string; retryable: boolean } | null };
27
-
28
- function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
29
- function same(left: unknown, right: unknown): boolean { return canonicalReviewValue(left) === canonicalReviewValue(right); }
30
- function exactKeys(value: unknown, keys: string[]): boolean { return Boolean(value && typeof value === "object" && !Array.isArray(value)
31
- && same(Object.keys(value as object).sort(), [...keys].sort())); }
32
- function timestamp(value: unknown): value is string {
33
- if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
34
- const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
35
- }
36
- function wireIdentity(value: BridgeIdentity): BridgeIdentity { return { projectRef: value.projectRef, runtimeInstanceId: value.runtimeInstanceId,
37
- sessionRef: value.sessionRef, taskId: value.taskId, taskRunId: value.taskRunId, agentOperationId: null, toolCallId: null }; }
38
- function wireRevisions(value: Record<string, any>): BridgeRevisions { return { runtimeRevision: value.runtimeRevision, taskRevision: value.taskRevision,
39
- controlRevision: value.controlRevision, workspaceRevision: value.workspaceRevision, indexRevision: value.indexRevision,
40
- approvalRevision: value.approvalRevision, sessionOptionRevision: value.sessionOptionRevision, queueRevision: value.queueRevision }; }
41
- function replayIdentityDisposition(record: Pick<SourceMutationEvidenceRecord, "projectRef" | "runtimeInstanceId" | "sessionRef" | "taskId" | "taskRunId">,
42
- identity: BridgeIdentity): "task-identity-mismatch" | "runtime-or-session-replaced" | null {
43
- if (record.taskId !== identity.taskId || record.taskRunId !== identity.taskRunId) return "task-identity-mismatch";
44
- if (record.projectRef !== identity.projectRef || record.runtimeInstanceId !== identity.runtimeInstanceId || record.sessionRef !== identity.sessionRef)
45
- return "runtime-or-session-replaced";
46
- return null;
47
- }
48
- function receipt(record: SourceMutationEvidenceRecord, deduplicated: boolean): SourceRevertReceipt {
49
- const identity = { projectRef: record.projectRef, runtimeInstanceId: record.runtimeInstanceId, sessionRef: record.sessionRef,
50
- taskId: record.taskId, taskRunId: record.taskRunId, agentOperationId: null, toolCallId: null } as BridgeIdentity;
51
- const before = wireRevisions(record.observedRevisionsBefore), after = wireRevisions(record.observedRevisionsAfter ?? record.observedRevisionsBefore);
52
- if (record.phase === "settled") return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
53
- idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "settled", resultCode: "reverted",
54
- requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated,
55
- auditRef: record.evidenceRef, settlementEvidenceRef: record.evidenceRef, error: null };
56
- if (record.phase === "rejected") {
57
- const stale = ["mutation-preimage-stale", "mutation-guard-precondition-stale"].includes(record.failureCode ?? "");
58
- return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
59
- idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "rejected",
60
- resultCode: stale ? "stale-revision" : "capability-unavailable", requestedAt: record.requestedAt, settledAt: record.recordedAt,
61
- observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated, auditRef: record.evidenceRef, settlementEvidenceRef: null,
62
- error: { code: record.failureCode ?? "source-revert-rejected", message: "The Pi guard rejected the confirmed source revert before commit.", retryable: true } };
63
- }
64
- return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
65
- idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "uncertain",
66
- resultCode: "effect-unknown", requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before,
67
- observedRevisionsAfter: after, deduplicated, auditRef: record.phase === "uncertain" ? record.evidenceRef : null,
68
- settlementEvidenceRef: null, error: { code: "source-revert-effect-unknown", message: "The worktree effect could not be confirmed. Inspect the source tabs.", retryable: false } };
69
- }
70
-
71
- export class SourceRevertController {
72
- readonly #bridge: SameSessionPiBridge; readonly #projectRoot: string;
73
- readonly #resolve: (fileRef: string, hunkRefs: string[]) => Promise<{ projection: SourceRevertProjection; authority: SourceRevertAuthority | null }>;
74
- readonly #revisions: () => Promise<Record<string, string | null>>;
75
- readonly #mutate: RevertMutate;
76
- readonly #now: () => Date; #serial: Promise<unknown> = Promise.resolve();
77
- constructor(options: { bridge: SameSessionPiBridge; projectRoot: string;
78
- resolve(fileRef: string, hunkRefs: string[]): Promise<{ projection: SourceRevertProjection; authority: SourceRevertAuthority | null }>;
79
- revisions(): Promise<Record<string, string | null>>; mutate: RevertMutate; now?: () => Date }) {
80
- this.#bridge = options.bridge; this.#projectRoot = options.projectRoot; this.#resolve = options.resolve;
81
- this.#revisions = options.revisions; this.#mutate = options.mutate; this.#now = options.now ?? (() => new Date());
82
- }
83
- execute(value: unknown): Promise<SourceRevertReceipt> {
84
- const run = this.#serial.then(() => this.#execute(value), () => this.#execute(value)); this.#serial = run.catch(() => undefined); return run;
85
- }
86
- async #execute(value: unknown): Promise<SourceRevertReceipt> {
87
- const command = value as RevertCommand, snapshot = this.#bridge.snapshot(), invalid = this.#validate(value);
88
- if (snapshot.state !== "ready" || !snapshot.identity || !snapshot.revisions) return this.#reject(command, "resync-required", "revert-binding-unavailable", true);
89
- if (invalid) return this.#reject(command, "invalid-command", invalid, false);
90
- const identity = wireIdentity(snapshot.identity), bridgeRevisions = wireRevisions(snapshot.revisions);
91
- if (!identity.taskId || !identity.taskRunId || !bridgeRevisions.taskRevision) return this.#reject(command, "capability-unavailable", "active-task-required", false);
92
- if (snapshot.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal", false);
93
- if (snapshot.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle", true);
94
- const evidence = readSourceMutationEvidence(this.#projectRoot, identity.taskRunId);
95
- if (evidence.corruptions.length) return this.#reject(command, "resync-required", "mutation-evidence-corrupt", false);
96
- const keyDigest = sha(command.idempotencyKey), prior = evidence.records.filter((record) => record.commandId === command.commandId || record.idempotencyKeyDigest === keyDigest);
97
- if (prior.length) {
98
- if (prior.some((record) => record.commandId !== command.commandId || record.idempotencyKeyDigest !== keyDigest || record.actionDigest !== command.actionDigest
99
- || record.action !== command.action)) return this.#reject(command, "idempotency-payload-mismatch", "idempotency-payload-mismatch", false);
100
- const dispositions = prior.map((record) => replayIdentityDisposition(record, identity)).filter(Boolean);
101
- if (dispositions.length) return this.#reject(command, "identity-mismatch",
102
- dispositions.includes("task-identity-mismatch") ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
103
- return receipt(prior.at(-1)!, true);
104
- }
105
- const now = this.#now().getTime();
106
- if (Date.parse(command.requestedAt) > now + 30_000) return this.#reject(command, "invalid-command", "requested-at-in-future", false);
107
- if (now > Date.parse(command.expiresAt)) return this.#reject(command, "expired", "command-expired", false);
108
- if (!same(command.identity, identity)) return this.#reject(command, "identity-mismatch", "identity-mismatch", false);
109
- if (command.expectedRevisions.runtimeRevision !== bridgeRevisions.runtimeRevision || command.expectedRevisions.taskRevision !== bridgeRevisions.taskRevision
110
- || command.expectedRevisions.controlRevision !== bridgeRevisions.controlRevision) return this.#reject(command, "stale-revision", "stale-task-control-revision", true);
111
- let resolved: { projection: SourceRevertProjection; authority: SourceRevertAuthority | null };
112
- try { resolved = await this.#resolve(command.payload.fileRef, command.payload.hunkRefs); }
113
- catch { return this.#reject(command, "capability-unavailable", "revert-preview-unavailable", true); }
114
- const rebound = this.#bridge.snapshot();
115
- if (rebound.state !== "ready" || !rebound.identity || !rebound.revisions)
116
- return this.#reject(command, "resync-required", "revert-binding-unavailable-after-preview", true);
117
- const reboundIdentity = wireIdentity(rebound.identity), reboundRevisions = wireRevisions(rebound.revisions);
118
- if (!same(reboundIdentity, identity)) {
119
- const taskChanged = reboundIdentity.taskId !== identity.taskId || reboundIdentity.taskRunId !== identity.taskRunId;
120
- return this.#reject(command, "identity-mismatch", taskChanged ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
121
- }
122
- if (rebound.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal-after-preview", false);
123
- if (rebound.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle-after-preview", true);
124
- if (command.expectedRevisions.runtimeRevision !== reboundRevisions.runtimeRevision
125
- || command.expectedRevisions.taskRevision !== reboundRevisions.taskRevision
126
- || command.expectedRevisions.controlRevision !== reboundRevisions.controlRevision)
127
- return this.#reject(command, "stale-revision", "stale-task-control-revision-after-preview", true);
128
- if (!resolved.authority || resolved.projection.state !== "ready" || !resolved.projection.target)
129
- return this.#reject(command, "capability-unavailable", resolved.projection.reasonCode ?? "revert-preview-unavailable", true);
130
- const target = resolved.projection.target;
131
- if (!this.#matches(command, target) || this.#now().getTime() > Date.parse(target.expiresAt))
132
- return this.#reject(command, "stale-revision", "revert-preview-stale", true);
133
- const before = { ...bridgeRevisions, workspaceRevision: target.workspaceRevision, indexRevision: target.indexRevision };
134
- let requested: SourceMutationEvidenceRecord;
135
- try { requested = appendSourceMutationEvidence({ projectRoot: this.#projectRoot, projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
136
- sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId, commandId: command.commandId, idempotencyKeyDigest: keyDigest,
137
- actionDigest: command.actionDigest, action: "source.revert", target, selectedHunkRefs: [...command.payload.hunkRefs], phase: "requested",
138
- resultCode: "mutation-requested", failureCode: null, requestedAt: command.requestedAt, recordedAt: this.#now().toISOString(),
139
- beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage,
140
- afterWorkspacePreimage: null, observedRevisionsBefore: before, observedRevisionsAfter: null }); }
141
- catch { return this.#reject(command, "capability-unavailable", "mutation-evidence-unavailable", false); }
142
- let effect: SourceIndexTransactionResult;
143
- try { effect = await this.#mutate({ identity: { projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
144
- sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId }, action: "source.revert",
145
- expectedTaskRevision: target.taskRevision, expectedControlRevision: bridgeRevisions.controlRevision!, expectedIndexPreimage: target.indexPreimage,
146
- expectedWorkspacePreimage: target.workspacePreimage, previewRef: target.previewRef, confirmedPreviewDigest: target.confirmedPreviewDigest,
147
- authority: resolved.authority }); }
148
- catch { effect = { state: "rejected", reasonCode: "mutation-guard-unavailable", beforeIndexPreimage: target.indexPreimage,
149
- afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false }; }
150
- if (effect.executor !== "pi-guard" || effect.directExecution !== false) effect = { ...effect, state: "uncertain", reasonCode: "mutation-executor-untrusted",
151
- afterIndexPreimage: null, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false };
152
- let after: BridgeRevisions; try { after = wireRevisions(await this.#revisions()); } catch { after = before; }
153
- const phase = effect.state, resultCode = effect.state === "settled" ? "reverted" : effect.state === "rejected" ? "mutation-rejected" : "effect-unknown";
154
- try { const terminal = appendSourceMutationEvidence({ projectRoot: this.#projectRoot, projectRef: identity.projectRef,
155
- runtimeInstanceId: identity.runtimeInstanceId, sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId,
156
- commandId: command.commandId, idempotencyKeyDigest: keyDigest, actionDigest: command.actionDigest, action: "source.revert", target,
157
- selectedHunkRefs: [...command.payload.hunkRefs], phase, resultCode, failureCode: effect.reasonCode, requestedAt: command.requestedAt,
158
- recordedAt: this.#now().toISOString(), beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: effect.afterIndexPreimage,
159
- beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: effect.afterWorkspacePreimage,
160
- observedRevisionsBefore: before, observedRevisionsAfter: after }); return receipt(terminal, false); }
161
- catch { return receipt(requested, false); }
162
- }
163
- #matches(command: RevertCommand, target: SourceRevertTarget): boolean {
164
- return command.payload.fileRef === target.fileRef && same(command.payload.hunkRefs, target.hunkRefs) && command.payload.previewRef === target.previewRef
165
- && command.payload.confirmedPreviewDigest === target.confirmedPreviewDigest && command.payload.contentDigest === target.contentDigest
166
- && command.expectedRevisions.taskRevision === target.taskRevision && command.expectedRevisions.workspaceRevision === target.workspaceRevision
167
- && command.expectedRevisions.indexRevision === target.indexRevision && command.expectedRevisions.workspacePreimage === target.workspacePreimage
168
- && command.expectedRevisions.indexPreimage === target.indexPreimage && command.expectedRevisions.patchPreimage === target.patchPreimage;
169
- }
170
- #validate(value: unknown): string | null {
171
- const command = value as RevertCommand;
172
- if (!exactKeys(command, ["schemaVersion", "version", "messageType", "commandId", "idempotencyKey", "requestedAt", "expiresAt", "capabilityScope", "action", "actionDigest", "identity", "expectedRevisions", "payload"])
173
- || command.schemaVersion !== 1 || command.version !== "piagent-webui-control-v1" || command.messageType !== "command" || command.capabilityScope !== "reviewActions"
174
- || command.action !== "source.revert" || !REF.test(command.commandId) || !IDEMPOTENCY.test(command.idempotencyKey) || !timestamp(command.requestedAt)
175
- || !timestamp(command.expiresAt) || Date.parse(command.requestedAt) > Date.parse(command.expiresAt) || Date.parse(command.expiresAt) - Date.parse(command.requestedAt) > 300_000)
176
- return "invalid-command-metadata";
177
- if (!exactKeys(command.identity, ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId", "agentOperationId", "toolCallId"])
178
- || ![command.identity.projectRef, command.identity.runtimeInstanceId, command.identity.sessionRef].every((item) => REF.test(item))
179
- || ![command.identity.taskId, command.identity.taskRunId].every((item) => typeof item === "string" && PUBLIC_REF.test(item))
180
- || command.identity.agentOperationId !== null || command.identity.toolCallId !== null) return "invalid-command-identity";
181
- if (!exactKeys(command.expectedRevisions, [...REVISION_KEYS, "workspacePreimage", "indexPreimage", "patchPreimage"])
182
- || !REVISION.test(command.expectedRevisions.runtimeRevision) || !REVISION.test(command.expectedRevisions.taskRevision ?? "")
183
- || !REVISION.test(command.expectedRevisions.controlRevision ?? "")
184
- || !REVISION.test(command.expectedRevisions.workspaceRevision ?? "") || !REVISION.test(command.expectedRevisions.indexRevision ?? "")
185
- || [command.expectedRevisions.approvalRevision, command.expectedRevisions.sessionOptionRevision, command.expectedRevisions.queueRevision]
186
- .some((item) => item !== null && !REVISION.test(String(item)))
187
- || !WORKSPACE.test(command.expectedRevisions.workspacePreimage) || !DIGEST.test(command.expectedRevisions.indexPreimage)
188
- || !DIGEST.test(command.expectedRevisions.patchPreimage)) return "invalid-revert-authority";
189
- if (!exactKeys(command.payload, ["fileRef", "hunkRefs", "previewRef", "confirmedPreviewDigest", "contentDigest"])
190
- || !REF.test(command.payload.fileRef) || !REF.test(command.payload.previewRef) || !Array.isArray(command.payload.hunkRefs)
191
- || command.payload.hunkRefs.length > 1 || new Set(command.payload.hunkRefs).size !== command.payload.hunkRefs.length
192
- || command.payload.hunkRefs.some((item) => !REF.test(item)) || !DIGEST.test(command.payload.confirmedPreviewDigest)
193
- || !DIGEST.test(command.payload.contentDigest)) return "invalid-revert-payload";
194
- if (!DIGEST.test(command.actionDigest) || command.actionDigest !== controlActionDigest(command)) return "action-digest-mismatch";
195
- return null;
196
- }
197
- #reject(command: RevertCommand, resultCode: SourceRevertReceipt["resultCode"], code: string, retryable: boolean): SourceRevertReceipt {
198
- const snapshot = this.#bridge.snapshot(), at = this.#now().toISOString(), observed = snapshot.identity ? wireIdentity(snapshot.identity) : null;
199
- const identity = observed ? { ...observed, taskId: observed.taskId ?? "task.unavailable", taskRunId: observed.taskRunId ?? "task-run.unavailable" }
200
- : { projectRef: "project.unavailable", runtimeInstanceId: "runtime.unavailable", sessionRef: "session.unavailable", taskId: "task.unavailable",
201
- taskRunId: "task-run.unavailable", agentOperationId: null, toolCallId: null } as BridgeIdentity;
202
- const revisions = snapshot.revisions ? wireRevisions(snapshot.revisions) : { runtimeRevision: "runtime.unavailable", taskRevision: null, controlRevision: null,
203
- workspaceRevision: null, indexRevision: null, approvalRevision: null, sessionOptionRevision: null, queueRevision: null };
204
- return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt",
205
- commandId: typeof command?.commandId === "string" && REF.test(command.commandId) ? command.commandId : `revert-rejected.${reviewDigest(command ?? null).slice(7)}`,
206
- idempotencyKeyDigest: sha(typeof command?.idempotencyKey === "string" ? command.idempotencyKey : canonicalReviewValue(command ?? null)), action: "source.revert",
207
- actionDigest: typeof command?.actionDigest === "string" && DIGEST.test(command.actionDigest) ? command.actionDigest : sha(code), identity,
208
- phase: "rejected", resultCode, requestedAt: timestamp(command?.requestedAt) ? command.requestedAt : at, settledAt: at,
209
- observedRevisionsBefore: revisions, observedRevisionsAfter: revisions, deduplicated: false, auditRef: null, settlementEvidenceRef: null,
210
- error: { code, message: "The confirmed exact source revert could not be completed.", retryable } };
7
+ // The legacy extension retains its task evidence store; Gateway supplies its own binding.
8
+ export class SourceRevertController extends SharedSourceRevertController {
9
+ constructor(options: Omit<ConstructorParameters<typeof SharedSourceRevertController<SourceRevertAuthority>>[0], "evidence" | "allowSessionReverts">) {
10
+ super({ ...options, allowSessionReverts: false, evidence: {
11
+ read: (root, run) => run ? readSourceMutationEvidence(root, run) : { records: [], corruptions: ["active-task-required"] },
12
+ append: value => {
13
+ if (!value.taskId || !value.taskRunId) throw new Error("active-task-required");
14
+ return appendSourceMutationEvidence({ ...value, taskId: value.taskId, taskRunId: value.taskRunId });
15
+ }
16
+ } });
211
17
  }
212
18
  }
@@ -0,0 +1,99 @@
1
+ import { checkpointAuthority } from '../../piagent-core/runtime/checkpoints/checkpoint-authority.ts';
2
+ import { workspaceWriteAuthority } from '../../piagent-core/runtime/policy/workspace-write-authority.ts';
3
+ import { CheckpointController } from '../server/checkpoint-controller.ts';
4
+ import { CHECKPOINT_VERSION, sameCheckpointValue } from '../server/checkpoint-command.ts';
5
+ import type { CheckpointPreview } from '../contracts/generated/checkpoint-v1.ts';
6
+ import type { SessionRuntimeSupervisor } from './session-runtime-supervisor.ts';
7
+ import { sessionRefForPath, projectRefForCwd } from './session-catalog.ts';
8
+ import { CheckpointReceiptStore } from './checkpoint-store.ts';
9
+ import { transcriptMessageRef } from '../server/transcript-projection.ts';
10
+ import { webUiSessionRef } from '../../piagent-core/runtime/inspection/webui-snapshot.ts';
11
+ import type { CheckpointPort } from '../../piagent-core/runtime/checkpoints/checkpoint-authority.ts';
12
+
13
+ export type ManagedCheckpointRuntime = { current(): boolean; writable(): Promise<boolean>; port: CheckpointPort };
14
+
15
+ type Options = { root: string; key: Buffer; gatewayInstanceRef: string; managed: boolean;
16
+ runtimes: Pick<SessionRuntimeSupervisor, 'listSessions' | 'acquire' | 'liveSessionManager' | 'ownership' | 'currentOperation'>;
17
+ managedCheckpoint?(id: string): ManagedCheckpointRuntime;
18
+ changed(sessionRef: string): void };
19
+
20
+ export class CheckpointSessions {
21
+ #options: Options;
22
+ #store: CheckpointReceiptStore;
23
+ #controllers = new Map<string, CheckpointController>();
24
+ constructor(options: Options) { this.#options = options; this.#store = new CheckpointReceiptStore(options.root, options.key); }
25
+ async #live(sessionRef: string) {
26
+ const { runtimes, key, gatewayInstanceRef } = this.#options;
27
+ if (this.#options.managed && !this.#options.managedCheckpoint) throw Error('checkpoint-company-boundary-required');
28
+ const matches = (await runtimes.listSessions()).filter(info => sessionRefForPath(key, info.path) === sessionRef);
29
+ if (matches.length !== 1) throw Error('checkpoint-session-unavailable');
30
+ const info = matches[0]!, lease = await runtimes.acquire(sessionRef), manager = runtimes.liveSessionManager(sessionRef);
31
+ if (!manager || !lease.runtimeInstanceRef || manager.getSessionId() !== info.id) throw Error('checkpoint-session-unavailable');
32
+ const managed = this.#options.managed ? this.#options.managedCheckpoint!(info.id) : null;
33
+ const port = managed ? { ...managed.port, current: managed.current } : checkpointAuthority.resolve(info.cwd, info.id);
34
+ const assertCurrent = () => {
35
+ const ownership = runtimes.ownership(sessionRef);
36
+ if (!port.current() || runtimes.liveSessionManager(sessionRef) !== manager || manager.getSessionId() !== info.id
37
+ || ownership.state !== 'gateway-owned' || ownership.owner.continuity !== 'exact'
38
+ || ownership.owner.gatewayInstanceRef !== gatewayInstanceRef || ownership.owner.runtimeInstanceRef !== lease.runtimeInstanceRef
39
+ || ownership.owner.ownerEpoch !== lease.ownerEpoch) throw Error('checkpoint-runtime-replaced');
40
+ if (!managed && manager.buildSessionContext().model?.provider === 'agent_watch_managed') throw Error('checkpoint-company-boundary-required');
41
+ };
42
+ assertCurrent();
43
+ const identity = { projectRef: projectRefForCwd(key, info.cwd), sessionRef, runtimeInstanceId: lease.runtimeInstanceRef,
44
+ taskId: null, taskRunId: null, agentOperationId: null, toolCallId: null };
45
+ const writable = async () => {
46
+ assertCurrent();
47
+ const value = managed ? await managed.writable() : workspaceWriteAuthority.writable(info.cwd, info.id);
48
+ assertCurrent(); return value;
49
+ };
50
+ return { info, port, assertCurrent, identity, writable, managed: !!managed };
51
+ }
52
+ async #facts(sessionRef: string) {
53
+ const binding = await this.#live(sessionRef), listed = await binding.port.list();
54
+ binding.assertCurrent();
55
+ return { identity: binding.identity, revisions: { checkpointRevision: listed.revision },
56
+ points: listed.points.map(point => ({ ...point, turnRef: transcriptMessageRef(webUiSessionRef(binding.info.id), point.turnRef) })),
57
+ available: listed.available, agentRunning: Boolean(this.#options.runtimes.currentOperation(sessionRef)),
58
+ writable: await binding.writable() };
59
+ }
60
+ #controller(sessionRef: string) {
61
+ let controller = this.#controllers.get(sessionRef);
62
+ if (!controller) {
63
+ if (this.#controllers.size >= 1024) throw Error('checkpoint-session-capacity');
64
+ controller = new CheckpointController({
65
+ facts: () => this.#facts(sessionRef), history: () => this.#store.history(sessionRef),
66
+ append: receipt => {
67
+ this.#store.append(sessionRef, receipt);
68
+ if (receipt.phase === 'settled' || receipt.phase === 'uncertain') this.#options.changed(sessionRef);
69
+ },
70
+ restore: async (target, expected, identity) => {
71
+ const binding = await this.#live(sessionRef);
72
+ if (!sameCheckpointValue(identity, binding.identity)) throw Error('checkpoint-runtime-replaced');
73
+ if (!await binding.writable()) throw Error('checkpoint-read-only');
74
+ const guard = () => {
75
+ binding.assertCurrent();
76
+ if (this.#options.runtimes.currentOperation(sessionRef)) throw Error('checkpoint-session-busy');
77
+ if (!binding.managed && !workspaceWriteAuthority.writable(binding.info.cwd, binding.info.id)) throw Error('checkpoint-read-only');
78
+ };
79
+ guard();
80
+ return await binding.port.restoreTo(target, expected, guard);
81
+ },
82
+ });
83
+ this.#controllers.set(sessionRef, controller);
84
+ }
85
+ return controller;
86
+ }
87
+ state(sessionRef: string) { return this.#controller(sessionRef).state(); }
88
+ execute(sessionRef: string, command: unknown) { return this.#controller(sessionRef).execute(command); }
89
+ async preview(sessionRef: string, target: string): Promise<CheckpointPreview> {
90
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(target)) throw Error('invalid-checkpoint-target');
91
+ const binding = await this.#live(sessionRef), preview = await binding.port.preview(target);
92
+ binding.assertCurrent();
93
+ return { schemaVersion: 1, version: CHECKPOINT_VERSION, messageType: 'preview', identity: binding.identity,
94
+ revisions: { checkpointRevision: preview.revision }, targetId: target, result: preview.result as CheckpointPreview['result'],
95
+ previewDigest: preview.digest, tree: preview.tree,
96
+ steps: preview.steps.map(step => ({ ...step, turnRef: transcriptMessageRef(webUiSessionRef(binding.info.id), step.turnRef) })), files: preview.files,
97
+ conflicts: preview.conflicts, stoppedAt: preview.stoppedAt };
98
+ }
99
+ }
@@ -0,0 +1,6 @@
1
+ import type { CheckpointReceipt } from '../contracts/generated/checkpoint-v1.ts';
2
+ import { SignedSessionJournal } from './signed-session-journal.ts';
3
+
4
+ export class CheckpointReceiptStore extends SignedSessionJournal<CheckpointReceipt> {
5
+ constructor(root: string, key: Buffer) { super(root, key, 'checkpoint-receipts'); }
6
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { WebSocket } from "ws";
3
+ import { CompanyTerminalRelay } from "./company-terminal-relay.ts";
3
4
 
4
5
  import type { Catalog, SessionRow } from "../contracts/generated/session-catalog-v1.ts";
5
6
  import { webUiModelRef } from "../../piagent-core/runtime/inspection/webui-snapshot.ts";
@@ -41,6 +42,7 @@ const timedOut = (error: unknown) => error instanceof Error && (error.name === "
41
42
  const failure = (error: unknown, fallback: string) => error instanceof Error && /^[a-z][a-z0-9:._-]{2,95}$/.test(error.message) ? error.message : fallback;
42
43
 
43
44
  export class CompanyRelay {
45
+ readonly terminals: CompanyTerminalRelay;
44
46
  readonly #connector: CompanyConnector;
45
47
  readonly #events: GatewayEventStore;
46
48
  readonly #hubProject: (cwd: string) => string;
@@ -70,6 +72,8 @@ export class CompanyRelay {
70
72
  this.#connector = options.connector; this.#events = options.events;
71
73
  this.#hubProject = options.hubProject; this.#folder = options.folder;
72
74
  this.#readTimeoutMs = options.readTimeoutMs ?? READ_TIMEOUT_MS; this.#answerTimeoutMs = options.answerTimeoutMs ?? ANSWER_TIMEOUT_MS;
75
+ this.terminals = new CompanyTerminalRelay({ owns: ref => this.owns(ref),
76
+ link: async () => { const link = await this.#ready(); return this.#bootstrap(link.socket); } });
73
77
  }
74
78
 
75
79
  status(): CompanyStatus {
@@ -123,6 +127,7 @@ export class CompanyRelay {
123
127
 
124
128
  close(): void {
125
129
  this.#closed = true;
130
+ this.terminals.close();
126
131
  if (this.#retry) clearTimeout(this.#retry);
127
132
  this.#ws?.terminate();
128
133
  }
@@ -0,0 +1,54 @@
1
+ import type { TerminalState, TerminalOutput, TerminalSummary } from '../contracts/generated/terminal-v1.ts';
2
+ import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
3
+ import { TerminalOutputBuffer } from './terminal-output-buffer.ts';
4
+
5
+ const identityKey = (value: TerminalState['identity']) => JSON.stringify([value.gatewayInstanceRef, value.runtimeInstanceRef, value.sessionRef]);
6
+ type Entry = { buffer: TerminalOutputBuffer; sequence: number; truncated: boolean };
7
+ // The relay keeps only the same bounded, redacted replay the company Gateway
8
+ // provides. Live bytes never enter the durable event stream or browser storage.
9
+ export class CompanyTerminalCache {
10
+ state: TerminalState | null = null;
11
+ readonly #tabs = new Map<string, Entry>();
12
+ accept(message: TerminalStreamMessage) {
13
+ if (message.messageType === 'state') {
14
+ if (this.state && identityKey(this.state.identity) !== identityKey(message.identity)) this.#tabs.clear();
15
+ this.state = message;
16
+ for (const ref of this.#tabs.keys()) if (!(message.terminals as readonly TerminalSummary[]).some(tab => tab.terminalRef === ref)) this.#tabs.delete(ref);
17
+ return;
18
+ }
19
+ if (!this.state || identityKey(this.state.identity) !== identityKey(message.identity)) throw Error('company-terminal-identity-changed');
20
+ let entry = this.#tabs.get(message.terminalRef);
21
+ if (!entry) {
22
+ if (this.#tabs.size >= 4) throw Error('company-terminal-capacity');
23
+ entry = { buffer: new TerminalOutputBuffer(), sequence: 0, truncated: false }; this.#tabs.set(message.terminalRef, entry);
24
+ }
25
+ if (message.messageType === 'output') {
26
+ if (message.replay && message.reset) { entry.buffer = new TerminalOutputBuffer(); entry.truncated = message.truncated; }
27
+ else if (!message.replay && message.sequence <= entry.sequence) return;
28
+ entry.buffer.append(message.data);
29
+ }
30
+ entry.sequence = message.sequence;
31
+ }
32
+ disconnected() {
33
+ if (!this.state) return null;
34
+ this.state = { ...this.state, available: false, reasonCode: 'company-terminal-disconnected' };
35
+ return this.state;
36
+ }
37
+ replay(): TerminalOutput[] {
38
+ if (!this.state) return [];
39
+ const { identity } = this.state;
40
+ return (this.state.terminals as readonly TerminalSummary[]).flatMap(tab => {
41
+ const entry = this.#tabs.get(tab.terminalRef), snapshot = entry?.buffer.snapshot() ?? { text: '', truncated: false };
42
+ const parts: string[] = [];
43
+ for (let start = 0; start < snapshot.text.length;) {
44
+ let end = Math.min(start + 8192, snapshot.text.length);
45
+ if (end < snapshot.text.length && /[\uD800-\uDBFF]/.test(snapshot.text[end - 1]!)) end--;
46
+ parts.push(snapshot.text.slice(start, end)); start = end;
47
+ }
48
+ if (!parts.length) parts.push('');
49
+ return parts.map((data, index): TerminalOutput => ({ schemaVersion: 1, version: 'piagent-terminal-v1', messageType: 'output', identity,
50
+ terminalRef: tab.terminalRef, sequence: entry?.sequence ?? tab.sequence, data, replay: true,
51
+ reset: index === 0, complete: index === parts.length - 1, truncated: snapshot.truncated || Boolean(entry?.truncated) }));
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { WebSocket } from 'ws';
3
+ import type { TerminalCommand, TerminalReceipt, TerminalState, TerminalSummary } from '../contracts/generated/terminal-v1.ts';
4
+ import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
5
+ import { isTerminalMessage } from '../shared/terminal-message.ts';
6
+ import { CompanyTerminalCache } from './company-terminal-cache.ts';
7
+
8
+ export type CompanyTerminalLink = { origin: string; cookie: string; csrf: string };
9
+ type Pending = { method: string; resolve(value: unknown): void; reject(error: Error): void; timer: NodeJS.Timeout;
10
+ replay?: Map<string, number>; result?: TerminalState };
11
+
12
+ export class CompanyTerminalChannel {
13
+ readonly cache = new CompanyTerminalCache();
14
+ readonly #socket: WebSocket;
15
+ readonly #ref: string;
16
+ readonly #csrf: string;
17
+ readonly #emit: (message: TerminalStreamMessage) => void;
18
+ readonly #pending = new Map<string, Pending>();
19
+ readonly ready: Promise<void>;
20
+ #read: Promise<TerminalState> | null = null;
21
+ #closed = false;
22
+ get closed() { return this.#closed; }
23
+ constructor(ref: string, link: CompanyTerminalLink, emit: (message: TerminalStreamMessage) => void, disconnected: () => void) {
24
+ if (!/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(link.origin)) throw Error('company-gateway-invalid');
25
+ this.#ref = ref; this.#csrf = link.csrf; this.#emit = emit;
26
+ const ws = this.#socket = new WebSocket(`${link.origin.replace('http:', 'ws:')}/api/v1/gateway`, 'piagent.gateway.v1',
27
+ { headers: { Origin: link.origin, Cookie: link.cookie }, maxPayload: 70_000, perMessageDeflate: false });
28
+ this.ready = new Promise((resolve, reject) => {
29
+ let hello = false;
30
+ const timer = setTimeout(() => { reject(Error('company-terminal-connect-timeout')); ws.terminate(); }, 5000);
31
+ ws.on('open', () => ws.send(JSON.stringify({ schemaVersion: 1, version: 'piagent-gateway-protocol-v1', messageType: 'connect',
32
+ clientRef: 'dashboard-company-terminal', minimumProtocol: 1, maximumProtocol: 1, lastEventSequence: null, catalogRevision: null })));
33
+ ws.on('message', data => {
34
+ try {
35
+ const frame = JSON.parse(String(data));
36
+ if (!hello && frame.version === 'piagent-gateway-protocol-v1' && frame.messageType === 'hello') {
37
+ hello = true; clearTimeout(timer); resolve(); return;
38
+ }
39
+ if (!hello) throw Error();
40
+ this.#message(frame);
41
+ } catch { ws.close(1008, 'company-terminal-protocol-invalid'); }
42
+ });
43
+ ws.on('error', () => {});
44
+ ws.once('close', () => {
45
+ clearTimeout(timer); this.#closed = true;
46
+ reject(Error('company-terminal-disconnected'));
47
+ for (const pending of this.#pending.values()) { clearTimeout(pending.timer); pending.reject(Error('company-terminal-disconnected')); }
48
+ this.#pending.clear();
49
+ const state = this.cache.disconnected(); if (state) this.#emit(state);
50
+ disconnected();
51
+ });
52
+ });
53
+ }
54
+ #message(frame: any) {
55
+ if (frame.version === 'piagent-gateway-protocol-v1') {
56
+ if (frame.messageType !== 'response') return; // General session events are owned by CompanyRelay.
57
+ const pending = this.#pending.get(frame.requestId); if (!pending) return;
58
+ if (frame.method !== pending.method) throw Error();
59
+ if (frame.ok !== true) {
60
+ clearTimeout(pending.timer); this.#pending.delete(frame.requestId);
61
+ pending.reject(Error('company-terminal-request-rejected')); return;
62
+ }
63
+ if (pending.method === 'terminals.state') {
64
+ if (!isTerminalMessage(frame.result) || frame.result.messageType !== 'state' || frame.result.identity.sessionRef !== this.#ref) throw Error();
65
+ const state = frame.result as TerminalState; this.cache.accept(state); this.#emit(state);
66
+ pending.result = state; pending.replay = new Map((state.terminals as readonly TerminalSummary[]).map(tab => [tab.terminalRef, tab.sequence]));
67
+ if (pending.replay.size) return;
68
+ }
69
+ clearTimeout(pending.timer); this.#pending.delete(frame.requestId); pending.resolve(frame.result); return;
70
+ }
71
+ if (!isTerminalMessage(frame) || frame.identity.sessionRef !== this.#ref) throw Error();
72
+ const message = frame as TerminalStreamMessage;
73
+ this.cache.accept(message); this.#emit(message);
74
+ if (message.messageType === 'output' && message.replay && message.complete) {
75
+ for (const [id, pending] of this.#pending) if (pending.replay?.get(message.terminalRef) === message.sequence) {
76
+ pending.replay.delete(message.terminalRef);
77
+ if (!pending.replay.size) {
78
+ clearTimeout(pending.timer); this.#pending.delete(id); pending.resolve(this.cache.state ?? pending.result);
79
+ }
80
+ }
81
+ }
82
+ }
83
+ async #request(method: string, params: Record<string, unknown>): Promise<unknown> {
84
+ await this.ready;
85
+ if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) throw Error('company-terminal-disconnected');
86
+ if (this.#pending.size >= 64 || this.#socket.bufferedAmount > 512 * 1024) throw Error('company-terminal-capacity');
87
+ const requestId = `terminal_${randomUUID().replaceAll('-', '')}`;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ this.#pending.delete(requestId); reject(Error('company-terminal-response-timeout')); this.#socket.terminate();
91
+ }, 15000);
92
+ this.#pending.set(requestId, { method, resolve, reject, timer });
93
+ this.#socket.send(JSON.stringify({ schemaVersion: 1, version: 'piagent-gateway-protocol-v1', messageType: 'request',
94
+ requestId, method, params: { ...params, csrfToken: this.#csrf } }), error => { if (error) this.#socket.terminate(); });
95
+ });
96
+ }
97
+ state(): Promise<TerminalState> {
98
+ this.#read ??= (this.#request('terminals.state', { sessionRef: this.#ref }) as Promise<TerminalState>).finally(() => { this.#read = null; });
99
+ return this.#read;
100
+ }
101
+ async execute(command: TerminalCommand): Promise<TerminalReceipt> {
102
+ if (command.identity.sessionRef !== this.#ref) throw Error('company-terminal-session-mismatch');
103
+ const value = await this.#request('terminals.command', { command }) as TerminalReceipt;
104
+ if (!value || value.schemaVersion !== 1 || value.version !== command.version || value.messageType !== 'receipt'
105
+ || value.commandId !== command.commandId || value.idempotencyKey !== command.idempotencyKey || value.actionDigest !== command.actionDigest
106
+ || value.action !== command.action || !value.identity || !Object.entries(command.identity).every(([key, item]) => (value.identity as any)[key] === item)
107
+ || !value.observedRevisions || typeof value.observedRevisions.terminalRevision !== 'string'
108
+ || !['settled', 'rejected', 'uncertain'].includes(value.phase) || typeof value.deduplicated !== 'boolean'
109
+ || !(value.terminalRef === null || typeof value.terminalRef === 'string') || !(value.reasonCode === null || typeof value.reasonCode === 'string'))
110
+ throw Error('company-terminal-receipt-invalid');
111
+ // Project only contract fields; an accidental upstream private field cannot travel to the browser.
112
+ return { schemaVersion: 1, version: value.version, messageType: 'receipt', commandId: value.commandId, idempotencyKey: value.idempotencyKey,
113
+ action: value.action, actionDigest: value.actionDigest, identity: command.identity, observedRevisions: { terminalRevision: value.observedRevisions.terminalRevision },
114
+ phase: value.phase, terminalRef: value.terminalRef, reasonCode: value.reasonCode, deduplicated: value.deduplicated };
115
+ }
116
+ close() { this.#closed = true; this.#socket.terminate(); }
117
+ }
@@ -0,0 +1,75 @@
1
+ import type { TerminalOutput, TerminalState } from '../contracts/generated/terminal-v1.ts';
2
+ import type { TerminalStreamMessage } from '../server/terminal-websocket-stream.ts';
3
+ import { parseTerminalCommand } from '../server/terminal-command.ts';
4
+ import { CompanyTerminalChannel, type CompanyTerminalLink } from './company-terminal-channel.ts';
5
+
6
+ type Entry = { listeners: Set<(message: TerminalStreamMessage) => void>; channel: CompanyTerminalChannel | null;
7
+ connecting: Promise<CompanyTerminalChannel> | null; retry: NodeJS.Timeout | null; idle: NodeJS.Timeout | null };
8
+
9
+ export class CompanyTerminalRelay {
10
+ readonly #link: () => Promise<CompanyTerminalLink>;
11
+ readonly #owns: (ref: string) => boolean;
12
+ readonly #entries = new Map<string, Entry>();
13
+ #closed = false;
14
+ constructor(options: { link(): Promise<CompanyTerminalLink>; owns(ref: string): boolean }) { this.#link = options.link; this.#owns = options.owns; }
15
+ #entry(ref: string): Entry {
16
+ if (this.#closed || !this.#owns(ref)) throw Error('company-terminal-session-unavailable');
17
+ let entry = this.#entries.get(ref);
18
+ if (!entry) {
19
+ if (this.#entries.size >= 32) throw Error('company-terminal-capacity');
20
+ entry = { listeners: new Set(), channel: null, connecting: null, retry: null, idle: null }; this.#entries.set(ref, entry);
21
+ }
22
+ if (entry.idle) clearTimeout(entry.idle);
23
+ const current = entry;
24
+ entry.idle = setTimeout(() => { if (!current.listeners.size) this.#remove(ref, current); }, 30000); entry.idle.unref();
25
+ return entry;
26
+ }
27
+ #retry(ref: string, entry: Entry) {
28
+ if (this.#closed || this.#entries.get(ref) !== entry || !entry.listeners.size || entry.retry) return;
29
+ entry.retry = setTimeout(() => {
30
+ entry.retry = null;
31
+ void this.state(ref).catch(() => this.#retry(ref, entry));
32
+ }, 3000); entry.retry.unref();
33
+ }
34
+ async #channel(ref: string, entry: Entry): Promise<CompanyTerminalChannel> {
35
+ if (entry.channel && !entry.channel.closed) { await entry.channel.ready; return entry.channel; }
36
+ entry.connecting ??= (async () => {
37
+ const link = await this.#link();
38
+ if (this.#closed || this.#entries.get(ref) !== entry || !this.#owns(ref)) throw Error('company-terminal-session-unavailable');
39
+ const channel = new CompanyTerminalChannel(ref, link, message => {
40
+ if (this.#entries.get(ref) !== entry || entry.channel !== channel) return;
41
+ for (const listener of entry.listeners) try { listener(message); } catch { /* browser stream owns its backpressure */ }
42
+ }, () => { if (entry.channel === channel) this.#retry(ref, entry); });
43
+ entry.channel = channel; await channel.ready;
44
+ if (entry.retry) { clearTimeout(entry.retry); entry.retry = null; }
45
+ return channel;
46
+ })().finally(() => { entry.connecting = null; });
47
+ return entry.connecting;
48
+ }
49
+ async state(ref: string): Promise<TerminalState> {
50
+ const entry = this.#entry(ref);
51
+ return (await this.#channel(ref, entry)).state();
52
+ }
53
+ async execute(value: unknown) {
54
+ const command = parseTerminalCommand(value), ref = command.identity.sessionRef, entry = this.#entry(ref);
55
+ const channel = await this.#channel(ref, entry);
56
+ if (!channel.cache.state) await channel.state();
57
+ // Exactly one dispatch. Only an explicit client retry may send this command again.
58
+ return channel.execute(command);
59
+ }
60
+ subscribe(ref: string, listener: (message: TerminalStreamMessage) => void) {
61
+ const entry = this.#entry(ref);
62
+ if (entry.listeners.size >= 32) throw Error('company-terminal-subscription-capacity');
63
+ entry.listeners.add(listener);
64
+ return () => { entry.listeners.delete(listener); if (!entry.listeners.size) this.#remove(ref, entry); };
65
+ }
66
+ replay(ref: string): TerminalOutput[] { return this.#entries.get(ref)?.channel?.cache.replay() ?? []; }
67
+ #remove(ref: string, entry: Entry) {
68
+ if (this.#entries.get(ref) !== entry) return;
69
+ this.#entries.delete(ref);
70
+ if (entry.retry) clearTimeout(entry.retry);
71
+ if (entry.idle) clearTimeout(entry.idle);
72
+ entry.channel?.close();
73
+ }
74
+ close() { this.#closed = true; for (const [ref, entry] of this.#entries) this.#remove(ref, entry); }
75
+ }