@piagent/platform 1.37.0 → 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 (292) hide show
  1. package/CHANGELOG.md +70 -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 +3 -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 +23 -0
  22. package/docs-site/content/index.html +4 -4
  23. package/docs-site/content/whats-new.html +23 -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 +24 -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 +24 -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/helper-run.mjs +11 -1
  116. package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
  117. package/packages/piagent-core/managed/ip-policy.mjs +50 -0
  118. package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
  119. package/packages/piagent-core/managed/memory.mjs +1 -1
  120. package/packages/piagent-core/managed/package-proxy.mjs +12 -14
  121. package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
  122. package/packages/piagent-core/managed/permission.mjs +29 -6
  123. package/packages/piagent-core/managed/repository-operation.mjs +2 -1
  124. package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
  125. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
  126. package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
  127. package/packages/piagent-core/managed/session.mjs +6 -7
  128. package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
  129. package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
  130. package/packages/piagent-core/managed/studio-link.mjs +16 -0
  131. package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
  132. package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
  133. package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
  134. package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
  135. package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
  136. package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
  137. package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
  138. package/packages/piagent-core/managed/tool-worker.mjs +18 -3
  139. package/packages/piagent-core/managed/web-fetch.mjs +3 -14
  140. package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
  141. package/packages/piagent-core/package.json +1 -1
  142. package/packages/piagent-core/policies/base-policy.json +2 -1
  143. package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
  144. package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
  145. package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
  146. package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
  147. package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
  148. package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
  149. package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
  150. package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
  151. package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
  152. package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
  153. package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
  154. package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
  155. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
  156. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
  157. package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
  158. package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
  159. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
  160. package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
  161. package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
  162. package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
  163. package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
  164. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
  165. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
  166. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
  167. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
  168. package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
  169. package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
  170. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
  171. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
  172. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
  173. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
  174. package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
  175. package/packages/piagent-core/runtime/process-owner.mjs +20 -0
  176. package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
  177. package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
  178. package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
  179. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  180. package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
  181. package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
  182. package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
  183. package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
  184. package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
  185. package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
  186. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
  187. package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
  188. package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
  189. package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
  190. package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
  191. package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
  192. package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
  193. package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
  194. package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
  195. package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
  196. package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
  197. package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
  198. package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
  199. package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
  200. package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
  201. package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
  202. package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
  203. package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
  204. package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
  205. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
  206. package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
  207. package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
  208. package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
  209. package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
  210. package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
  211. package/packages/piagent-webui/dist/client/index.html +10 -2
  212. package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
  213. package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
  214. package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
  215. package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
  216. package/packages/piagent-webui/gateway/company-relay.ts +5 -0
  217. package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
  218. package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
  219. package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
  220. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
  221. package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
  222. package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
  223. package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
  224. package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
  225. package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
  226. package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
  227. package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
  228. package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
  229. package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
  230. package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
  231. package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
  232. package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
  233. package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
  234. package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
  235. package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
  236. package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
  237. package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
  238. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
  239. package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
  240. package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
  241. package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
  242. package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
  243. package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
  244. package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
  245. package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
  246. package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
  247. package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
  248. package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
  249. package/packages/piagent-webui/package.json +14 -2
  250. package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
  251. package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
  252. package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
  253. package/packages/piagent-webui/server/control-command-digest.ts +5 -0
  254. package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
  255. package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
  256. package/packages/piagent-webui/server/loopback-server.ts +9 -0
  257. package/packages/piagent-webui/server/read-model-provider.ts +3 -0
  258. package/packages/piagent-webui/server/read-only-router.ts +3 -0
  259. package/packages/piagent-webui/server/session-auth.ts +4 -1
  260. package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
  261. package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
  262. package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
  263. package/packages/piagent-webui/server/terminal-command.ts +35 -0
  264. package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
  265. package/packages/piagent-webui/server/transcript-projection.ts +2 -1
  266. package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
  267. package/packages/piagent-webui/server/workspace-reader.ts +23 -0
  268. package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
  269. package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
  270. package/packages/piagent-webui/shared/terminal-message.ts +23 -0
  271. package/schemas/piagent-webui/catalog-v1.json +63 -0
  272. package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
  273. package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
  274. package/schemas/piagent-webui/diff-v1.schema.json +4 -0
  275. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
  276. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  277. package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
  278. package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
  279. package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
  280. package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
  281. package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
  282. package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
  283. package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
  284. package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
  285. package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
  286. package/schemas/project-profile.schema.json +8 -3
  287. package/scripts/company-terminal.mjs +16 -11
  288. package/scripts/pi-auto.sh +5 -1
  289. package/scripts/team-doctor.sh +8 -0
  290. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
  291. package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
  292. package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
@@ -0,0 +1,225 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import type { SourceMutationAppendOptions, SourceMutationEvidenceRecord } from "../../piagent-core/runtime/inspection/source-action-contract.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-action-contract.ts";
6
+ import type { SourceIndexTransactionResult } from "../../piagent-core/runtime/inspection/source-action-contract.ts";
7
+ import { controlActionDigest } from "./control-command-digest.ts";
8
+ import type { BridgeIdentity, BridgeRevisions, BridgeSnapshot } from "../../piagent-core/runtime/inspection/session-identity.ts";
9
+ type RevertSessionBinding = { snapshot(): BridgeSnapshot };
10
+
11
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/, PUBLIC_REF = /^[A-Za-z0-9][A-Za-z0-9._:@~-]{0,159}$/,
12
+ REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/, DIGEST = /^sha256:[a-f0-9]{64}$/,
13
+ WORKSPACE = /^wt-content-v2:[a-f0-9]{64}$/, IDEMPOTENCY = /^[A-Za-z0-9._~-]{32,128}$/;
14
+ const REVISION_KEYS = ["runtimeRevision", "taskRevision", "controlRevision", "workspaceRevision", "indexRevision", "approvalRevision", "sessionOptionRevision", "queueRevision"];
15
+ type RevertCommand = { schemaVersion: 1; version: "piagent-webui-control-v1"; messageType: "command"; commandId: string; idempotencyKey: string;
16
+ requestedAt: string; expiresAt: string; capabilityScope: "reviewActions"; action: "source.revert"; actionDigest: string; identity: BridgeIdentity;
17
+ expectedRevisions: BridgeRevisions & { workspacePreimage: string; indexPreimage: string; patchPreimage: string };
18
+ payload: { fileRef: string; hunkRefs: string[]; previewRef: string; confirmedPreviewDigest: string; contentDigest: string } };
19
+ type RevertMutate<Authority> = (input: { identity: { projectRef: string; runtimeInstanceId: string; sessionRef: string; taskId: string | null; taskRunId: string | null };
20
+ action: "source.revert"; expectedTaskRevision: string | null; expectedControlRevision: string; expectedIndexPreimage: string;
21
+ expectedWorkspacePreimage: string; previewRef: string; confirmedPreviewDigest: string;
22
+ authority: Authority }) => Promise<SourceIndexTransactionResult>;
23
+ export type SourceRevertReceipt = { schemaVersion: 1; version: "piagent-webui-control-v1"; messageType: "receipt"; commandId: string;
24
+ idempotencyKeyDigest: string; action: "source.revert"; actionDigest: string; identity: BridgeIdentity; phase: "settled" | "rejected" | "uncertain";
25
+ resultCode: "reverted" | "effect-unknown" | "stale-revision" | "identity-mismatch" | "capability-unavailable" | "idempotency-payload-mismatch"
26
+ | "expired" | "invalid-command" | "resync-required" | "terminal-task"; requestedAt: string; settledAt: string;
27
+ observedRevisionsBefore: BridgeRevisions; observedRevisionsAfter: BridgeRevisions; deduplicated: boolean; auditRef: string | null;
28
+ settlementEvidenceRef: string | null; error: { code: string; message: string; retryable: boolean } | null };
29
+
30
+ function sha(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; }
31
+ function same(left: unknown, right: unknown): boolean { return canonicalReviewValue(left) === canonicalReviewValue(right); }
32
+ function exactKeys(value: unknown, keys: string[]): boolean { return Boolean(value && typeof value === "object" && !Array.isArray(value)
33
+ && same(Object.keys(value as object).sort(), [...keys].sort())); }
34
+ function timestamp(value: unknown): value is string {
35
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
36
+ const parsed = Date.parse(value); return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
37
+ }
38
+ function wireIdentity(value: BridgeIdentity): BridgeIdentity { return { projectRef: value.projectRef, runtimeInstanceId: value.runtimeInstanceId,
39
+ sessionRef: value.sessionRef, taskId: value.taskId, taskRunId: value.taskRunId, agentOperationId: null, toolCallId: null }; }
40
+ function wireRevisions(value: Record<string, any>): BridgeRevisions { return { runtimeRevision: value.runtimeRevision, taskRevision: value.taskRevision,
41
+ controlRevision: value.controlRevision, workspaceRevision: value.workspaceRevision, indexRevision: value.indexRevision,
42
+ approvalRevision: value.approvalRevision, sessionOptionRevision: value.sessionOptionRevision, queueRevision: value.queueRevision }; }
43
+ function replayIdentityDisposition(record: Pick<SourceRevertEvidenceRecord, "projectRef" | "runtimeInstanceId" | "sessionRef" | "taskId" | "taskRunId">,
44
+ identity: BridgeIdentity): "task-identity-mismatch" | "runtime-or-session-replaced" | null {
45
+ if (record.taskId !== identity.taskId || record.taskRunId !== identity.taskRunId) return "task-identity-mismatch";
46
+ if (record.projectRef !== identity.projectRef || record.runtimeInstanceId !== identity.runtimeInstanceId || record.sessionRef !== identity.sessionRef)
47
+ return "runtime-or-session-replaced";
48
+ return null;
49
+ }
50
+ function receipt(record: SourceRevertEvidenceRecord, deduplicated: boolean): SourceRevertReceipt {
51
+ const identity = { projectRef: record.projectRef, runtimeInstanceId: record.runtimeInstanceId, sessionRef: record.sessionRef,
52
+ taskId: record.taskId, taskRunId: record.taskRunId, agentOperationId: null, toolCallId: null } as BridgeIdentity;
53
+ const before = wireRevisions(record.observedRevisionsBefore), after = wireRevisions(record.observedRevisionsAfter ?? record.observedRevisionsBefore);
54
+ if (record.phase === "settled") return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
55
+ idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "settled", resultCode: "reverted",
56
+ requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated,
57
+ auditRef: record.evidenceRef, settlementEvidenceRef: record.evidenceRef, error: null };
58
+ if (record.phase === "rejected") {
59
+ const stale = ["mutation-preimage-stale", "mutation-guard-precondition-stale"].includes(record.failureCode ?? "");
60
+ return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
61
+ idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "rejected",
62
+ resultCode: stale ? "stale-revision" : "capability-unavailable", requestedAt: record.requestedAt, settledAt: record.recordedAt,
63
+ observedRevisionsBefore: before, observedRevisionsAfter: after, deduplicated, auditRef: record.evidenceRef, settlementEvidenceRef: null,
64
+ error: { code: record.failureCode ?? "source-revert-rejected", message: "The Pi guard rejected the confirmed source revert before commit.", retryable: true } };
65
+ }
66
+ return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt", commandId: record.commandId,
67
+ idempotencyKeyDigest: record.idempotencyKeyDigest, action: "source.revert", actionDigest: record.actionDigest, identity, phase: "uncertain",
68
+ resultCode: "effect-unknown", requestedAt: record.requestedAt, settledAt: record.recordedAt, observedRevisionsBefore: before,
69
+ observedRevisionsAfter: after, deduplicated, auditRef: record.phase === "uncertain" ? record.evidenceRef : null,
70
+ settlementEvidenceRef: null, error: { code: "source-revert-effect-unknown", message: "The worktree effect could not be confirmed. Inspect the source tabs.", retryable: false } };
71
+ }
72
+
73
+ export type SourceRevertEvidenceRecord = Omit<SourceMutationEvidenceRecord, "taskId" | "taskRunId"> & { taskId: string | null; taskRunId: string | null };
74
+ export type SourceRevertEvidenceAppend = Omit<SourceMutationAppendOptions, "taskId" | "taskRunId"> & { taskId: string | null; taskRunId: string | null };
75
+ export type SourceRevertEvidenceStore = {
76
+ read(projectRoot: string, taskRunId: string | null, sessionRef: string): { records: SourceRevertEvidenceRecord[]; corruptions: string[] };
77
+ append(options: SourceRevertEvidenceAppend): SourceRevertEvidenceRecord;
78
+ };
79
+
80
+ export class SourceRevertController<Authority = SourceRevertAuthority> {
81
+ readonly #evidence: SourceRevertEvidenceStore; readonly #allowSessionReverts: boolean;
82
+ readonly #bridge: RevertSessionBinding; readonly #projectRoot: string;
83
+ readonly #resolve: (fileRef: string, hunkRefs: string[]) => Promise<{ projection: SourceRevertProjection; authority: Authority | null }>;
84
+ readonly #revisions: () => Promise<Record<string, string | null>>;
85
+ readonly #mutate: RevertMutate<Authority>;
86
+ readonly #now: () => Date; #serial: Promise<unknown> = Promise.resolve();
87
+ constructor(options: { allowSessionReverts?: boolean; evidence: SourceRevertEvidenceStore; bridge: RevertSessionBinding; projectRoot: string;
88
+ resolve(fileRef: string, hunkRefs: string[]): Promise<{ projection: SourceRevertProjection; authority: Authority | null }>;
89
+ revisions(): Promise<Record<string, string | null>>; mutate: RevertMutate<Authority>; now?: () => Date }) {
90
+ this.#allowSessionReverts = options.allowSessionReverts === true; this.#evidence = options.evidence; this.#bridge = options.bridge; this.#projectRoot = options.projectRoot; this.#resolve = options.resolve;
91
+ this.#revisions = options.revisions; this.#mutate = options.mutate; this.#now = options.now ?? (() => new Date());
92
+ }
93
+ execute(value: unknown): Promise<SourceRevertReceipt> {
94
+ const run = this.#serial.then(() => this.#execute(value), () => this.#execute(value)); this.#serial = run.catch(() => undefined); return run;
95
+ }
96
+ async #execute(value: unknown): Promise<SourceRevertReceipt> {
97
+ const command = value as RevertCommand, snapshot = this.#bridge.snapshot(), invalid = this.#validate(value);
98
+ if (snapshot.state !== "ready" || !snapshot.identity || !snapshot.revisions) return this.#reject(command, "resync-required", "revert-binding-unavailable", true);
99
+ if (invalid) return this.#reject(command, "invalid-command", invalid, false);
100
+ const identity = wireIdentity(snapshot.identity), bridgeRevisions = wireRevisions(snapshot.revisions);
101
+ const sessionBound = this.#allowSessionReverts && identity.taskId === null && identity.taskRunId === null && bridgeRevisions.taskRevision === null;
102
+ if (!sessionBound && (!identity.taskId || !identity.taskRunId || !bridgeRevisions.taskRevision)) return this.#reject(command, "capability-unavailable", "active-task-required", false);
103
+ if (snapshot.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal", false);
104
+ if (snapshot.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle", true);
105
+ const evidence = this.#evidence.read(this.#projectRoot, identity.taskRunId, identity.sessionRef);
106
+ if (evidence.corruptions.length) return this.#reject(command, "resync-required", "mutation-evidence-corrupt", false);
107
+ const keyDigest = sha(command.idempotencyKey), prior = evidence.records.filter((record) => record.commandId === command.commandId || record.idempotencyKeyDigest === keyDigest);
108
+ if (prior.length) {
109
+ if (prior.some((record) => record.commandId !== command.commandId || record.idempotencyKeyDigest !== keyDigest || record.actionDigest !== command.actionDigest
110
+ || record.action !== command.action)) return this.#reject(command, "idempotency-payload-mismatch", "idempotency-payload-mismatch", false);
111
+ const dispositions = prior.map((record) => replayIdentityDisposition(record, identity)).filter(Boolean);
112
+ if (dispositions.length) return this.#reject(command, "identity-mismatch",
113
+ dispositions.includes("task-identity-mismatch") ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
114
+ return receipt(prior.at(-1)!, true);
115
+ }
116
+ const now = this.#now().getTime();
117
+ if (Date.parse(command.requestedAt) > now + 30_000) return this.#reject(command, "invalid-command", "requested-at-in-future", false);
118
+ if (now > Date.parse(command.expiresAt)) return this.#reject(command, "expired", "command-expired", false);
119
+ if (!same(command.identity, identity)) return this.#reject(command, "identity-mismatch", "identity-mismatch", false);
120
+ if (command.expectedRevisions.runtimeRevision !== bridgeRevisions.runtimeRevision || command.expectedRevisions.taskRevision !== bridgeRevisions.taskRevision
121
+ || command.expectedRevisions.controlRevision !== bridgeRevisions.controlRevision) return this.#reject(command, "stale-revision", "stale-task-control-revision", true);
122
+ let resolved: { projection: SourceRevertProjection; authority: Authority | null };
123
+ try { resolved = await this.#resolve(command.payload.fileRef, command.payload.hunkRefs); }
124
+ catch { return this.#reject(command, "capability-unavailable", "revert-preview-unavailable", true); }
125
+ const rebound = this.#bridge.snapshot();
126
+ if (rebound.state !== "ready" || !rebound.identity || !rebound.revisions)
127
+ return this.#reject(command, "resync-required", "revert-binding-unavailable-after-preview", true);
128
+ const reboundIdentity = wireIdentity(rebound.identity), reboundRevisions = wireRevisions(rebound.revisions);
129
+ if (!same(reboundIdentity, identity)) {
130
+ const taskChanged = reboundIdentity.taskId !== identity.taskId || reboundIdentity.taskRunId !== identity.taskRunId;
131
+ return this.#reject(command, "identity-mismatch", taskChanged ? "task-identity-mismatch" : "runtime-or-session-replaced", false);
132
+ }
133
+ if (rebound.taskState === "terminal") return this.#reject(command, "terminal-task", "task-terminal-after-preview", false);
134
+ if (rebound.liveness !== "idle") return this.#reject(command, "capability-unavailable", "agent-not-idle-after-preview", true);
135
+ if (command.expectedRevisions.runtimeRevision !== reboundRevisions.runtimeRevision
136
+ || command.expectedRevisions.taskRevision !== reboundRevisions.taskRevision
137
+ || command.expectedRevisions.controlRevision !== reboundRevisions.controlRevision)
138
+ return this.#reject(command, "stale-revision", "stale-task-control-revision-after-preview", true);
139
+ if (!resolved.authority || resolved.projection.state !== "ready" || !resolved.projection.target)
140
+ return this.#reject(command, "capability-unavailable", resolved.projection.reasonCode ?? "revert-preview-unavailable", true);
141
+ const target = resolved.projection.target;
142
+ if (!this.#matches(command, target) || this.#now().getTime() > Date.parse(target.expiresAt))
143
+ return this.#reject(command, "stale-revision", "revert-preview-stale", true);
144
+ const before = { ...bridgeRevisions, workspaceRevision: target.workspaceRevision, indexRevision: target.indexRevision };
145
+ let requested: SourceRevertEvidenceRecord;
146
+ try { requested = this.#evidence.append({ projectRoot: this.#projectRoot, projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
147
+ sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId, commandId: command.commandId, idempotencyKeyDigest: keyDigest,
148
+ actionDigest: command.actionDigest, action: "source.revert", target, selectedHunkRefs: [...command.payload.hunkRefs], phase: "requested",
149
+ resultCode: "mutation-requested", failureCode: null, requestedAt: command.requestedAt, recordedAt: this.#now().toISOString(),
150
+ beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage,
151
+ afterWorkspacePreimage: null, observedRevisionsBefore: before, observedRevisionsAfter: null }); }
152
+ catch { return this.#reject(command, "capability-unavailable", "mutation-evidence-unavailable", false); }
153
+ let effect: SourceIndexTransactionResult;
154
+ try { effect = await this.#mutate({ identity: { projectRef: identity.projectRef, runtimeInstanceId: identity.runtimeInstanceId,
155
+ sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId }, action: "source.revert",
156
+ expectedTaskRevision: target.taskRevision, expectedControlRevision: bridgeRevisions.controlRevision!, expectedIndexPreimage: target.indexPreimage,
157
+ expectedWorkspacePreimage: target.workspacePreimage, previewRef: target.previewRef, confirmedPreviewDigest: target.confirmedPreviewDigest,
158
+ authority: resolved.authority }); }
159
+ catch { effect = { state: "rejected", reasonCode: "mutation-guard-unavailable", beforeIndexPreimage: target.indexPreimage,
160
+ afterIndexPreimage: null, beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false }; }
161
+ if (effect.executor !== "pi-guard" || effect.directExecution !== false) effect = { ...effect, state: "uncertain", reasonCode: "mutation-executor-untrusted",
162
+ afterIndexPreimage: null, afterWorkspacePreimage: null, executor: "pi-guard", directExecution: false };
163
+ let after: BridgeRevisions; try { after = wireRevisions(await this.#revisions()); } catch { after = before; }
164
+ const phase = effect.state, resultCode = effect.state === "settled" ? "reverted" : effect.state === "rejected" ? "mutation-rejected" : "effect-unknown";
165
+ try { const terminal = this.#evidence.append({ projectRoot: this.#projectRoot, projectRef: identity.projectRef,
166
+ runtimeInstanceId: identity.runtimeInstanceId, sessionRef: identity.sessionRef, taskId: identity.taskId, taskRunId: identity.taskRunId,
167
+ commandId: command.commandId, idempotencyKeyDigest: keyDigest, actionDigest: command.actionDigest, action: "source.revert", target,
168
+ selectedHunkRefs: [...command.payload.hunkRefs], phase, resultCode, failureCode: effect.reasonCode, requestedAt: command.requestedAt,
169
+ recordedAt: this.#now().toISOString(), beforeIndexPreimage: target.indexPreimage, afterIndexPreimage: effect.afterIndexPreimage,
170
+ beforeWorkspacePreimage: target.workspacePreimage, afterWorkspacePreimage: effect.afterWorkspacePreimage,
171
+ observedRevisionsBefore: before, observedRevisionsAfter: after }); return receipt(terminal, false); }
172
+ catch { return receipt(requested, false); }
173
+ }
174
+ #matches(command: RevertCommand, target: SourceRevertTarget): boolean {
175
+ return command.payload.fileRef === target.fileRef && same(command.payload.hunkRefs, target.hunkRefs) && command.payload.previewRef === target.previewRef
176
+ && command.payload.confirmedPreviewDigest === target.confirmedPreviewDigest && command.payload.contentDigest === target.contentDigest
177
+ && command.expectedRevisions.taskRevision === target.taskRevision && command.expectedRevisions.workspaceRevision === target.workspaceRevision
178
+ && command.expectedRevisions.indexRevision === target.indexRevision && command.expectedRevisions.workspacePreimage === target.workspacePreimage
179
+ && command.expectedRevisions.indexPreimage === target.indexPreimage && command.expectedRevisions.patchPreimage === target.patchPreimage;
180
+ }
181
+ #validate(value: unknown): string | null {
182
+ const command = value as RevertCommand;
183
+ if (!exactKeys(command, ["schemaVersion", "version", "messageType", "commandId", "idempotencyKey", "requestedAt", "expiresAt", "capabilityScope", "action", "actionDigest", "identity", "expectedRevisions", "payload"])
184
+ || command.schemaVersion !== 1 || command.version !== "piagent-webui-control-v1" || command.messageType !== "command" || command.capabilityScope !== "reviewActions"
185
+ || command.action !== "source.revert" || !REF.test(command.commandId) || !IDEMPOTENCY.test(command.idempotencyKey) || !timestamp(command.requestedAt)
186
+ || !timestamp(command.expiresAt) || Date.parse(command.requestedAt) > Date.parse(command.expiresAt) || Date.parse(command.expiresAt) - Date.parse(command.requestedAt) > 300_000)
187
+ return "invalid-command-metadata";
188
+ const sessionBound = this.#allowSessionReverts && command.identity?.taskId === null && command.identity?.taskRunId === null
189
+ && command.expectedRevisions?.taskRevision === null;
190
+ if (!exactKeys(command.identity, ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId", "agentOperationId", "toolCallId"])
191
+ || ![command.identity.projectRef, command.identity.runtimeInstanceId, command.identity.sessionRef].every((item) => REF.test(item))
192
+ || (!sessionBound && ![command.identity.taskId, command.identity.taskRunId].every((item) => typeof item === "string" && PUBLIC_REF.test(item)))
193
+ || command.identity.agentOperationId !== null || command.identity.toolCallId !== null) return "invalid-command-identity";
194
+ if (!exactKeys(command.expectedRevisions, [...REVISION_KEYS, "workspacePreimage", "indexPreimage", "patchPreimage"])
195
+ || !REVISION.test(command.expectedRevisions.runtimeRevision) || (!sessionBound && !REVISION.test(command.expectedRevisions.taskRevision ?? ""))
196
+ || !REVISION.test(command.expectedRevisions.controlRevision ?? "")
197
+ || !REVISION.test(command.expectedRevisions.workspaceRevision ?? "") || !REVISION.test(command.expectedRevisions.indexRevision ?? "")
198
+ || [command.expectedRevisions.approvalRevision, command.expectedRevisions.sessionOptionRevision, command.expectedRevisions.queueRevision]
199
+ .some((item) => item !== null && !REVISION.test(String(item)))
200
+ || !WORKSPACE.test(command.expectedRevisions.workspacePreimage) || !DIGEST.test(command.expectedRevisions.indexPreimage)
201
+ || !DIGEST.test(command.expectedRevisions.patchPreimage)) return "invalid-revert-authority";
202
+ if (!exactKeys(command.payload, ["fileRef", "hunkRefs", "previewRef", "confirmedPreviewDigest", "contentDigest"])
203
+ || !REF.test(command.payload.fileRef) || !REF.test(command.payload.previewRef) || !Array.isArray(command.payload.hunkRefs)
204
+ || command.payload.hunkRefs.length > 1 || new Set(command.payload.hunkRefs).size !== command.payload.hunkRefs.length
205
+ || command.payload.hunkRefs.some((item) => !REF.test(item)) || !DIGEST.test(command.payload.confirmedPreviewDigest)
206
+ || !DIGEST.test(command.payload.contentDigest)) return "invalid-revert-payload";
207
+ if (!DIGEST.test(command.actionDigest) || command.actionDigest !== controlActionDigest(command)) return "action-digest-mismatch";
208
+ return null;
209
+ }
210
+ #reject(command: RevertCommand, resultCode: SourceRevertReceipt["resultCode"], code: string, retryable: boolean): SourceRevertReceipt {
211
+ const snapshot = this.#bridge.snapshot(), at = this.#now().toISOString(), observed = snapshot.identity ? wireIdentity(snapshot.identity) : null;
212
+ const identity = observed && this.#allowSessionReverts ? observed : observed ? { ...observed, taskId: observed.taskId ?? "task.unavailable", taskRunId: observed.taskRunId ?? "task-run.unavailable" }
213
+ : { projectRef: "project.unavailable", runtimeInstanceId: "runtime.unavailable", sessionRef: "session.unavailable", taskId: "task.unavailable",
214
+ taskRunId: "task-run.unavailable", agentOperationId: null, toolCallId: null } as BridgeIdentity;
215
+ const revisions = snapshot.revisions ? wireRevisions(snapshot.revisions) : { runtimeRevision: "runtime.unavailable", taskRevision: null, controlRevision: null,
216
+ workspaceRevision: null, indexRevision: null, approvalRevision: null, sessionOptionRevision: null, queueRevision: null };
217
+ return { schemaVersion: 1, version: "piagent-webui-control-v1", messageType: "receipt",
218
+ commandId: typeof command?.commandId === "string" && REF.test(command.commandId) ? command.commandId : `revert-rejected.${reviewDigest(command ?? null).slice(7)}`,
219
+ idempotencyKeyDigest: sha(typeof command?.idempotencyKey === "string" ? command.idempotencyKey : canonicalReviewValue(command ?? null)), action: "source.revert",
220
+ actionDigest: typeof command?.actionDigest === "string" && DIGEST.test(command.actionDigest) ? command.actionDigest : sha(code), identity,
221
+ phase: "rejected", resultCode, requestedAt: timestamp(command?.requestedAt) ? command.requestedAt : at, settledAt: at,
222
+ observedRevisionsBefore: revisions, observedRevisionsAfter: revisions, deduplicated: false, auditRef: null, settlementEvidenceRef: null,
223
+ error: { code, message: "The confirmed exact source revert could not be completed.", retryable } };
224
+ }
225
+ }
@@ -0,0 +1,35 @@
1
+ import type { TerminalCommand } from "../contracts/generated/terminal-v1.ts";
2
+ import { controlActionDigest } from "./control-command-digest.ts";
3
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
4
+ const REVISION = /^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/;
5
+ const record = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
6
+ const exact = (value: unknown, keys: string[]): value is Record<string, unknown> => record(value)
7
+ && Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
8
+ const ref = (value: unknown) => typeof value === "string" && REF.test(value);
9
+ const date = (value: unknown) => typeof value === "string" && value.length <= 64 && Number.isFinite(Date.parse(value));
10
+ const dimensions = (value: Record<string, unknown>) => Number.isInteger(value.cols) && Number(value.cols) >= 2 && Number(value.cols) <= 500
11
+ && Number.isInteger(value.rows) && Number(value.rows) >= 1 && Number(value.rows) <= 200;
12
+ export function parseTerminalCommand(value: unknown): TerminalCommand {
13
+ const invalid = () => { throw new Error("terminal-command-invalid"); };
14
+ if (!exact(value, ["schemaVersion", "version", "messageType", "commandId", "idempotencyKey", "requestedAt", "expiresAt", "action", "actionDigest", "identity", "expectedRevisions", "payload"])) return invalid();
15
+ if (value.schemaVersion !== 1 || value.version !== "piagent-terminal-v1" || value.messageType !== "command"
16
+ || value.action !== "terminal.command" || !ref(value.commandId) || !ref(value.idempotencyKey)
17
+ || !date(value.requestedAt) || !date(value.expiresAt)
18
+ || !exact(value.identity, ["gatewayInstanceRef", "sessionRef", "runtimeInstanceRef"]) || !Object.values(value.identity).every(ref)
19
+ || !exact(value.expectedRevisions, ["terminalRevision"]) || typeof value.expectedRevisions.terminalRevision !== "string"
20
+ || !REVISION.test(value.expectedRevisions.terminalRevision) || !record(value.payload)) return invalid();
21
+ const window = Date.parse(String(value.expiresAt)) - Date.parse(String(value.requestedAt));
22
+ if (window < 0 || window > 300_000) return invalid();
23
+ const payload = value.payload;
24
+ if (payload.kind === "open") { if (!exact(payload, ["kind", "cols", "rows"]) || !dimensions(payload)) return invalid(); }
25
+ else if (payload.kind === "input") {
26
+ if (!exact(payload, ["kind", "terminalRef", "data"]) || !ref(payload.terminalRef) || typeof payload.data !== "string"
27
+ || Buffer.byteLength(payload.data) > 16 * 1024) return invalid();
28
+ } else if (payload.kind === "resize") {
29
+ if (!exact(payload, ["kind", "terminalRef", "cols", "rows"]) || !ref(payload.terminalRef) || !dimensions(payload)) return invalid();
30
+ } else if (payload.kind === "close") { if (!exact(payload, ["kind", "terminalRef"]) || !ref(payload.terminalRef)) return invalid(); }
31
+ else return invalid();
32
+ const command = value as unknown as TerminalCommand;
33
+ if (command.actionDigest !== controlActionDigest(command)) return invalid();
34
+ return command;
35
+ }
@@ -0,0 +1,56 @@
1
+ import type { TerminalState, TerminalOutput, TerminalExit } from "../contracts/generated/terminal-v1.ts";
2
+ export type TerminalStreamMessage = TerminalState | TerminalOutput | TerminalExit;
3
+ export type TerminalStreamPort = {
4
+ subscribe(sessionRef: string, listener: (message: TerminalStreamMessage) => void): () => void;
5
+ replay(sessionRef: string): TerminalOutput[];
6
+ };
7
+
8
+ // Each socket follows one selected session. PTYs outlive this subscription.
9
+ export class TerminalWebSocketStream {
10
+ readonly #port: TerminalStreamPort;
11
+ readonly #authorized: (csrf: string) => boolean;
12
+ readonly #send: (message: TerminalStreamMessage) => void;
13
+ readonly #close: (reason: string) => void;
14
+ #unsubscribe: (() => void) | null = null;
15
+ #generation = 0;
16
+ constructor(options: { port: TerminalStreamPort; authorized(csrf: string): boolean;
17
+ send(message: TerminalStreamMessage): void; close(reason: string): void }) {
18
+ this.#port = options.port; this.#authorized = options.authorized; this.#send = options.send; this.#close = options.close;
19
+ }
20
+ stop() { this.#generation++; this.#unsubscribe?.(); this.#unsubscribe = null; }
21
+ begin(sessionRef: string, csrf: string) {
22
+ this.stop();
23
+ const generation = this.#generation;
24
+ let pending: TerminalStreamMessage[] = [], bytes = 0, replaying = true;
25
+ const deliver = (message: TerminalStreamMessage) => {
26
+ if (generation !== this.#generation) return;
27
+ if (!this.#authorized(csrf)) { this.stop(); this.#close("terminal-session-expired"); return; }
28
+ this.#send(message);
29
+ };
30
+ this.#unsubscribe = this.#port.subscribe(sessionRef, message => {
31
+ if (message.identity.sessionRef !== sessionRef || generation !== this.#generation) return;
32
+ if (!this.#authorized(csrf)) { this.stop(); this.#close("terminal-session-expired"); return; }
33
+ if (!replaying) { deliver(message); return; }
34
+ bytes += Buffer.byteLength(JSON.stringify(message));
35
+ if (bytes > 512 * 1024 || pending.length >= 256) { this.stop(); pending = []; this.#close("terminal-backpressure"); return; }
36
+ pending.push(message);
37
+ });
38
+ return {
39
+ // Called immediately after the state response, before accepting live data.
40
+ finish: (ready: () => void) => {
41
+ if (generation !== this.#generation) return;
42
+ if (!this.#authorized(csrf)) { this.stop(); this.#close("terminal-session-expired"); return; }
43
+ const replay = this.#port.replay(sessionRef), seen = new Map<string, number>();
44
+ ready();
45
+ for (const message of replay) { seen.set(message.terminalRef, message.sequence); deliver(message); }
46
+ replaying = false;
47
+ for (const message of pending) {
48
+ if (message.messageType === "state" || message.messageType === "exit"
49
+ || message.sequence > (seen.get(message.terminalRef) ?? -1)) deliver(message);
50
+ }
51
+ pending = [];
52
+ },
53
+ abort: () => { if (generation === this.#generation) this.stop(); }
54
+ };
55
+ }
56
+ }
@@ -62,6 +62,7 @@ export type TranscriptProjectionInput = {
62
62
 
63
63
  function hash(value: string): string { return createHash("sha256").update(value).digest("hex"); }
64
64
  function opaque(prefix: string, value: unknown): string { return `${prefix}.${hash(JSON.stringify(value))}`; }
65
+ export const transcriptMessageRef = (sessionRef: string, entryId: string) => opaque("message", [sessionRef, entryId]);
65
66
  function timestamp(value: unknown): string | null {
66
67
  const parsed = typeof value === "number" && Number.isFinite(value) ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
67
68
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
@@ -266,7 +267,7 @@ function item(entry: any, identity: TranscriptIdentity, customReceipt = false):
266
267
  textChars: Math.min(1_000_000_000, projected.full.length), digest: `sha256:${hash(projected.full)}`, truncated: projected.truncated,
267
268
  redacted: projected.redacted, imageCount: imageCount(entry.message), reasonCode: projected.redacted ? "sensitive-values-redacted" as const : null };
268
269
  })();
269
- return { messageRef: opaque("message", [identity.sessionRef, entry.id]), parentMessageRef: null, role: itemRole, recordedAt,
270
+ return { messageRef: transcriptMessageRef(identity.sessionRef, entry.id), parentMessageRef: null, role: itemRole, recordedAt,
270
271
  agentOperationId: null, turnIndex: null, content, ...(userProjection.attachments.length ? { attachments: userProjection.attachments } : {}),
271
272
  toolCalls: projectedToolCalls };
272
273
  }
@@ -0,0 +1,32 @@
1
+ import { WorkspaceReadError } from "../../piagent-core/security/workspace-file-reader.ts";
2
+
3
+ export type WorkspaceRead = (kind: "tree" | "file" | "paths" | "search", path: string) => unknown | Promise<unknown>;
4
+ export type WorkspaceRouteResult = { handled: false } | { handled: true; status: number; value: unknown };
5
+
6
+ // Authentication and company forwarding belong to the session router. This
7
+ // parser accepts only project-relative paths, including an empty tree root.
8
+ export async function routeWorkspaceRead(url: URL, read?: WorkspaceRead): Promise<WorkspaceRouteResult> {
9
+ const match = /^\/api\/v1\/workspace\/(tree|file|paths|search)$/.exec(url.pathname);
10
+ if (!match) return { handled: false };
11
+ const refused = (status: number, code: string): WorkspaceRouteResult => ({ handled: true, status, value: { error: { code } } });
12
+ if (!read) return refused(503, "workspace-unavailable");
13
+ const parameter = ["paths", "search"].includes(match[1]) ? "query" : "path";
14
+ const relative = url.searchParams.get(parameter) ?? "";
15
+ if ([...url.searchParams.keys()].some(key => key !== parameter) || url.searchParams.getAll(parameter).length > 1
16
+ || relative.length > (parameter === "query" ? 512 : 4096) || /[\x00-\x1f\x7f]/.test(relative)
17
+ || (match[1] !== "search" && (relative.includes("\\") || relative.startsWith("/")))
18
+ || (match[1] === "search" && !relative.trim())
19
+ || (parameter === "path" && (relative ? relative.split("/").some(part => !part || part === "." || part === "..") : match[1] !== "tree"))) {
20
+ return refused(400, "workspace-path-invalid");
21
+ }
22
+ try { return { handled: true, status: 200, value: await read(match[1] as "tree" | "file" | "paths" | "search", relative) }; }
23
+ catch (error) {
24
+ if (error instanceof WorkspaceReadError) return refused(error.code === "changed-during-read" ? 409 : 400, error.code);
25
+ const code = (error as NodeJS.ErrnoException)?.code;
26
+ if (code === "ENOENT") return refused(404, "workspace-path-not-found");
27
+ if (code === "ELOOP" || code === "EACCES" || code === "EPERM" || (error as Error)?.message === "workspace-path-protected") {
28
+ return refused(403, "workspace-path-protected");
29
+ }
30
+ return refused(503, "workspace-unavailable");
31
+ }
32
+ }
@@ -0,0 +1,23 @@
1
+ import { searchWorkspace } from "../../piagent-core/extensions/workspace-search.ts";
2
+ import { loadProjectContextIndexPolicy } from "../../piagent-core/extensions/workspace-policy.js";
3
+ import { inspectWorkspaceFile, inspectWorkspaceTree, inspectWorkspacePaths } from "../../piagent-core/extensions/workspace-inspection.ts";
4
+ import { inspectWorkspaceEntry } from "../../piagent-core/security/workspace-file-reader.ts";
5
+ import type { WorkspaceRead } from "./workspace-read-routes.ts";
6
+
7
+ // Re-read policy on every request; changing a profile must not leave a warm
8
+ // editor with the permissions that happened to exist when it first opened.
9
+ export function personalWorkspaceReader(options: {
10
+ cwd: string; packageRoot: string; identity(): Record<string, unknown> | Promise<Record<string, unknown>>;
11
+ }): WorkspaceRead {
12
+ return async (kind, relative) => {
13
+ try {
14
+ const stat = inspectWorkspaceEntry(options.cwd, ".pi/piagent-profile.json");
15
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_048_576) throw new Error("workspace-policy-unavailable");
16
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
17
+ const { policy, profile } = loadProjectContextIndexPolicy(options.packageRoot, options.cwd);
18
+ const context = { policy, profile, readOnly: profile.permissionProfile === "read-only" };
19
+ const value = kind === "search" ? await searchWorkspace(options.cwd, relative, context) : kind === "paths" ? await inspectWorkspacePaths(options.cwd, relative, context) : kind === "tree" ? inspectWorkspaceTree(options.cwd, relative, context) : inspectWorkspaceFile(options.cwd, relative, context);
20
+ return { schemaVersion: 1, version: `piagent-webui-workspace-${kind}-v1`, generatedAt: new Date().toISOString(),
21
+ identity: await options.identity(), ...value };
22
+ };
23
+ }
@@ -0,0 +1,118 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalReviewValue } from "../../piagent-core/runtime/inspection/review-state-contract.ts";
3
+ import { WorkspaceWriteError } from "../../piagent-core/extensions/workspace-write.ts";
4
+ import type { Identity, WorkspaceWriteCommand, WorkspaceWritePayload, WorkspaceWriteReceipt, WorkspaceWriteState } from "../contracts/generated/workspace-write-v1.ts";
5
+
6
+ const VERSION = "piagent-webui-workspace-write-v1" as const;
7
+ const sha = (value: string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
8
+ const canonical = canonicalReviewValue;
9
+ const same = (left: unknown, right: unknown) => canonical(left) === canonical(right);
10
+ const keys = (value: unknown, expected: string[]) => !!value && typeof value === "object" && !Array.isArray(value)
11
+ && same(Object.keys(value).sort(), [...expected].sort());
12
+ const timestamp = (value: unknown): value is string => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
13
+ const ref = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/.test(value);
14
+ export function workspaceWriteDigest(command: Pick<WorkspaceWriteCommand, "action" | "identity" | "expectedRevisions" | "payload">): string {
15
+ return sha(canonical({ action: command.action, identity: command.identity, expectedRevisions: command.expectedRevisions, payload: command.payload }));
16
+ }
17
+ // A write, or a rename (with its destination) or delete of a file whose digest the member saw.
18
+ const payloadKeys = (payload: WorkspaceWritePayload) => keys(payload, ["path", "content", "expectedDigest"])
19
+ || payload?.operation === "write" && keys(payload, ["path", "content", "expectedDigest", "operation"])
20
+ || payload?.operation === "delete" && keys(payload, ["path", "content", "expectedDigest", "operation"]) && payload.content === "" && payload.expectedDigest !== "absent"
21
+ || payload?.operation === "rename" && keys(payload, ["path", "content", "expectedDigest", "operation", "destination"]) && payload.content === ""
22
+ && payload.expectedDigest !== "absent" && typeof payload.destination === "string" && !!payload.destination && payload.destination.length <= 4096;
23
+ function parse(value: unknown): WorkspaceWriteCommand {
24
+ const command = value as WorkspaceWriteCommand;
25
+ if (!keys(value, ["schemaVersion", "version", "messageType", "commandId", "idempotencyKey", "requestedAt", "expiresAt", "action", "actionDigest", "identity", "expectedRevisions", "payload"])
26
+ || command.schemaVersion !== 1 || command.version !== VERSION || command.messageType !== "command" || command.action !== "workspace.write"
27
+ || !ref(command.commandId) || typeof command.idempotencyKey !== "string" || !/^[A-Za-z0-9._~-]{32,128}$/.test(command.idempotencyKey)
28
+ || !timestamp(command.requestedAt) || !timestamp(command.expiresAt)
29
+ || !keys(command.identity, ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId", "agentOperationId", "toolCallId"])
30
+ || !keys(command.expectedRevisions, ["workspaceRevision"]) || typeof command.expectedRevisions.workspaceRevision !== "string"
31
+ || !/^[A-Za-z0-9][A-Za-z0-9._:~-]{0,159}$/.test(command.expectedRevisions.workspaceRevision)
32
+ || !payloadKeys(command.payload) || typeof command.payload.path !== "string" || !command.payload.path || command.payload.path.length > 4096
33
+ || typeof command.payload.content !== "string" || Buffer.byteLength(command.payload.content) > 1_048_576
34
+ || typeof command.payload.expectedDigest !== "string" || !/^(absent|[a-f0-9]{64})$/.test(command.payload.expectedDigest)
35
+ || command.actionDigest !== workspaceWriteDigest(command)) throw new Error("invalid-workspace-write-command");
36
+ return command;
37
+ }
38
+ export type WorkspaceWriteFacts = { identity: Identity; writable: boolean; agentRunning: boolean };
39
+ export type WorkspaceWriteAdapterResult = { state: "saved"; digest: string } | { state: "rejected"; code: WorkspaceWriteError["code"]; currentDigest?: string | null };
40
+ type Options = {
41
+ facts(): WorkspaceWriteFacts | Promise<WorkspaceWriteFacts>;
42
+ // Gateway persistence owns receipts; successful saves mirror to the session.
43
+ history(): WorkspaceWriteReceipt[];
44
+ append(receipt: WorkspaceWriteReceipt): void | Promise<void>;
45
+ write(payload: WorkspaceWritePayload): WorkspaceWriteAdapterResult | Promise<WorkspaceWriteAdapterResult>;
46
+ now?(): Date;
47
+ };
48
+
49
+ export class WorkspaceWriteController {
50
+ readonly #options: Options;
51
+ #serial: Promise<unknown> = Promise.resolve();
52
+ constructor(options: Options) { this.#options = options; }
53
+ #revision(facts: WorkspaceWriteFacts, history: WorkspaceWriteReceipt[]): string {
54
+ const last = [...history].reverse().find(receipt => receipt.phase === "settled" && receipt.resultCode === "saved"
55
+ && receipt.identity.projectRef === facts.identity.projectRef && receipt.identity.sessionRef === facts.identity.sessionRef);
56
+ return last?.observedRevisionsAfter.workspaceRevision ?? `workspace:${sha(canonical(facts.identity)).slice(7)}`;
57
+ }
58
+ async state(): Promise<WorkspaceWriteState> {
59
+ const facts = await this.#options.facts();
60
+ return { schemaVersion: 1, version: VERSION, messageType: "state", ...facts,
61
+ revisions: { workspaceRevision: this.#revision(facts, this.#options.history()) } };
62
+ }
63
+ execute(value: unknown): Promise<WorkspaceWriteReceipt> {
64
+ const command = parse(value);
65
+ const run = this.#serial.then(() => this.#execute(command));
66
+ this.#serial = run.catch(() => undefined); return run;
67
+ }
68
+ async #execute(command: WorkspaceWriteCommand): Promise<WorkspaceWriteReceipt> {
69
+ const facts = await this.#options.facts(), history = this.#options.history();
70
+ const key = sha(command.idempotencyKey), previous = [...history].reverse().find(receipt => receipt.idempotencyKeyDigest === key);
71
+ const revisions = { workspaceRevision: this.#revision(facts, history) };
72
+ const now = this.#options.now?.() ?? new Date();
73
+ const receipt: WorkspaceWriteReceipt = { schemaVersion: 1, version: VERSION, messageType: "receipt", commandId: command.commandId,
74
+ idempotencyKeyDigest: key, action: command.action, actionDigest: command.actionDigest, identity: facts.identity,
75
+ phase: "rejected", resultCode: "write-failed", requestedAt: command.requestedAt, settledAt: now.toISOString(),
76
+ observedRevisionsBefore: revisions, observedRevisionsAfter: revisions, path: command.payload.path, digest: null, currentDigest: null, deduplicated: false };
77
+ const reject = (resultCode: WorkspaceWriteReceipt["resultCode"]) => ({ ...receipt, resultCode });
78
+ if (previous && same(command.identity, previous.identity)
79
+ && command.identity.projectRef === facts.identity.projectRef && command.identity.sessionRef === facts.identity.sessionRef) {
80
+ if (previous.actionDigest !== command.actionDigest) return reject("idempotency-payload-mismatch");
81
+ return { ...previous, deduplicated: true };
82
+ }
83
+ if (!same(command.identity, facts.identity)) return reject("identity-mismatch");
84
+ if (previous) return reject("idempotency-payload-mismatch");
85
+ const refuse = async (code: WorkspaceWriteReceipt["resultCode"]) => { const value = reject(code); await this.#options.append(value); return value; };
86
+ if (Date.parse(command.expiresAt) < now.getTime() || Date.parse(command.requestedAt) > now.getTime() + 5000
87
+ || Date.parse(command.expiresAt) <= Date.parse(command.requestedAt) || Date.parse(command.expiresAt) - Date.parse(command.requestedAt) > 120_000) return refuse("expired");
88
+ if (!facts.writable) return refuse("read-only");
89
+ if (!same(command.expectedRevisions, revisions)) return refuse("stale-revision");
90
+ // Persist an uncertain receipt before dispatch. A crash must never replay a
91
+ // possibly completed write, even when its completion was not recorded.
92
+ const pending = { ...receipt, phase: "uncertain" as const, resultCode: "effect-unknown" as const };
93
+ await this.#options.append(pending);
94
+ let settled: WorkspaceWriteReceipt;
95
+ try {
96
+ const current = await this.#options.facts();
97
+ if (!same(current.identity, facts.identity)) settled = reject("identity-mismatch");
98
+ else if (!current.writable) settled = reject("read-only");
99
+ else if (Date.parse(command.expiresAt) < (this.#options.now?.() ?? new Date()).getTime()) settled = reject("expired");
100
+ else {
101
+ const result = await this.#options.write(command.payload);
102
+ settled = result.state === "saved" ? { ...receipt, phase: "settled", resultCode: "saved", digest: result.digest,
103
+ observedRevisionsAfter: { workspaceRevision: `workspace:${key.slice(7)}` } }
104
+ : { ...receipt, resultCode: result.code, currentDigest: result.currentDigest ?? null };
105
+ }
106
+ } catch (error) {
107
+ // The live personal guard may be loaded by Pi's TS loader, with a
108
+ // different Error constructor than native ESM. Validate its bounded code.
109
+ const failure = error as WorkspaceWriteError | undefined;
110
+ settled = (error instanceof WorkspaceWriteError || failure?.name === "WorkspaceWriteError")
111
+ && ["invalid-write", "path-protected", "read-only", "stale-content", "unsafe-path"].includes(failure!.code)
112
+ ? { ...receipt, resultCode: failure!.code, currentDigest: failure!.currentDigest ?? null } : pending;
113
+ }
114
+ settled.settledAt = (this.#options.now?.() ?? new Date()).toISOString();
115
+ try { await this.#options.append(settled); } catch { return pending; }
116
+ return settled;
117
+ }
118
+ }
@@ -0,0 +1,38 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { SessionAuthority } from "./session-auth.ts";
3
+ import { errorResponse, jsonResponse, requestBody } from "./http-security.ts";
4
+
5
+ export type WorkspaceWriteRoutes = {
6
+ state(sessionRef: string): unknown | Promise<unknown>;
7
+ execute(sessionRef: string, command: unknown): unknown | Promise<unknown>;
8
+ };
9
+ type Context = { request: IncomingMessage; response: ServerResponse; url: URL; origin: string;
10
+ requestOrigin: string | undefined; auth: SessionAuthority; now: number; consumeControl(id: string, now: number): boolean };
11
+ export async function routeWorkspaceWrite(context: Context, handler?: WorkspaceWriteRoutes): Promise<boolean> {
12
+ const { request, response, url, auth, requestOrigin, origin, now, consumeControl } = context;
13
+ const match = /^\/api\/v1\/sessions\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159})\/workspace\/write$/.exec(url.pathname);
14
+ if (!match) return false;
15
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
16
+ if (url.search) { errorResponse(response, 400, "invalid-workspace-write-query"); return true; }
17
+ if (!["GET", "POST"].includes(request.method ?? "")) { errorResponse(response, 405, "method-not-allowed"); return true; }
18
+ let command: unknown;
19
+ if (request.method === "POST") {
20
+ if (requestOrigin !== origin) { errorResponse(response, 403, "origin-required"); return true; }
21
+ const session = auth.authorizeMutation(request);
22
+ if (!session) { errorResponse(response, 403, "mutation-authority-rejected"); return true; }
23
+ if (!consumeControl(session.id, now)) { errorResponse(response, 429, "control-rate-limit"); return true; }
24
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
25
+ errorResponse(response, 415, "content-type"); return true;
26
+ }
27
+ // JSON escaping may expand one permitted UTF-8 text byte to six bytes.
28
+ try { command = JSON.parse((await requestBody(request, 6 * 1_048_576 + 8192)).toString("utf8")); }
29
+ catch (error) { errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-workspace-write-command"); return true; }
30
+ if ((command as { identity?: { sessionRef?: unknown } } | null)?.identity?.sessionRef !== match[1]) {
31
+ errorResponse(response, 400, "workspace-session-mismatch"); return true;
32
+ }
33
+ }
34
+ if (!handler) { errorResponse(response, 503, "workspace-write-unavailable"); return true; }
35
+ try { jsonResponse(response, 200, request.method === "GET" ? await handler.state(match[1]) : await handler.execute(match[1], command)); }
36
+ catch (error) { errorResponse(response, (error as Error).message === "invalid-workspace-write-command" ? 400 : 503, "workspace-write-unavailable"); }
37
+ return true;
38
+ }
@@ -0,0 +1,23 @@
1
+ const object = (value: unknown): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value);
2
+ const ref = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/.test(value);
3
+ const exact = (value: Record<string, any>, keys: string[]) => Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
4
+ export function isTerminalMessage(value: unknown): boolean {
5
+ if (!object(value) || value.schemaVersion !== 1 || value.version !== 'piagent-terminal-v1' || !object(value.identity)
6
+ || !exact(value.identity, ['gatewayInstanceRef', 'runtimeInstanceRef', 'sessionRef'])
7
+ || !['gatewayInstanceRef', 'runtimeInstanceRef', 'sessionRef'].every(key => ref(value.identity[key]))) return false;
8
+ const base = ['schemaVersion', 'version', 'messageType', 'identity'];
9
+ if (value.messageType === 'state') return exact(value, [...base, 'revisions', 'available', 'reasonCode', 'terminals'])
10
+ && object(value.revisions) && exact(value.revisions, ['terminalRevision']) && typeof value.revisions.terminalRevision === 'string'
11
+ && typeof value.available === 'boolean' && (value.reasonCode === null || typeof value.reasonCode === 'string')
12
+ && Array.isArray(value.terminals) && value.terminals.length <= 4 && value.terminals.every((tab: unknown) => object(tab)
13
+ && exact(tab, ['terminalRef', 'number', 'state', 'cols', 'rows', 'sequence', 'exitCode'])
14
+ && ref(tab.terminalRef) && Number.isSafeInteger(tab.number) && tab.number > 0 && ['running', 'exited'].includes(tab.state)
15
+ && Number.isSafeInteger(tab.sequence) && tab.sequence >= 0 && Number.isInteger(tab.cols) && tab.cols >= 2 && tab.cols <= 500
16
+ && Number.isInteger(tab.rows) && tab.rows >= 1 && tab.rows <= 200 && (tab.exitCode === null || Number.isInteger(tab.exitCode)));
17
+ if (!ref(value.terminalRef) || !Number.isSafeInteger(value.sequence) || value.sequence < 0) return false;
18
+ if (value.messageType === 'output') return exact(value, [...base, 'terminalRef', 'sequence', 'data', 'replay', 'reset', 'complete', 'truncated'])
19
+ && typeof value.data === 'string' && value.data.length <= 8192
20
+ && ['replay', 'reset', 'complete', 'truncated'].every(key => typeof value[key] === 'boolean');
21
+ return value.messageType === 'exit' && exact(value, [...base, 'terminalRef', 'sequence', 'exitCode', 'signal'])
22
+ && (value.exitCode === null || Number.isInteger(value.exitCode)) && (value.signal === null || Number.isInteger(value.signal));
23
+ }