@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
@@ -0,0 +1,87 @@
1
+ import type { CheckpointCommand, CheckpointReceipt, CheckpointState } from '../contracts/generated/checkpoint-v1.ts';
2
+ import { CHECKPOINT_VERSION, checkpointSha, parseCheckpointCommand, sameCheckpointValue as same } from './checkpoint-command.ts';
3
+
4
+ export type CheckpointFacts = Omit<CheckpointState, 'schemaVersion' | 'version' | 'messageType'> & { writable: boolean };
5
+ export type CheckpointRestoreResult = { result: string; completed: string[]; files: string[]; conflicts?: string[]; stoppedAt?: string | null };
6
+ type Options = {
7
+ facts(): Promise<CheckpointFacts> | CheckpointFacts;
8
+ history(): CheckpointReceipt[];
9
+ append(receipt: CheckpointReceipt): void | Promise<void>;
10
+ restore(target: string, expected: { digest: string; revision: string; tree: string }, identity: CheckpointState['identity']): Promise<CheckpointRestoreResult>;
11
+ now?(): Date;
12
+ };
13
+
14
+ export class CheckpointController {
15
+ #options: Options;
16
+ #tail: Promise<unknown> = Promise.resolve();
17
+ #pending = 0;
18
+ constructor(options: Options) { this.#options = options; }
19
+ async state(): Promise<CheckpointState> {
20
+ const { writable: _writable, ...facts } = await this.#options.facts();
21
+ return { schemaVersion: 1, version: CHECKPOINT_VERSION, messageType: 'state', ...facts };
22
+ }
23
+ execute(value: unknown): Promise<CheckpointReceipt> {
24
+ const command = parseCheckpointCommand(value);
25
+ if (this.#pending >= 128) return Promise.reject(Error('checkpoint-command-capacity'));
26
+ this.#pending++;
27
+ const run = this.#tail.then(() => this.#execute(command));
28
+ this.#tail = run.catch(() => undefined);
29
+ return run.finally(() => { this.#pending--; });
30
+ }
31
+ #now() { return this.#options.now?.() ?? new Date(); }
32
+ #expired(command: CheckpointCommand) {
33
+ const now = this.#now().getTime(), requested = Date.parse(command.requestedAt), expires = Date.parse(command.expiresAt);
34
+ return expires <= now || requested > now + 5000 || expires <= requested || expires - requested > 120000;
35
+ }
36
+ async #execute(command: CheckpointCommand): Promise<CheckpointReceipt> {
37
+ const facts = await this.#options.facts(), key = checkpointSha(command.idempotencyKey);
38
+ const previous = [...this.#options.history()].reverse().find(receipt => receipt.idempotencyKeyDigest === key);
39
+ const receipt: CheckpointReceipt = { schemaVersion: 1, version: CHECKPOINT_VERSION, messageType: 'receipt', commandId: command.commandId,
40
+ idempotencyKeyDigest: key, action: command.action, actionDigest: command.actionDigest, identity: facts.identity,
41
+ phase: 'rejected', resultCode: 'unavailable', requestedAt: command.requestedAt, settledAt: this.#now().toISOString(),
42
+ observedRevisionsBefore: facts.revisions, observedRevisionsAfter: facts.revisions, deduplicated: false,
43
+ targetId: command.payload.targetId, completed: [], files: [], conflicts: [], stoppedAt: null };
44
+ const reject = (resultCode: CheckpointReceipt['resultCode']): CheckpointReceipt => ({ ...receipt, resultCode });
45
+ if (previous && same(command.identity, previous.identity) && facts.identity.projectRef === command.identity.projectRef
46
+ && facts.identity.sessionRef === command.identity.sessionRef) {
47
+ if (previous.actionDigest !== command.actionDigest) return reject('idempotency-payload-mismatch');
48
+ return { ...previous, deduplicated: true };
49
+ }
50
+ if (!same(command.identity, facts.identity)) return reject('identity-mismatch');
51
+ if (previous) return reject('idempotency-payload-mismatch');
52
+ const refusal = (current: CheckpointFacts): CheckpointReceipt['resultCode'] | null => {
53
+ if (!same(command.identity, current.identity)) return 'identity-mismatch';
54
+ if (this.#expired(command)) return 'expired';
55
+ if (!current.available) return 'unavailable';
56
+ if (!current.writable) return 'read-only';
57
+ if (current.agentRunning) return 'busy';
58
+ if (!same(command.expectedRevisions, current.revisions)) return 'stale-revision';
59
+ return null;
60
+ };
61
+ const denied = refusal(facts);
62
+ if (denied) { const result = reject(denied); await this.#options.append(result); return result; }
63
+ // Publication precedes dispatch. Lost completion remains non-replayable.
64
+ const pending: CheckpointReceipt = { ...receipt, phase: 'uncertain', resultCode: 'effect-unknown' };
65
+ await this.#options.append(pending);
66
+ let settled = pending;
67
+ try {
68
+ const deniedNow = refusal(await this.#options.facts());
69
+ if (deniedNow) settled = reject(deniedNow);
70
+ else {
71
+ const result = await this.#options.restore(command.payload.targetId, {
72
+ digest: command.payload.previewDigest, revision: command.expectedRevisions.checkpointRevision, tree: command.payload.expectedTree,
73
+ }, command.identity);
74
+ const after = await this.#options.facts();
75
+ const code = result.result === 'done' ? 'restored' : result.result === 'refused' ? 'conflict'
76
+ : result.result === 'stale' ? 'stale-revision' : result.result === 'busy' ? 'busy' : result.result === 'read-only' ? 'read-only'
77
+ : ['unavailable', 'failed', 'none'].includes(result.result) ? 'unavailable' : 'effect-unknown';
78
+ settled = { ...receipt, phase: code === 'effect-unknown' ? 'uncertain' : result.completed.length || code === 'restored' ? 'settled' : 'rejected',
79
+ resultCode: code, observedRevisionsAfter: after.revisions, completed: result.completed,
80
+ files: result.files, conflicts: result.conflicts ?? [], stoppedAt: result.stoppedAt ?? null };
81
+ }
82
+ } catch { settled = pending; }
83
+ settled = { ...settled, settledAt: this.#now().toISOString() };
84
+ try { await this.#options.append(settled); } catch { return pending; }
85
+ return settled;
86
+ }
87
+ }
@@ -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 CheckpointRoutes = {
6
+ state(sessionRef: string): unknown | Promise<unknown>;
7
+ preview(sessionRef: string, target: string): unknown | Promise<unknown>;
8
+ execute(sessionRef: string, command: unknown): unknown | Promise<unknown>;
9
+ };
10
+ type Context = { request: IncomingMessage; response: ServerResponse; url: URL; origin: string;
11
+ requestOrigin: string | undefined; auth: SessionAuthority; now: number; consumeControl(id: string, now: number): boolean };
12
+ export async function routeCheckpoint(context: Context, handler?: CheckpointRoutes): Promise<boolean> {
13
+ const { request, response, url, auth, requestOrigin, origin, now, consumeControl } = context;
14
+ const match = /^\/api\/v1\/sessions\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159})\/checkpoints(?:\/([a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}))?$/.exec(url.pathname);
15
+ if (!match) return false;
16
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
17
+ if (url.search) { errorResponse(response, 400, "invalid-checkpoint-query"); return true; }
18
+ if (!["GET", "POST"].includes(request.method ?? "") || match[2] && request.method !== "GET") { errorResponse(response, 405, "method-not-allowed"); return true; }
19
+ let command: unknown;
20
+ if (request.method === "POST") {
21
+ if (requestOrigin !== origin) { errorResponse(response, 403, "origin-required"); return true; }
22
+ const session = auth.authorizeMutation(request);
23
+ if (!session) { errorResponse(response, 403, "mutation-authority-rejected"); return true; }
24
+ if (!consumeControl(session.id, now)) { errorResponse(response, 429, "control-rate-limit"); return true; }
25
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
26
+ errorResponse(response, 415, "content-type"); return true;
27
+ }
28
+ try { command = JSON.parse((await requestBody(request, 8192)).toString("utf8")); }
29
+ catch (error) { errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-checkpoint-command"); return true; }
30
+ if ((command as { identity?: { sessionRef?: unknown } } | null)?.identity?.sessionRef !== match[1]) {
31
+ errorResponse(response, 400, "checkpoint-session-mismatch"); return true;
32
+ }
33
+ }
34
+ if (!handler) { errorResponse(response, 503, "checkpoint-unavailable"); return true; }
35
+ try { jsonResponse(response, 200, request.method === "GET" ? (match[2] ? await handler.preview(match[1], match[2]) : await handler.state(match[1])) : await handler.execute(match[1], command)); }
36
+ catch (error) { errorResponse(response, (error as Error).message === "invalid-checkpoint-command" ? 400 : 503, "checkpoint-unavailable"); }
37
+ return true;
38
+ }
@@ -0,0 +1,5 @@
1
+ import { reviewDigest } from "../../piagent-core/runtime/inspection/review-state-contract.ts";
2
+
3
+ export function controlActionDigest(command: { action: unknown; identity: unknown; expectedRevisions: unknown; payload: unknown }): string {
4
+ return reviewDigest({ action: command.action, identity: command.identity, expectedRevisions: command.expectedRevisions, payload: command.payload });
5
+ }
@@ -8,23 +8,7 @@ import { readTerminalDeliveryReceipt, terminalDeliverySessionEntries } from "./t
8
8
 
9
9
  const CORE_ROOT = "../../piagent-core";
10
10
  const SOURCE_AUTHORITY_ALIGNED = Symbol("piagent-source-authority-aligned");
11
- const STABLE_IDENTITY_FIELDS = ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId"] as const;
12
-
13
- function stableIdentityMatches(left: Record<string, any> | null | undefined, right: Record<string, any> | null | undefined): boolean {
14
- return Boolean(left && right && STABLE_IDENTITY_FIELDS.every((field) => left[field] === right[field]));
15
- }
16
-
17
- function sourceBinding(snapshot: Record<string, any>) {
18
- return { sourceProjectionRevision: snapshot.sourceChanges.projectionRevision,
19
- taskViewRevision: snapshot.sourceChanges.task.revision, workspaceRevision: snapshot.sourceChanges.workingTree.revision,
20
- indexRevision: snapshot.sourceChanges.staged.revision };
21
- }
22
-
23
- function sourceDocumentMatches(document: Record<string, any> | null | undefined, snapshot: Record<string, any>): boolean {
24
- const binding = sourceBinding(snapshot);
25
- return Boolean(document && stableIdentityMatches(document.identity, snapshot.identity)
26
- && Object.entries(binding).every(([field, value]) => document.snapshotBinding?.[field] === value));
27
- }
11
+ import { STABLE_IDENTITY_FIELDS, stableIdentityMatches, sourceBinding, sourceDocumentMatches } from "./source-projection-binding.ts";
28
12
 
29
13
  type RuntimeEvent = { eventCursor: string; [key: string]: unknown };
30
14
  type EventReplay = {
@@ -42,8 +26,8 @@ type EventStore = {
42
26
  };
43
27
 
44
28
  async function loadCoreInspection() {
45
- const [{ collectFileDiff }, { buildWebUiInspectionProjection }, { matchesProtectedPath }, reviewProjection, reviewStore, mutationProjection, revertProjection, openProjection, commitSummaryProjection, taskIndexProjection, taskTimelineProjection, recoveryHistoryProjection, handoffHistoryProjection, subagentTreeProjection, releaseMonitorProjection, documentWorkspaceProjection] = await Promise.all([
46
- import(`${CORE_ROOT}/runtime/inspection/diff-projection.ts`),
29
+ const [{ collectSessionFileDiff: collectFileDiff }, { buildWebUiInspectionProjection }, { matchesProtectedPath }, reviewProjection, reviewStore, mutationProjection, revertProjection, openProjection, commitSummaryProjection, taskIndexProjection, taskTimelineProjection, recoveryHistoryProjection, handoffHistoryProjection, subagentTreeProjection, releaseMonitorProjection, documentWorkspaceProjection] = await Promise.all([
30
+ import(`${CORE_ROOT}/runtime/inspection/session-diff-projection.ts`),
47
31
  import(`${CORE_ROOT}/runtime/inspection/webui-snapshot.ts`),
48
32
  import(`${CORE_ROOT}/extensions/policy-core.js`),
49
33
  import(`${CORE_ROOT}/runtime/inspection/review-state-projection.ts`),
@@ -87,6 +71,8 @@ export type CoreInspectionInput = {
87
71
  approvalProjection?: () => { revision: string | null; summary: Record<string, unknown> };
88
72
  approvalDetail?: (approvalRef: string) => unknown | null;
89
73
  sourceMutationGuardAvailable?: () => boolean;
74
+ sourceSessionControlRevision?: () => string | null;
75
+ sourceSessionObservation?: (path: string) => unknown;
90
76
  sourceOpenAvailable?: () => boolean;
91
77
  lifecycleControl?: () => { state: "active" | "pause-requested" | "paused" | "terminal" | "unknown"; controlRevision: string | null;
92
78
  dispatchBlocked: boolean; currentPhase: string; pauseEpoch: number; actions: { stop: boolean; pause: boolean; resume: boolean } };
@@ -218,6 +204,9 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
218
204
  } };
219
205
  }
220
206
  }
207
+ if (value.snapshot.identity.taskId === null && value.snapshot.identity.taskRunId === null && this.#input.sourceSessionControlRevision) {
208
+ value.snapshot.revision.controlRevision = this.#input.sourceSessionControlRevision();
209
+ }
221
210
  projectApprovalState(value.snapshot, this.#input.approvalProjection?.());
222
211
  if (sourceIdentityAligned) {
223
212
  const binding = sourceBinding(value.snapshot);
@@ -312,7 +301,7 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
312
301
  const { collectFileDiff, matchesProtectedPath } = await loadCoreInspection();
313
302
  const protectedPaths = this.#input.protectedPaths?.() ?? [];
314
303
  const value = await collectFileDiff({
315
- cwd: this.#input.cwd, identity, sourceView: source, fileRef,
304
+ cwd: this.#input.cwd, identity, sourceView: source, fileRef, sessionObservation: this.#input.sourceSessionObservation,
316
305
  precondition: { expectedViewRevision: source.viewRevision, expectedFileRevision: file.fileRevision,
317
306
  expectedBaseDigest: file.baseDigest ?? null, expectedCurrentDigest: file.currentDigest ?? null },
318
307
  taskRevision: projection.snapshot.revision.taskRevision,
@@ -379,14 +368,19 @@ export class CoreInspectionProvider implements WebUiReadModelProvider {
379
368
  const projection = await this.#projection(), snapshot = projection.snapshot;
380
369
  this.#requireSourceAuthority(projection, ["working-tree", ...(projection.sourceViews.task ? ["task" as const] : [])]);
381
370
  const identity = { ...snapshot.identity, agentOperationId: null, toolCallId: null };
382
- if (!identity.taskId || !identity.taskRunId || !snapshot.revision.taskRevision || !snapshot.revision.workspaceRevision || !snapshot.revision.indexRevision)
371
+ const sessionBound = identity.taskId === null && identity.taskRunId === null && snapshot.revision.taskRevision === null
372
+ && !!this.#input.sourceSessionObservation;
373
+ if ((!sessionBound && (!identity.taskId || !identity.taskRunId || !snapshot.revision.taskRevision))
374
+ || !snapshot.revision.workspaceRevision || !snapshot.revision.indexRevision)
383
375
  throw new ReadModelNotFound();
384
376
  const source = projection.sourceViews.workingTree, taskView = projection.sourceViews.task ?? null;
385
- if (!source?.files.some((file: any) => file.fileRef === fileRef)) throw new ReadModelNotFound();
377
+ const file = source?.files.find((file: any) => file.fileRef === fileRef);
378
+ if (!file) throw new ReadModelNotFound();
386
379
  const diff = await this.diff("working-tree", fileRef) as any;
387
380
  const { collectSourceRevertPreview, matchesProtectedPath } = await loadCoreInspection();
388
381
  const protectedPaths = this.#input.protectedPaths?.() ?? [];
389
382
  return collectSourceRevertPreview({ cwd: this.#input.cwd, identity, sourceView: source, taskView, diff,
383
+ sessionObservation: sessionBound && file.pathDisplay === "exact-safe" ? this.#input.sourceSessionObservation?.(file.path) : undefined,
390
384
  taskRevision: snapshot.revision.taskRevision, workspaceRevision: snapshot.revision.workspaceRevision,
391
385
  indexRevision: snapshot.revision.indexRevision, selectedHunkRefs: hunkRef ? [hunkRef] : [],
392
386
  confirmationKey: this.#sourceRevertConfirmationKey,
@@ -1,3 +1,4 @@
1
+ import { TerminalWebSocketStream, type TerminalStreamPort } from "./terminal-websocket-stream.ts";
1
2
  import type { IncomingMessage, Server } from "node:http";
2
3
  import { WebSocket, WebSocketServer } from "ws";
3
4
 
@@ -5,7 +6,7 @@ import type { SessionAuthority } from "./session-auth.ts";
5
6
 
6
7
  const FRAME_LIMIT = 70_000;
7
8
  const CLIENT_LIMIT = 8;
8
- const BACKPRESSURE_LIMIT = 512 * 1024;
9
+ const BACKPRESSURE_LIMIT = 512 * 1024, TERMINAL_BACKLOG = 128 * 1024;
9
10
  const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
10
11
  const REQUEST_ID = /^[A-Za-z0-9_-]{16,96}$/;
11
12
 
@@ -13,9 +14,10 @@ type GatewayProtocolEvent = {
13
14
  sequence: number;
14
15
  [key: string]: unknown;
15
16
  };
16
- export type GatewayRequest = { requestId: string; method: "gateway.health" | "sessions.list" | "sessions.get" | "sessions.command"; params: Record<string, unknown> };
17
+ export type GatewayRequest = { requestId: string; method: "gateway.health" | "sessions.list" | "sessions.get" | "sessions.command" | "terminals.state" | "terminals.command"; params: Record<string, unknown> };
17
18
  export type GatewayProtocolHandler = {
18
19
  capabilities(): unknown;
20
+ terminalEvents?: TerminalStreamPort;
19
21
  execute(request: GatewayRequest): Promise<unknown>;
20
22
  events: {
21
23
  readonly stateVersion: number;
@@ -44,12 +46,18 @@ function parseConnect(value: unknown): { lastEventSequence: number | null } | nu
44
46
  function parseRequest(value: unknown): GatewayRequest | null {
45
47
  if (!record(value) || !exactKeys(value, ["schemaVersion", "version", "messageType", "requestId", "method", "params"]) || value.schemaVersion !== 1
46
48
  || value.version !== "piagent-gateway-protocol-v1" || value.messageType !== "request" || typeof value.requestId !== "string"
47
- || !REQUEST_ID.test(value.requestId) || !["gateway.health", "sessions.list", "sessions.get", "sessions.command"].includes(String(value.method))
49
+ || !REQUEST_ID.test(value.requestId) || !["gateway.health", "sessions.list", "sessions.get", "sessions.command", "terminals.state", "terminals.command"].includes(String(value.method))
48
50
  || !record(value.params)) return null;
49
51
  const params = value.params;
50
52
  if (value.method === "gateway.health" && !exactKeys(params, [])) return null;
51
53
  if (value.method === "sessions.get" && (!exactKeys(params, ["sessionRef"]) || typeof params.sessionRef !== "string" || !REF.test(params.sessionRef))) return null;
52
54
  if (value.method === "sessions.command" && (!exactKeys(params, ["command"]) || !record(params.command))) return null;
55
+ if (value.method === "terminals.state" || value.method === "terminals.command") {
56
+ if (typeof params.csrfToken !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(params.csrfToken)) return null;
57
+ if (value.method === "terminals.state" && (!exactKeys(params, ["sessionRef", "csrfToken"])
58
+ || typeof params.sessionRef !== "string" || !REF.test(params.sessionRef))) return null;
59
+ if (value.method === "terminals.command" && (!exactKeys(params, ["command", "csrfToken"]) || !record(params.command))) return null;
60
+ }
53
61
  if (value.method === "sessions.list") {
54
62
  if (!exactKeys(params, ["cursor", "limit", "filter", "query", "projectRef"]) || !nullableRef(params.cursor) || !nullableRef(params.projectRef)
55
63
  || !Number.isInteger(params.limit) || Number(params.limit) < 1 || Number(params.limit) > 200
@@ -100,12 +108,22 @@ export function attachGatewayWebSocket(options: {
100
108
  webSockets.handleUpgrade(request, socket, head, (client) => webSockets.emit("connection", client, request));
101
109
  };
102
110
  options.server.on("upgrade", upgrade);
103
- webSockets.on("connection", (socket) => {
111
+ webSockets.on("connection", (socket, upgradeRequest: IncomingMessage) => {
112
+ const terminalStream = options.protocol.terminalEvents ? new TerminalWebSocketStream({ port: options.protocol.terminalEvents,
113
+ authorized: csrf => Boolean(options.authority.authorizeMutationToken(upgradeRequest, csrf)),
114
+ // Live terminal output yields to the conversation: while the socket is
115
+ // behind it is skipped (the terminal keeps its replay buffer) rather
116
+ // than closing the socket the chat also uses.
117
+ send: message => { if (message.messageType === "output" && !message.replay && socket.bufferedAmount > TERMINAL_BACKLOG) return; send(socket, message); },
118
+ close: reason => socket.close(1008, reason) }) : null;
119
+ let queued = 0;
104
120
  sockets.add(socket); let connected = false, unsubscribe: (() => void) | null = null, chain = Promise.resolve();
105
121
  const timeout = setTimeout(() => socket.close(1008, "connect-timeout"), 5_000);
106
122
  socket.on("message", (data, binary) => {
107
123
  if (binary) { socket.close(1003, "text-only"); return; }
124
+ if (++queued > 128) { socket.close(1013, "request-backpressure"); return; }
108
125
  chain = chain.then(async () => {
126
+ if (socket.readyState !== WebSocket.OPEN) return;
109
127
  let value: unknown;
110
128
  try { value = JSON.parse(data.toString()); } catch { socket.close(1007, "invalid-json"); return; }
111
129
  if (!connected) {
@@ -128,11 +146,21 @@ export function attachGatewayWebSocket(options: {
128
146
  }
129
147
  const request = parseRequest(value);
130
148
  if (!request) { socket.close(1008, "invalid-request"); return; }
131
- try { send(socket, response(request, options.protocol.events.stateVersion, await options.protocol.execute(request))); }
132
- catch (error) { send(socket, errorResponse(request, options.protocol.events.stateVersion, error)); }
133
- }).catch(() => socket.close(1011, "gateway-failure"));
149
+ let replay: ReturnType<TerminalWebSocketStream["begin"]> | undefined;
150
+ try {
151
+ if (request.method.startsWith("terminals.") && !options.authority.authorizeMutationToken(upgradeRequest, request.params.csrfToken))
152
+ throw Object.assign(new Error("terminal-csrf-required"), { code: "terminal-csrf-required" });
153
+ if (request.method === "terminals.state") replay = terminalStream?.begin(String(request.params.sessionRef), String(request.params.csrfToken));
154
+ const result = await options.protocol.execute(request);
155
+ if (request.method.startsWith("terminals.") && !options.authority.authorizeMutationToken(upgradeRequest, request.params.csrfToken))
156
+ throw Object.assign(new Error("terminal-csrf-required"), { code: "terminal-csrf-required" });
157
+ const reply = () => send(socket, response(request, options.protocol.events.stateVersion, result));
158
+ if (replay) replay.finish(reply); else reply();
159
+ }
160
+ catch (error) { replay?.abort(); send(socket, errorResponse(request, options.protocol.events.stateVersion, error)); }
161
+ }).catch(() => socket.close(1011, "gateway-failure")).finally(() => { queued--; });
134
162
  });
135
- socket.once("close", () => { clearTimeout(timeout); unsubscribe?.(); sockets.delete(socket); });
163
+ socket.once("close", () => { clearTimeout(timeout); unsubscribe?.(); terminalStream?.stop(); sockets.delete(socket); });
136
164
  socket.once("error", () => socket.terminate());
137
165
  });
138
166
  return { close: async () => {
@@ -1,8 +1,11 @@
1
+ import { routeSessionSourceRevert, type SessionSourceRevertRoutes } from "./session-source-revert-routes.ts";
1
2
  import http, { type ServerResponse } from "node:http";
2
3
  import { randomBytes } from "node:crypto";
3
4
 
4
5
  import { errorResponse, applySecurityHeaders, jsonResponse, requestBody } from "./http-security.ts";
5
6
  import { routeMemberRequest, type UpdateRoutes } from "./member-routes.ts";
7
+ import { routeWorkspaceWrite, type WorkspaceWriteRoutes } from "./workspace-write-routes.ts";
8
+ import { routeCheckpoint, type CheckpointRoutes } from "./checkpoint-routes.ts";
6
9
  import { ReadModelNotFound, type WebUiReadModelProvider } from "./read-model-provider.ts";
7
10
  import { routeReadOnlyRequest } from "./read-only-router.ts";
8
11
  import { SessionAuthority } from "./session-auth.ts";
@@ -49,6 +52,9 @@ export async function startLoopbackServer(options: {
49
52
  readSessionLiveState?: () => unknown | Promise<unknown>;
50
53
  readSessionCreationOptions?: () => unknown | Promise<unknown>;
51
54
  readSessionModel?: (sessionRef: string) => WebUiReadModelProvider | Promise<WebUiReadModelProvider>;
55
+ workspaceWrite?: WorkspaceWriteRoutes;
56
+ checkpoints?: CheckpointRoutes;
57
+ sessionSourceRevert?: SessionSourceRevertRoutes;
52
58
  executeSessionAttachment?: (sessionRef: string, command: unknown) => unknown | Promise<unknown>;
53
59
  readSessionConnections?: (sessionRef: string) => unknown | Promise<unknown>;
54
60
  executeSessionConnection?: (command: unknown) => unknown | Promise<unknown>;
@@ -303,6 +309,9 @@ export async function startLoopbackServer(options: {
303
309
 
304
310
  // The main agent's questions to the member, a project's Git branches, and
305
311
  // Piagent's own updates.
312
+ if (await routeSessionSourceRevert({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options.sessionSourceRevert)) return;
313
+ if (await routeWorkspaceWrite({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options.workspaceWrite)) return;
314
+ if (await routeCheckpoint({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options.checkpoints)) return;
306
315
  if (await routeMemberRequest({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options)) return;
307
316
 
308
317
  if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
@@ -1,3 +1,5 @@
1
+ import type { WorkspaceRead } from "./workspace-read-routes.ts";
2
+
1
3
  export type SourceView = "task" | "working-tree" | "staged";
2
4
  export type StreamEvent = { cursor: string; value: unknown };
3
5
  export type StreamReplay = {
@@ -9,6 +11,7 @@ export type StreamReplay = {
9
11
  };
10
12
 
11
13
  export type WebUiReadModelProvider = {
14
+ workspace?: WorkspaceRead;
12
15
  snapshot(): unknown | Promise<unknown>;
13
16
  sourceChanges(view: SourceView): unknown | Promise<unknown>;
14
17
  diff(view: SourceView, fileRef: string): unknown | Promise<unknown>;
@@ -1,3 +1,4 @@
1
+ import { routeWorkspaceRead } from "./workspace-read-routes.ts";
1
2
  import type { WebUiReadModelProvider, SourceView } from "./read-model-provider.ts";
2
3
 
3
4
  const OPAQUE_REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
@@ -5,6 +6,8 @@ const OPAQUE_REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
5
6
  export type ReadRouteResult = { handled: false } | { handled: true; status: number; value: unknown };
6
7
 
7
8
  export async function routeReadOnlyRequest(url: URL, provider: WebUiReadModelProvider): Promise<ReadRouteResult> {
9
+ const workspace = await routeWorkspaceRead(url, provider.workspace?.bind(provider));
10
+ if (workspace.handled) return workspace;
8
11
  if (url.pathname === "/api/v1/snapshot" && !url.search) return { handled: true, status: 200, value: await provider.snapshot() };
9
12
  if (url.pathname === "/api/v1/activity" && !url.search) return { handled: true, status: 200, value: await provider.activity() };
10
13
  if (url.pathname === "/api/v1/tasks" && !url.search) return { handled: true, status: 200, value: await provider.taskIndex() };
@@ -93,8 +93,11 @@ export class SessionAuthority {
93
93
  }
94
94
 
95
95
  authorizeMutation(request: IncomingMessage, now = Date.now()): BrowserSession | null {
96
+ return this.authorizeMutationToken(request, request.headers["x-piagent-csrf"], now);
97
+ }
98
+
99
+ authorizeMutationToken(request: IncomingMessage, csrf: unknown, now = Date.now()): BrowserSession | null {
96
100
  const session = this.authenticate(request, now);
97
- const csrf = request.headers["x-piagent-csrf"];
98
101
  return session && typeof csrf === "string" && equal(csrf, session.csrf) ? session : null;
99
102
  }
100
103
 
@@ -0,0 +1,54 @@
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 SessionSourceRevertRoutes = {
6
+ sources?(sessionRef: string): unknown | Promise<unknown>;
7
+ diff?(sessionRef: string, fileRef: string): unknown | Promise<unknown>;
8
+ preview(sessionRef: string, fileRef: string, hunkRef: string | null): unknown | Promise<unknown>;
9
+ execute(sessionRef: string, command: unknown): unknown | Promise<unknown>;
10
+ };
11
+ type Context = { request: IncomingMessage; response: ServerResponse; url: URL; origin: string;
12
+ requestOrigin: string | undefined; auth: SessionAuthority; now: number; consumeControl(id: string, now: number): boolean };
13
+ const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
14
+ export async function routeSessionSourceRevert(context: Context, handler?: SessionSourceRevertRoutes): Promise<boolean> {
15
+ const { request, response, url, auth, requestOrigin, origin, now, consumeControl } = context;
16
+ const review = /^\/api\/v1\/sessions\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159})\/source-review(?:\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159}))?$/.exec(url.pathname);
17
+ if (review) {
18
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
19
+ if (request.method !== "GET") { errorResponse(response, 405, "method-not-allowed"); return true; }
20
+ if (url.search) { errorResponse(response, 400, "invalid-source-review-query"); return true; }
21
+ try {
22
+ const read = review[2] ? handler?.diff : handler?.sources;
23
+ if (!read) throw new Error("unavailable");
24
+ jsonResponse(response, 200, await read(review[1], review[2]));
25
+ } catch { errorResponse(response, 503, "source-review-unavailable"); }
26
+ return true;
27
+ }
28
+ const match = /^\/api\/v1\/sessions\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159})\/source-reverts(?:\/([A-Za-z0-9][A-Za-z0-9._~-]{0,159}))?$/.exec(url.pathname);
29
+ if (!match) return false;
30
+ if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
31
+ const reading = request.method === "GET" && !!match[2], writing = request.method === "POST" && !match[2];
32
+ if (!reading && !writing) { errorResponse(response, 405, "method-not-allowed"); return true; }
33
+ const hunkRef = url.searchParams.get("hunkRef");
34
+ if (writing && url.search || reading && ([...url.searchParams.keys()].some(key => key !== "hunkRef")
35
+ || url.searchParams.getAll("hunkRef").length > 1 || hunkRef !== null && !REF.test(hunkRef))) {
36
+ errorResponse(response, 400, "invalid-source-revert-query"); return true;
37
+ }
38
+ let command: unknown;
39
+ if (writing) {
40
+ if (requestOrigin !== origin) { errorResponse(response, 403, "origin-required"); return true; }
41
+ const session = auth.authorizeMutation(request);
42
+ if (!session) { errorResponse(response, 403, "mutation-authority-rejected"); return true; }
43
+ if (!consumeControl(session.id, now)) { errorResponse(response, 429, "control-rate-limit"); return true; }
44
+ if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
45
+ errorResponse(response, 415, "content-type"); return true;
46
+ }
47
+ try { command = JSON.parse((await requestBody(request, 70_000)).toString("utf8")); }
48
+ catch (error) { errorResponse(response, (error as Error).message === "body-limit" ? 413 : 400, "invalid-source-revert-command"); return true; }
49
+ }
50
+ if (!handler) { errorResponse(response, 503, "source-revert-unavailable"); return true; }
51
+ try { jsonResponse(response, 200, reading ? await handler.preview(match[1], match[2], hunkRef) : await handler.execute(match[1], command)); }
52
+ catch { errorResponse(response, 503, "source-revert-unavailable"); }
53
+ return true;
54
+ }
@@ -0,0 +1,18 @@
1
+ export const STABLE_IDENTITY_FIELDS = ["projectRef", "runtimeInstanceId", "sessionRef", "taskId", "taskRunId"] as const;
2
+
3
+ export function stableIdentityMatches(left: Record<string, any> | null | undefined, right: Record<string, any> | null | undefined): boolean {
4
+ return Boolean(left && right && STABLE_IDENTITY_FIELDS.every((field) => left[field] === right[field]));
5
+ }
6
+
7
+ export function sourceBinding(snapshot: Record<string, any>) {
8
+ return { sourceProjectionRevision: snapshot.sourceChanges.projectionRevision,
9
+ taskViewRevision: snapshot.sourceChanges.task.revision, workspaceRevision: snapshot.sourceChanges.workingTree.revision,
10
+ indexRevision: snapshot.sourceChanges.staged.revision };
11
+ }
12
+
13
+ export function sourceDocumentMatches(document: Record<string, any> | null | undefined, snapshot: Record<string, any>): boolean {
14
+ const binding = sourceBinding(snapshot);
15
+ return Boolean(document && stableIdentityMatches(document.identity, snapshot.identity)
16
+ && Object.entries(binding).every(([field, value]) => document.snapshotBinding?.[field] === value));
17
+ }
18
+