@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,167 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { checkpointDigest, previewCheckpointRestore } from './restore-preview.mjs';
3
+
4
+ // Another owner holds this session's history (a second runtime mid-turn, a
5
+ // replaced runtime): the change waits rather than racing it. Any other failure
6
+ // only costs the turn its checkpoint.
7
+ export const checkpointOwnershipError = error =>
8
+ /checkpoint-(?:turn-owned|store-busy|lock-changed|authority-changed|session-capacity|command-capacity)/.test(String(error?.message ?? error));
9
+
10
+ // Shared turn history and drift refusal; Git execution is supplied by an adapter.
11
+ export class TurnCheckpointService {
12
+ #stack = [];
13
+ #tail = Promise.resolve();
14
+ #pending = 0;
15
+ #gap = false;
16
+ constructor(git, { busy = /** @returns {boolean} */ () => false, history = null,
17
+ memberEdits = /** @param {number} _since @returns {Set<string>} */ _since => new Set() } = {}) {
18
+ this.git = git; this.busy = busy; this.history = history; this.memberEdits = memberEdits;
19
+ this.#stack = history?.load() ?? [];
20
+ }
21
+ /** @template T @param {() => Promise<T>} action @returns {Promise<T>} */
22
+ #serial(action) {
23
+ if (this.#pending >= 128) return Promise.reject(Error('checkpoint-command-capacity'));
24
+ this.#pending++;
25
+ const locked = async () => {
26
+ // Non-Git projects remain side-effect-free, including checkpoint metadata.
27
+ if (!this.history || this.git.available && !await this.git.available()) return await action();
28
+ return await this.history.operation(async () => {
29
+ // A turn that ran without a checkpoint (the store refused it) ends the
30
+ // history it would otherwise sit inside.
31
+ if (this.#gap) { this.#gap = false; await this.#barrier(null); }
32
+ return await action();
33
+ });
34
+ };
35
+ const operation = this.#tail.then(locked, locked);
36
+ this.#tail = operation.then(() => {}, () => {});
37
+ return operation.finally(() => { this.#pending--; });
38
+ }
39
+ async close() { await this.history?.close(); }
40
+ revoke() { this.history?.revoke(); }
41
+ // A change is about to happen without a checkpoint for its turn.
42
+ markGap() { this.#stack = []; this.#gap = true; }
43
+ take(run) {
44
+ return this.#serial(() => this.#take(run)).catch(error => {
45
+ if (run && run.checkpoint === undefined && !checkpointOwnershipError(error)) { run.checkpoint = null; run.checkpointError = error; this.markGap(); }
46
+ throw error;
47
+ });
48
+ }
49
+ finish(run) { return this.#serial(() => this.#finish(run)); }
50
+ undo() { return this.#serial(() => this.#undo()); }
51
+ list() { return this.#serial(async () => {
52
+ this.history?.assertCurrent();
53
+ return { revision: checkpointDigest(this.#stack), available: this.git.available ? await this.git.available() : true,
54
+ points: this.#stack.map(({ id, turnRef, at }) => ({ id, turnRef, at })) };
55
+ }); }
56
+ preview(targetId) { return this.#serial(() => this.#preview(targetId)); }
57
+ async #preview(targetId) {
58
+ this.history?.assertCurrent();
59
+ const preview = await previewCheckpointRestore(this.git, this.#stack, targetId, this.busy(), this.memberEdits);
60
+ this.history?.assertCurrent();
61
+ return preview;
62
+ }
63
+ restoreTo(targetId, expected, guard = undefined) { return this.#serial(async () => {
64
+ guard?.();
65
+ const preview = await this.#preview(targetId);
66
+ const completed = [], files = new Set();
67
+ if (!expected?.digest || !preview.digest || expected.digest !== preview.digest
68
+ || expected.revision !== preview.revision || expected.tree !== preview.tree)
69
+ return { result: preview.result === 'busy' ? 'busy' : 'stale', completed, files: [] };
70
+ // Every step uses the same drift/policy guard as /undo and publishes its
71
+ // shortened stack. A later refusal never conceals already completed turns.
72
+ for (const step of preview.steps) {
73
+ try {
74
+ if (this.#stack.at(-1)?.id !== step.id) return { result: 'stale', completed, files: [...files] };
75
+ const undo = await this.#undo(guard);
76
+ // `files`: what this restore changed; `conflicts`: why it stopped.
77
+ if (undo.result !== 'done') return { ...undo, completed, files: [...files].sort(), conflicts: undo.result === 'refused' ? undo.files : [], stoppedAt: step.id };
78
+ completed.push(step.id);
79
+ for (const file of [...step.restore, ...step.remove]) files.add(file);
80
+ } catch (error) {
81
+ // Refused before a file of this step changed: a definite answer.
82
+ const reason = String(error?.message ?? error), definite = !/checkpoint-partial/.test(reason);
83
+ const named = /checkpoint-content-changed: (.+)$/.exec(reason)?.[1]?.split(', ') ?? [];
84
+ if (definite && /checkpoint-content-changed/.test(reason)) return { result: 'refused', completed, files: [...files], conflicts: named, stoppedAt: step.id };
85
+ if (definite && /checkpoint-(?:read-only|path-protected)/.test(reason)) return { result: 'read-only', completed, files: [...files], stoppedAt: step.id, reason };
86
+ if (definite && /^(?:Error: )?checkpoint-/.test(reason)) return { result: 'unavailable', completed, files: [...files], stoppedAt: step.id, reason };
87
+ return { result: 'uncertain', completed, files: [...files], stoppedAt: step.id, reason };
88
+ }
89
+ }
90
+ return { result: preview.conflicts.length ? 'refused' : 'done', completed, files: [...files].sort(),
91
+ conflicts: preview.conflicts, stoppedAt: preview.stoppedAt };
92
+ }); }
93
+ // Before the first change of a run; once per run. A snapshot that fails
94
+ // never stops the agent's edit: the turn just has no checkpoint, and older
95
+ // checkpoints are dropped with it, since undoing past an unrecorded turn
96
+ // would leave its changes in place while claiming an earlier state.
97
+ async #take(run) {
98
+ if (!run || run.checkpoint !== undefined) return;
99
+ run.checkpoint = null;
100
+ let point = null;
101
+ try {
102
+ const before = await this.git.tree();
103
+ if (before) {
104
+ point = { id: randomUUID(), turnRef: run.turnRef ?? randomUUID(), before, after: null, at: Date.now() };
105
+ await this.history?.begin(point);
106
+ run.checkpoint = point;
107
+ }
108
+ } catch (error) {
109
+ // Another owner: the edit waits and this turn tries again, history intact.
110
+ if (checkpointOwnershipError(error)) { run.checkpoint = undefined; throw error; }
111
+ run.checkpointError = error;
112
+ await this.#barrier(point);
113
+ }
114
+ }
115
+ async #barrier(point) {
116
+ this.#stack = [];
117
+ if (!this.history) return;
118
+ try { if (point) await this.history.abandon(point); } catch { /* the capture is retired on the next operation */ }
119
+ try { this.#stack = await this.history.save([]); } catch { /* a stale store refuses later restores itself */ }
120
+ }
121
+ // When the run ends: the tree after it, kept when the run changed something.
122
+ async #finish(run) {
123
+ const point = run?.checkpoint;
124
+ if (!point || run.checkpointFinished) return;
125
+ try { point.after = await this.git.tree(); }
126
+ catch (error) { run.checkpointError = error; run.checkpointFinished = true; await this.#barrier(point); return; }
127
+ if (point.after && point.after !== point.before) {
128
+ const next = [...this.#stack, point];
129
+ this.#stack = this.history ? await this.history.save(next) : next.slice(-64);
130
+ run.checkpointFinished = true;
131
+ } else if (point.after) {
132
+ await this.history?.abandon(point); run.checkpointFinished = true;
133
+ }
134
+ }
135
+ // Puts back what the last turn changed: { result, restored, removed, files }
136
+ // for the WebUI note (phase "undo") and `text`, the sentence for the member.
137
+ async #undo(guard = undefined) {
138
+ guard?.();
139
+ const answer = (undo, text) => ({ ...undo, text });
140
+ if (this.busy()) return answer({ result: 'busy', restored: 0, removed: 0, files: [] }, 'Đang có một lượt chạy: chờ nó xong (hoặc dừng nó) rồi /undo.');
141
+ const point = this.#stack.at(-1);
142
+ if (!point) return answer({ result: 'none', restored: 0, removed: 0, files: [] }, 'Không có lượt nào để hoàn tác trong cuộc trò chuyện này (chỉ áp dụng cho project là git repository; checkpoint có từ khi mở cuộc trò chuyện).');
143
+ await this.history?.prepare();
144
+ const now = await this.git.tree();
145
+ if (!now) return answer({ result: 'failed', restored: 0, removed: 0, files: [] }, 'Không đọc được trạng thái hiện tại của project; không hoàn tác.');
146
+ const since = new Set((await this.git.changed(point.after, now)).map(f => f.path));
147
+ // A file already back as it was before the turn (an undo that stopped
148
+ // midway) is done, not a conflict: only the rest is put back.
149
+ const differs = new Set((await this.git.changed(point.before, now)).map(f => f.path));
150
+ const turn = (await this.git.changed(point.before, point.after)).filter(f => differs.has(f.path));
151
+ // A file the member saved after the turn began is theirs, even when the
152
+ // turn's snapshots captured it: refuse rather than put it back.
153
+ const edited = this.memberEdits(point.at);
154
+ const touched = turn.filter(f => since.has(f.path) || edited.has(f.path)).map(f => f.path);
155
+ if (touched.length) return answer({ result: 'refused', restored: 0, removed: 0, files: touched.slice(0, 5) },
156
+ `Không hoàn tác: ${touched.length} file lượt trước đã sửa lại thay đổi sau đó (${touched.slice(0, 5).join(', ')}${touched.length > 5 ? ', …' : ''}). Hoàn tác sẽ ghi đè thay đổi đó.`);
157
+ const restore = turn.filter(f => f.status !== 'A').map(f => f.path), remove = turn.filter(f => f.status === 'A').map(f => f.path);
158
+ if (this.busy()) return answer({ result: 'busy', restored: 0, removed: 0, files: [] }, 'Đang có một lượt chạy: chờ nó xong (hoặc dừng nó) rồi /undo.');
159
+ this.history?.assertCurrent();
160
+ guard?.();
161
+ if (restore.length || remove.length) await this.git.restore(point.before, restore, remove, now, guard);
162
+ const next = this.#stack.slice(0, -1);
163
+ this.#stack = this.history ? await this.history.save(next) : next;
164
+ return answer({ result: 'done', restored: restore.length, removed: remove.length, files: [...restore, ...remove].slice(0, 5), more: this.#stack.length > 0 },
165
+ `Đã hoàn tác lượt trước: ${restore.length} file trả về như trước lượt đó, ${remove.length} file lượt đó tạo đã xoá.${this.#stack.length ? ' /undo lần nữa để hoàn tác lượt trước nữa.' : ''} Commit tạo trong lượt đó (nếu có) vẫn giữ nguyên.`);
166
+ }
167
+ }
@@ -24,7 +24,7 @@ type SessionHookDependencies = {
24
24
  event: string
25
25
  ) => TaskContract | undefined;
26
26
  onTurnEnd?: (ctx: ExtensionContext) => void;
27
- onAgentSettled?: (ctx: ExtensionContext) => void;
27
+ onAgentSettled?: (ctx: ExtensionContext) => void | Promise<void>;
28
28
  beforeShutdown?: (ctx: ExtensionContext) => void | Promise<void>;
29
29
  };
30
30
 
@@ -102,7 +102,7 @@ export function registerSessionHooks(pi: ExtensionAPI, dependencies: SessionHook
102
102
  taskRunId: task?.taskRunId,
103
103
  taskOutcome: task?.trace.outcome
104
104
  });
105
- dependencies.onAgentSettled?.(ctx);
105
+ await dependencies.onAgentSettled?.(ctx);
106
106
  });
107
107
 
108
108
  pi.on("session_compact", async (event, ctx) => {
@@ -115,19 +115,22 @@ export function registerSessionHooks(pi: ExtensionAPI, dependencies: SessionHook
115
115
  });
116
116
 
117
117
  pi.on("session_shutdown", async (event, ctx) => {
118
- await dependencies.beforeShutdown?.(ctx);
119
- flushObservedTaskContext(
120
- pi,
121
- ctx,
122
- state.qualifiedTaskContext(ctx),
123
- maxManifestFiles,
124
- "context_observed_before_shutdown"
125
- );
126
- telemetry(ctx, {
127
- event: "session_shutdown",
128
- reason: event.reason,
129
- targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
130
- });
131
- state.clearSession(ctx);
118
+ try { await dependencies.beforeShutdown?.(ctx); }
119
+ finally {
120
+ try {
121
+ flushObservedTaskContext(
122
+ pi,
123
+ ctx,
124
+ state.qualifiedTaskContext(ctx),
125
+ maxManifestFiles,
126
+ "context_observed_before_shutdown"
127
+ );
128
+ telemetry(ctx, {
129
+ event: "session_shutdown",
130
+ reason: event.reason,
131
+ targetSessionFile: event.targetSessionFile ? path.basename(event.targetSessionFile) : undefined
132
+ });
133
+ } finally { state.clearSession(ctx); }
134
+ }
132
135
  });
133
136
  }
@@ -319,6 +319,9 @@ export function registerSessionStartHook(pi: ExtensionAPI, dependencies: Session
319
319
  if (permissionProfile.mode === "trusted-full-access") {
320
320
  ctx.ui.notify("Piagent permission profile trusted-full-access is active; protected paths, secret redaction, and destructive/external confirmations remain enforced.", "warning");
321
321
  }
322
+ if (permissionProfile.mode === "bypass") {
323
+ ctx.ui.notify("Piagent bypass is active: no confirmation, protected-path or policy gate applies in this personal session. Output redaction and /undo checkpoints remain.", "warning");
324
+ }
322
325
  const mcpNotice = dependencies.mcpReadinessNotice(ctx.cwd);
323
326
  if (mcpNotice) ctx.ui.notify(mcpNotice, "warning");
324
327
  const updateNotice = dependencies.updateAvailabilityNotice();
@@ -18,7 +18,7 @@ type ToolCallHookDependencies = {
18
18
  beforeAuthorize?: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
19
19
  authorize: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
20
20
  reviewBudgetDecision?: (event: ToolCallEvent, ctx: ExtensionContext) => ToolCallDecision;
21
- beforeStart?: (event: ToolCallEvent, ctx: ExtensionContext) => ToolCallDecision;
21
+ beforeStart?: (event: ToolCallEvent, ctx: ExtensionContext) => Promise<ToolCallDecision> | ToolCallDecision;
22
22
  afterDecision?: (
23
23
  event: ToolCallEvent,
24
24
  ctx: ExtensionContext,
@@ -74,7 +74,7 @@ export function registerToolCallHook(pi: ExtensionAPI, dependencies: ToolCallHoo
74
74
  const decision = authorizationDecision && typeof authorizationDecision === "object" && authorizationDecision.block
75
75
  ? authorizationDecision
76
76
  : dependencies.reviewBudgetDecision?.(resolvedEvent, ctx);
77
- const startDecision = !(decision && typeof decision === "object" && decision.block) ? dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
77
+ const startDecision = !(decision && typeof decision === "object" && decision.block) ? await dependencies.beforeStart?.(resolvedEvent, ctx) : undefined;
78
78
  const finalDecision = startDecision && typeof startDecision === "object" && startDecision.block ? startDecision : decision;
79
79
  // Every refusal leaves through here, so this is where it is answered. A
80
80
  // remedy attached at the 29 sites that build one is a remedy the next site
@@ -1,3 +1,5 @@
1
+ import type { ExactGitPatchAuthority } from "./source-action-contract.ts";
2
+ export type { ExactGitPatchAuthority } from "./source-action-contract.ts";
1
3
  import { createHash } from "node:crypto";
2
4
  import os from "node:os";
3
5
  import { collectGitStatus, runReadOnlyGit, type GitStatusRecord, type GitStatusSnapshot } from "./git-status-adapter.ts";
@@ -363,7 +365,6 @@ async function patchForUnstagedRecord(snapshot: GitStatusSnapshot, record: GitSt
363
365
  `--unified=${contextLines}`, "--", ...paths], { timeoutMs, maxBytes });
364
366
  }
365
367
 
366
- export type ExactGitPatchAuthority = { header: Buffer; hunks: Array<{ hunkRef: string; bytes: Buffer }> };
367
368
  type ExactGitPatchOptions = {
368
369
  snapshot: GitStatusSnapshot; record: GitStatusRecord; view: "unstaged" | "staged"; fileRef: string;
369
370
  contextLines?: number; timeoutMs?: number; maxBytes?: number; maxHunks?: number;
@@ -0,0 +1,90 @@
1
+ import path from "node:path";
2
+ import { inspectWorkspaceFile, workspacePathFlags, type WorkspacePolicy } from "../../extensions/workspace-inspection.ts";
3
+ import { redactSensitiveText } from "../../extensions/redaction-core.js";
4
+ import { PiSourceMutationGuard } from "../policy/source-mutation-guard.ts";
5
+ import { collectSourceChangeViews } from "./source-change-projection.ts";
6
+ import { collectSessionFileDiff } from "./session-diff-projection.ts";
7
+ import { collectSourceRevertPreview, validateSourceRevertTarget } from "./source-revert-projection.ts";
8
+ import { SessionMutationObserver } from "./session-mutation-observer.ts";
9
+ import { sessionAuthoredHunkRefs } from "./session-hunk-provenance.ts";
10
+ import { canonicalReviewValue } from "./review-state-contract.ts";
11
+ import type { SourceRevertTarget, WebUiIdentity } from "./source-action-contract.ts";
12
+
13
+ // Construct only inside the sandbox. Identity, observations and confirmation key
14
+ // come from the owning runtime; the browser supplies only a selected target.
15
+ export type ManagedSourceContext = {
16
+ cwd: string; rawSessionId: string; identity: WebUiIdentity; confirmationKey: Buffer;
17
+ controlRevision: string; idle(): boolean; policy(): WorkspacePolicy;
18
+ observation(file: string): unknown;
19
+ };
20
+ export function createManagedSourceRevert(context: ManagedSourceContext) {
21
+ const { cwd, identity } = context;
22
+ if (identity.taskId !== null || identity.taskRunId !== null) throw new Error("source-session-binding-required");
23
+ const relative = (root: string, file: string) => path.relative(cwd, path.resolve(root, file)).split(path.sep).join("/");
24
+ const blocked = (root: string, file: string, writing = true) => {
25
+ const name = relative(root, file);
26
+ if (!name || name === ".." || name.startsWith("../") || path.isAbsolute(name)) return true;
27
+ const flags = workspacePathFlags(name, context.policy());
28
+ return flags.protected || writing && flags.readOnly;
29
+ };
30
+ const observer = new SessionMutationObserver(), loaded = new Set<string>();
31
+ const observation = (file: string) => {
32
+ const policy = context.policy();
33
+ if (!loaded.has(file)) { observer.restore(cwd, context.rawSessionId, file, context.observation(file), policy); loaded.add(file); }
34
+ return observer.current(cwd, context.rawSessionId, file, policy);
35
+ };
36
+ const readBlocked = (root: string, file: string) => blocked(root, file, false);
37
+ const sources = () => collectSourceChangeViews({ cwd, identity, taskRevision: null, isProtectedPath: readBlocked });
38
+ const detail = async (fileRef: string) => {
39
+ const views = await sources(), source = views.workingTree;
40
+ const file = source.files.find(item => item.fileRef === fileRef);
41
+ if (!file) throw new Error("source-file-unavailable");
42
+ const diff = await collectSessionFileDiff({ cwd, identity, sourceView: source, fileRef, taskRevision: null, sessionObservation: observation,
43
+ isProtectedPath: readBlocked, redactLine: redactSensitiveText, revalidationMode: "selected-file",
44
+ selectedRepoPaths: file.pathDisplay === "exact-safe" ? [String(file.path)] : undefined,
45
+ precondition: { expectedViewRevision: source.viewRevision, expectedFileRevision: String(file.fileRevision),
46
+ expectedBaseDigest: file.baseDigest as string | null, expectedCurrentDigest: file.currentDigest as string | null } });
47
+ return { views, source, file, diff };
48
+ };
49
+ const resolve = async (fileRef: string, hunkRefs: string[]) => {
50
+ const { views, source, file, diff } = await detail(fileRef);
51
+ return collectSourceRevertPreview({ cwd, identity, sourceView: source, taskView: null, diff, taskRevision: null,
52
+ workspaceRevision: source.viewRevision, indexRevision: views.staged.viewRevision, selectedHunkRefs: hunkRefs,
53
+ sessionObservation: file.pathDisplay === "exact-safe" ? observation(String(file.path)) ?? undefined : undefined,
54
+ confirmationKey: context.confirmationKey, guardAvailable: context.idle() && context.policy().readOnly !== true,
55
+ isProtectedPath: blocked });
56
+ };
57
+ return {
58
+ sources, observation,
59
+ async diff(fileRef: string) { return (await detail(fileRef)).diff; },
60
+ async preview(fileRef: string, hunkRefs: string[]) { return (await resolve(fileRef, hunkRefs)).projection; },
61
+ async execute(value: unknown) {
62
+ const target: SourceRevertTarget = validateSourceRevertTarget(value);
63
+ if (Date.now() > Date.parse(target.expiresAt)) throw new Error("source-revert-preview-expired");
64
+ const { authority } = await resolve(target.fileRef, target.hunkRefs);
65
+ // The time window is checked separately; every content and identity binding
66
+ // must match the newly constructed, keyed preview before execution.
67
+ if (!authority || canonicalReviewValue({ ...authority.target, expiresAt: target.expiresAt }) !== canonicalReviewValue(target))
68
+ throw new Error("source-revert-preview-stale");
69
+ authority.target.expiresAt = target.expiresAt;
70
+ const guard = new PiSourceMutationGuard();
71
+ const unbind = guard.bind({ cwd, rawSessionId: context.rawSessionId, guardInstanceId: identity.runtimeInstanceId,
72
+ facts: () => ({ taskId: null, taskRunId: null, taskRevision: null, taskState: "none", sessionBound: true,
73
+ writable: context.policy().readOnly !== true, idle: context.idle(), controlRevision: context.controlRevision,
74
+ sessionObservation: observation,
75
+ sessionReverted: (candidate, before, committed) => observer.reverted(cwd, context.rawSessionId,
76
+ relative(candidate.repoRoot, candidate.repoPath), before, committed, context.policy()),
77
+ isProtectedPath: file => blocked(cwd, file), sessionHunkRefs: candidate => {
78
+ const file = relative(candidate.repoRoot, candidate.repoPath), observed = observation(file);
79
+ const current = inspectWorkspaceFile(cwd, file, context.policy());
80
+ return observed && current.content !== null ? sessionAuthoredHunkRefs(observed, current.content, candidate.patchAuthority) : [];
81
+ } }) });
82
+ try {
83
+ return await guard.executeRevert({ cwd, rawSessionId: context.rawSessionId, identity, action: "source.revert",
84
+ expectedTaskRevision: null, expectedControlRevision: context.controlRevision,
85
+ expectedIndexPreimage: target.indexPreimage, expectedWorkspacePreimage: target.workspacePreimage,
86
+ previewRef: target.previewRef, confirmedPreviewDigest: target.confirmedPreviewDigest, authority });
87
+ } finally { unbind(); }
88
+ }
89
+ };
90
+ }
@@ -16,16 +16,16 @@ export type ReviewStateProjection = {
16
16
  health: { state: "ok" | "degraded" | "unavailable" | "error"; reasonCode: string | null; message: string | null };
17
17
  };
18
18
 
19
- type DeriveReviewTargetOptions = {
19
+ type DeriveReviewTargetOptions<Revision extends string | null = string> = {
20
20
  diff: DiffDocument;
21
- taskId: string;
22
- taskRunId: string;
23
- taskRevision: string;
21
+ taskId: string | null;
22
+ taskRunId: string | null;
23
+ taskRevision: Revision;
24
24
  workspaceRevision: string;
25
25
  indexRevision: string | null;
26
26
  };
27
27
 
28
- function targetCore(options: DeriveReviewTargetOptions): Omit<ReviewTarget, "diffRef" | "patchPreimage" | "contentDigest"> & { basisRef: string } {
28
+ function targetCore<Revision extends string | null>(options: DeriveReviewTargetOptions<Revision>): Omit<ReviewTarget, "taskRevision" | "diffRef" | "patchPreimage" | "contentDigest"> & { taskRevision: Revision; basisRef: string } {
29
29
  const diff = options.diff as Record<string, any>, file = diff.file as Record<string, any>, observed = diff.observed as Record<string, any>;
30
30
  return {
31
31
  view: diff.view as ReviewView, fileRef: String(file.fileRef), taskRevision: options.taskRevision,
@@ -36,19 +36,30 @@ function targetCore(options: DeriveReviewTargetOptions): Omit<ReviewTarget, "dif
36
36
  };
37
37
  }
38
38
 
39
- export function deriveReviewTarget(options: DeriveReviewTargetOptions): ReviewTarget | null {
39
+ export function deriveSourceDiffTarget<Revision extends string | null>(options: DeriveReviewTargetOptions<Revision> & {
40
+ sessionIdentity?: Pick<WebUiIdentity, "projectRef" | "runtimeInstanceId" | "sessionRef">;
41
+ }): (Omit<ReviewTarget, "taskRevision"> & { taskRevision: Revision }) | null {
40
42
  const diff = options.diff as Record<string, any>;
41
- if (!options.taskId || !options.taskRunId || !options.taskRevision || !options.workspaceRevision
43
+ const binding = options.sessionIdentity;
44
+ const session = binding ? { projectRef: binding.projectRef, runtimeInstanceId: binding.runtimeInstanceId, sessionRef: binding.sessionRef } : undefined;
45
+ const sessionBound = session && options.taskId === null && options.taskRunId === null && options.taskRevision === null
46
+ && ["projectRef", "runtimeInstanceId", "sessionRef"].every(key => typeof session[key] === "string" && session[key] === diff.identity?.[key])
47
+ && diff.identity?.taskId === null && diff.identity?.taskRunId === null;
48
+ if ((!sessionBound && (!options.taskId || !options.taskRunId || !options.taskRevision)) || !options.workspaceRevision
42
49
  || diff.availability?.state !== "current" || diff.fallback?.kind !== "none"
43
50
  || diff.truncation?.truncated !== false || diff.redaction?.applied !== false || diff.redaction?.truncated !== false
44
51
  || diff.file?.status === "C" || diff.file?.content?.kind !== "text" || diff.file?.content?.access !== "available") return null;
45
- const core = targetCore(options), preimage = reviewDigest({ schemaVersion: 1, taskId: options.taskId, taskRunId: options.taskRunId, ...core });
52
+ const core = targetCore(options), preimage = reviewDigest({ schemaVersion: 1, taskId: options.taskId, taskRunId: options.taskRunId, ...(sessionBound ? { sessionIdentity: session } : {}), ...core });
46
53
  return { view: core.view, fileRef: core.fileRef, diffRef: `diff.${preimage.slice("sha256:".length)}`, taskRevision: core.taskRevision,
47
54
  workspaceRevision: core.workspaceRevision, indexRevision: core.indexRevision, viewRevision: core.viewRevision,
48
55
  fileRevision: core.fileRevision, baseDigest: core.baseDigest, currentDigest: core.currentDigest,
49
56
  patchPreimage: preimage, contentDigest: preimage };
50
57
  }
51
58
 
59
+ export function deriveReviewTarget(options: DeriveReviewTargetOptions): ReviewTarget | null {
60
+ return deriveSourceDiffTarget(options);
61
+ }
62
+
52
63
  function sameTarget(left: ReviewTarget, right: ReviewTarget): boolean {
53
64
  return canonicalReviewValue(left) === canonicalReviewValue(right);
54
65
  }
@@ -0,0 +1,47 @@
1
+ import { createHash } from "node:crypto";
2
+ import { collectExactGitPatchAuthority, collectFileDiff, type FileDiffOptions } from "./diff-projection.ts";
3
+ import { collectSelectedSourceFileState } from "./source-change-projection.ts";
4
+ import { readWorkspaceFile } from "./workspace-file-reader.ts";
5
+ import { sessionAuthoredHunkRefs } from "./session-hunk-provenance.ts";
6
+ import type { SessionMutationObservation } from "./session-mutation-observer.ts";
7
+
8
+ // Attribution is per hunk. A mixed file never inherits whole-file authorship
9
+ // merely because an observed agent edit exists somewhere in it.
10
+ export async function collectSessionFileDiff(options: FileDiffOptions & {
11
+ sessionObservation?(file: string): SessionMutationObservation | null | undefined;
12
+ }) {
13
+ const diff = await collectFileDiff(options), result = { ...diff, agentHunkRefs: [] as string[] };
14
+ if (options.sourceView.view !== "working-tree" || diff.availability.state !== "current"
15
+ || (diff.truncation as any)?.truncated !== false || (diff.redaction as any)?.applied !== false) return result;
16
+ const file = options.sourceView.files.find(item => item.fileRef === options.fileRef);
17
+ if (!file || file.pathDisplay !== "exact-safe" || typeof file.path !== "string") return result;
18
+ try {
19
+ const observation = options.sessionObservation?.(file.path);
20
+ if (!observation) return result;
21
+ const sourceOptions = { cwd: options.cwd, identity: options.identity, taskRevision: options.taskRevision,
22
+ isProtectedPath: options.isProtectedPath, timeoutMs: options.timeoutMs, maxGitBytes: options.maxBytes };
23
+ const selected = await collectSelectedSourceFileState(sourceOptions, "working-tree", options.fileRef, String(file.basisRef), [file.path]);
24
+ if (!selected || selected.file.fileRevision !== file.fileRevision || !selected.record.path.value
25
+ || options.isProtectedPath?.(selected.snapshot.repoRoot, selected.record.path.value)) return result;
26
+ const bytes = readWorkspaceFile(selected.snapshot.repoRoot, selected.record.path.value, 256 * 1024);
27
+ const current = bytes.toString("utf8"), currentDigest = createHash("sha256").update(bytes).digest("hex");
28
+ if (!Buffer.from(current).equals(bytes) || (diff.file as { currentDigest?: string }).currentDigest !== `sha256:${currentDigest}`) return result;
29
+ let authored: string[];
30
+ if (selected.record.kind === "untracked") {
31
+ // A witnessed creation proves the complete new file for display only.
32
+ // It does not mint a Git patch authority or enable file deletion.
33
+ authored = observation.baseline.digest === "absent" && observation.baseline.content === ""
34
+ && observation.currentDigest === currentDigest ? diff.hunks.map(hunk => String(hunk.hunkRef)) : [];
35
+ } else {
36
+ const patch = await collectExactGitPatchAuthority({ snapshot: selected.snapshot, record: selected.record, view: "unstaged",
37
+ fileRef: options.fileRef, timeoutMs: options.timeoutMs, maxBytes: options.maxBytes });
38
+ if (!patch) return result;
39
+ authored = sessionAuthoredHunkRefs(observation, current, patch);
40
+ }
41
+ const final = await collectSelectedSourceFileState(sourceOptions, "working-tree", options.fileRef, String(file.basisRef), [file.path]);
42
+ if (!final || final.file.fileRevision !== selected.file.fileRevision
43
+ || options.isProtectedPath?.(selected.snapshot.repoRoot, selected.record.path.value)) return result;
44
+ const visible = new Set(diff.hunks.map(hunk => hunk.hunkRef));
45
+ return { ...result, agentHunkRefs: authored.filter(ref => visible.has(ref)) };
46
+ } catch { return result; }
47
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from "node:crypto";
2
+ import { diffTextBuffers, type TextDiffOperation } from "./text-diff.ts";
3
+ import type { ExactGitPatchAuthority } from "./source-action-contract.ts";
4
+ import type { SessionMutationObservation } from "./session-mutation-observer.ts";
5
+
6
+ type ChangeBlock = { at: number; added: string[]; deleted: string[] };
7
+ const digest = (text: string) => createHash("sha256").update(text).digest("hex");
8
+ function blocks(operations: TextDiffOperation[], start = 1): ChangeBlock[] {
9
+ const result: ChangeBlock[] = [];
10
+ let current = start, block: ChangeBlock | null = null;
11
+ for (const operation of operations) {
12
+ if (operation.kind === "context") { block = null; current++; continue; }
13
+ if (!block) { block = { at: current, added: [], deleted: [] }; result.push(block); }
14
+ if (operation.kind === "added") { block.added.push(operation.text); current++; }
15
+ else block.deleted.push(operation.text);
16
+ }
17
+ return result;
18
+ }
19
+ function patchBlocks(bytes: Buffer): ChangeBlock[] | null {
20
+ const text = bytes.toString("utf8");
21
+ if (!Buffer.from(text).equals(bytes) || !text.endsWith("\n")) return null;
22
+ const lines = text.slice(0, -1).split("\n"), header = lines.shift();
23
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*$/.exec(header ?? "");
24
+ if (!match) return null;
25
+ const operations: TextDiffOperation[] = [];
26
+ for (const line of lines) {
27
+ const marker = line[0];
28
+ if (line === "\") {
29
+ const previous = operations.at(-1);
30
+ if (!previous?.text.endsWith("\n")) return null;
31
+ previous.text = previous.text.slice(0, -1);
32
+ continue;
33
+ }
34
+ if (![" ", "+", "-"].includes(marker)) return null;
35
+ operations.push({ kind: marker === " " ? "context" : marker === "+" ? "added" : "deleted", text: line.slice(1) + "\n" });
36
+ }
37
+ if (operations.filter(item => item.kind !== "added").length !== Number(match[2] ?? 1)
38
+ || operations.filter(item => item.kind !== "deleted").length !== Number(match[4] ?? 1)) return null;
39
+ return blocks(operations, Math.max(1, Number(match[3]) + (Number(match[4] ?? 1) === 0 ? 1 : 0)));
40
+ }
41
+
42
+ // Index hunks may contain member edits that predate this session. Only grant
43
+ // a hunk when every changed block is an exact delta from the observed baseline.
44
+ export function sessionAuthoredHunkRefs(observation: SessionMutationObservation, current: string,
45
+ patch: ExactGitPatchAuthority): string[] {
46
+ const baseline = observation.baseline;
47
+ if (digest(current) !== observation.currentDigest
48
+ || (baseline.digest === "absent" ? baseline.content !== "" : digest(baseline.content) !== baseline.digest)) return [];
49
+ const delta = diffTextBuffers(Buffer.from(baseline.content), Buffer.from(current), undefined, true);
50
+ if (!delta.exact) return [];
51
+ const proven = new Set(blocks(delta.operations).map(block => JSON.stringify(block)));
52
+ return patch.hunks.filter(hunk => {
53
+ const changes = patchBlocks(hunk.bytes);
54
+ return changes !== null && changes.length > 0 && changes.every(block => proven.has(JSON.stringify(block)));
55
+ }).map(hunk => hunk.hunkRef);
56
+ }