@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,272 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { workspacePathFlags, type WorkspacePolicy } from '../../extensions/workspace-inspection.ts';
7
+ import { effectiveProtectedPaths, matchesProtectedPath } from '../../extensions/workspace-policy.js';
8
+ import { inspectWorkspaceEntry, readWorkspaceLink, WorkspaceReadError } from '../../security/workspace-file-reader.ts';
9
+
10
+ // Untracked files above this size (a dataset, a build artifact nobody ignored)
11
+ // stay out of snapshots: undo leaves them alone rather than copying them into
12
+ // Git objects on every turn. Tracked files of any size are included.
13
+ const MAX_UNTRACKED = 100 << 20, MAX_ENTRIES = 500000, OUTPUT = 256 << 20;
14
+ const executable = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
15
+ const oid = (value: string) => { if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value)) throw Error('checkpoint-object-invalid'); return value; };
16
+ const internal = (file: string) => file.split('/').includes('.git') || file === '.pi/piagent-state' || file.startsWith('.pi/piagent-state/');
17
+ const safe = (file: string) => Boolean(file) && file.length <= 4096 && !/[\\\x00-\x1f\x7f]/.test(file) && !path.isAbsolute(file)
18
+ && !file.split('/').some(part => !part || part === '.' || part === '..') && !internal(file);
19
+ function valid(file: string) { if (!safe(file)) throw Error('checkpoint-path-invalid'); }
20
+ type FileState = { mode: string; fingerprint: string; permissions: number };
21
+ type TreeEntry = { mode: string; object: string; file: string };
22
+ type Hashed = { key: string; object: string };
23
+ const statKey = (stat: fs.Stats) => `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.mode}`;
24
+
25
+ // Snapshots hash raw bytes into a private index; neither Git filters nor
26
+ // checkout commands read or write workspace paths. One `hash-object
27
+ // --stdin-paths` per snapshot, and files unchanged since the last snapshot are
28
+ // not read again. Files Git cannot snapshot as blobs (submodules, sockets) are
29
+ // left out, so undo never touches them, instead of failing the turn.
30
+ // With `store`, objects and refs live in a private repository inside the Git
31
+ // directory: `git push --mirror` and the project's refs never see them, and the
32
+ // project's objects serve as alternates so tracked content is not copied.
33
+ export class PersonalCheckpointGit {
34
+ readonly root: string;
35
+ readonly #policy: () => WorkspacePolicy;
36
+ readonly #store: boolean;
37
+ #repository: string | null = null;
38
+ #objects: string | undefined;
39
+ #hashed = new Map<string, Hashed>();
40
+ constructor(root: string, policy: () => WorkspacePolicy, { store = false }: { store?: boolean } = {}) {
41
+ this.root = fs.realpathSync(root); this.#policy = policy; this.#store = store;
42
+ }
43
+ #run(args: string[], input?: Buffer, index?: string, cwd = this.root, objects: string | null | undefined = this.#objects, output?: number): Buffer {
44
+ // `objects` null: the project's own repository (listing files, finding it).
45
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/^GIT_/i.test(name)));
46
+ Object.assign(env, { GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', ...(index ? { GIT_INDEX_FILE: index } : {}) });
47
+ const argv = ['-C', cwd, ...(objects ? [`--git-dir=${objects}`] : []), ...args];
48
+ return execFileSync(executable, argv, { env, input, timeout: 120000, maxBuffer: OUTPUT,
49
+ stdio: [input ? 'pipe' : 'ignore', output ?? 'pipe', 'pipe'] }) ?? Buffer.alloc(0);
50
+ }
51
+ #top(): string | null {
52
+ if (fs.realpathSync(this.root) !== this.root) throw Error('checkpoint-project-changed');
53
+ let top: string;
54
+ try { top = fs.realpathSync(this.#run(['rev-parse', '--show-toplevel'], undefined, undefined, this.root, null).toString().trim()); }
55
+ catch (error) {
56
+ if (!this.#repository && /not a git repository/i.test(String((error as { stderr?: Buffer }).stderr))) return null;
57
+ throw Error('checkpoint-repository-unavailable');
58
+ }
59
+ const relative = path.relative(top, this.root);
60
+ if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) || this.#repository && this.#repository !== top)
61
+ throw Error('checkpoint-project-changed');
62
+ this.#repository = top;
63
+ if (this.#store && !this.#objects) this.#objects = this.#openStore();
64
+ return top;
65
+ }
66
+ #openStore(): string {
67
+ const common = path.resolve(this.root, this.#run(['rev-parse', '--git-common-dir'], undefined, undefined, this.root, null).toString().trim());
68
+ const parent = path.join(common, 'piagent'), store = path.join(parent, 'checkpoints.git');
69
+ for (const directory of [parent, store]) {
70
+ try { fs.mkdirSync(directory, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
71
+ const stat = fs.lstatSync(directory);
72
+ if (!stat.isDirectory() || typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw Error('checkpoint-store-invalid');
73
+ }
74
+ if (!fs.existsSync(path.join(store, 'HEAD'))) this.#run(['init', '--bare', '--quiet', '--template=', store], undefined, undefined, this.root, null);
75
+ const alternates = path.join(store, 'objects', 'info', 'alternates'), objects = path.join(common, 'objects') + '\n';
76
+ fs.mkdirSync(path.dirname(alternates), { recursive: true });
77
+ if (!fs.existsSync(alternates) || fs.readFileSync(alternates, 'utf8') !== objects) fs.writeFileSync(alternates, objects, { mode: 0o600 });
78
+ return store;
79
+ }
80
+ available() { return this.#top() !== null; }
81
+ #flags(file: string, write = false) {
82
+ valid(file);
83
+ const policy = this.#policy(), flags = workspacePathFlags(file, policy);
84
+ if (flags.protected) throw Error(`checkpoint-path-protected: ${file}`);
85
+ if (write && (flags.readOnly || policy.readOnly === undefined && policy.profile.permissionProfile === 'read-only'))
86
+ throw Error(`checkpoint-read-only: ${file}`);
87
+ }
88
+ // Drift checks compare identity and timestamps, which every write changes,
89
+ // so a restore never reads whole files into memory.
90
+ #state(file: string): FileState | null {
91
+ this.#flags(file);
92
+ let stat: fs.Stats;
93
+ try { stat = inspectWorkspaceEntry(this.root, file); }
94
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; }
95
+ let mode: string, link = '';
96
+ if (stat.isSymbolicLink()) { mode = '120000'; link = readWorkspaceLink(this.root, file); }
97
+ else if (stat.isFile()) mode = stat.mode & 0o111 ? '100755' : '100644';
98
+ else throw Error(`checkpoint-unsupported-entry: ${file}`);
99
+ return { mode, permissions: stat.mode & 0o777, fingerprint: `${mode}:${statKey(stat)}:${link}` };
100
+ }
101
+ #list(args: string[]) {
102
+ return this.#run(['ls-files', ...args, '-z', '--', '.'], undefined, undefined, this.root, null)
103
+ .toString('utf8').split('\0').filter(Boolean);
104
+ }
105
+ #hash(files: { file: string; key: string }[], top: string): string[] {
106
+ if (!files.length) return [];
107
+ const input = Buffer.from(files.map(item => path.join(this.root, item.file)).join('\n') + '\n');
108
+ try {
109
+ const objects = this.#run(['hash-object', '-w', '--no-filters', '--stdin-paths'], input, undefined, top).toString().trim().split('\n');
110
+ if (objects.length !== files.length) throw Error('checkpoint-object-invalid');
111
+ return objects.map(oid);
112
+ } catch {
113
+ // A file removed while the batch ran: hash the rest one by one.
114
+ return files.map(item => {
115
+ try { return oid(this.#run(['hash-object', '-w', '--no-filters', '--', path.join(this.root, item.file)], undefined, undefined, top).toString().trim()); }
116
+ catch { if (!fs.existsSync(path.join(this.root, item.file))) return ''; throw Error(`checkpoint-hash-failed: ${item.file}`); }
117
+ });
118
+ }
119
+ }
120
+ tree(): string | null {
121
+ const top = this.#top(); if (!top) return null;
122
+ const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-checkpoint-')); fs.chmodSync(temporary, 0o700);
123
+ const index = path.join(temporary, 'index'), policy = this.#policy();
124
+ // workspacePathFlags(...).protected, with each folder decided once per snapshot.
125
+ const patterns = effectiveProtectedPaths(policy.policy, policy.profile).readProtectedPaths, known = new Map<string, boolean>();
126
+ const protectedPath = (file: string) => file.split('/').some((_, i, parts) => {
127
+ const prefix = parts.slice(0, i + 1).join('/');
128
+ if (!known.has(prefix)) known.set(prefix, !!matchesProtectedPath(prefix, patterns));
129
+ return known.get(prefix);
130
+ });
131
+ try {
132
+ const tracked = new Set(this.#list(['--cached'])), records: string[] = [], pending: { file: string; mode: string; key: string }[] = [];
133
+ const listed = [...new Set(this.#list(['--cached', '--others', '--exclude-standard']))].sort();
134
+ if (listed.length > MAX_ENTRIES) throw Error('checkpoint-file-limit');
135
+ const now = Date.now(), seen = new Set<string>();
136
+ for (const file of listed) {
137
+ if (!safe(file) || protectedPath(file)) continue;
138
+ let stat: fs.Stats;
139
+ try { stat = inspectWorkspaceEntry(this.root, file); }
140
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT' || error instanceof WorkspaceReadError) continue; throw error; }
141
+ if (stat.isSymbolicLink()) {
142
+ let target: string;
143
+ try { target = readWorkspaceLink(this.root, file); } catch { continue; }
144
+ const object = oid(this.#run(['hash-object', '-w', '--no-filters', '--stdin'], Buffer.from(target), undefined, top).toString().trim());
145
+ records.push(`120000 ${object}\t${file}\0`); continue;
146
+ }
147
+ if (!stat.isFile() || !tracked.has(file) && stat.size > MAX_UNTRACKED) continue;
148
+ const mode = stat.mode & 0o111 ? '100755' : '100644', key = statKey(stat), known = this.#hashed.get(file);
149
+ seen.add(file);
150
+ if (known?.key === key) records.push(`${mode} ${known.object}\t${file}\0`);
151
+ // A file written within the last two seconds may change again without
152
+ // moving its timestamp: hash it now, remember it next time.
153
+ else pending.push({ file, mode, key: now - stat.mtimeMs > 2000 ? key : '' });
154
+ }
155
+ this.#hash(pending, top).forEach((object, i) => {
156
+ const item = pending[i]!; if (!object) return;
157
+ if (item.key) this.#hashed.set(item.file, { key: item.key, object }); else this.#hashed.delete(item.file);
158
+ records.push(`${item.mode} ${object}\t${item.file}\0`);
159
+ });
160
+ for (const file of this.#hashed.keys()) if (!seen.has(file)) this.#hashed.delete(file);
161
+ this.#run(['read-tree', '--empty'], undefined, index, top);
162
+ this.#run(['update-index', '-z', '--index-info'], Buffer.from(records.join('')), index, top);
163
+ return oid(this.#run(['write-tree'], undefined, index, top).toString().trim());
164
+ } finally { fs.rmSync(temporary, { recursive: true, force: true }); }
165
+ }
166
+ #entries(tree: string): Map<string, TreeEntry> {
167
+ const records = this.#run(['ls-tree', '--full-tree', '-r', '-z', oid(tree)]).toString('utf8').split('\0').filter(Boolean);
168
+ if (records.length > MAX_ENTRIES) throw Error('checkpoint-file-limit');
169
+ const entries = new Map<string, TreeEntry>();
170
+ for (const record of records) {
171
+ const match = /^(100644|100755|120000) blob ([a-f0-9]{40}|[a-f0-9]{64})\t([\s\S]+)$/.exec(record);
172
+ if (!match) throw Error('checkpoint-tree-invalid');
173
+ const mode = match[1]!, object = match[2]!, file = match[3]!; valid(file);
174
+ if (entries.has(file)) throw Error('checkpoint-tree-invalid');
175
+ entries.set(file, { mode, object, file });
176
+ }
177
+ return entries;
178
+ }
179
+ changed(from: string, to: string) {
180
+ this.#top();
181
+ const before = this.#entries(from), after = this.#entries(to);
182
+ return [...new Set([...before.keys(), ...after.keys()])].sort().flatMap(file => {
183
+ const a = before.get(file), b = after.get(file);
184
+ return a?.object === b?.object && a?.mode === b?.mode ? [] : [{ status: !a ? 'A' : !b ? 'D' : 'M', path: file }];
185
+ });
186
+ }
187
+ restore(before: string, restore: string[], remove: string[], expectedTree?: string) {
188
+ if (!expectedTree) throw Error('checkpoint-current-tree-required');
189
+ const currentTree = this.tree();
190
+ if (currentTree !== oid(expectedTree)) {
191
+ const affected = new Set([...restore, ...remove]);
192
+ const changed = currentTree ? this.changed(expectedTree, currentTree).map(entry => entry.path).filter(file => affected.has(file)) : [];
193
+ if (!currentTree || changed.length) throw Error(`checkpoint-content-changed${changed.length ? ': ' + changed.slice(0, 5).join(', ') : ''}`);
194
+ }
195
+ const entries = this.#entries(before), files = [...restore, ...remove], current = this.#entries(expectedTree);
196
+ if (files.length > MAX_ENTRIES || new Set(files).size !== files.length) throw Error('checkpoint-file-limit');
197
+ const states = new Map<string, FileState | null>(), objects: string[] = [];
198
+ for (const file of files) {
199
+ this.#flags(file, true); states.set(file, this.#state(file));
200
+ // On disk but outside the current snapshot (ignored since, or too large
201
+ // to snapshot): its present content was never compared, so keep it.
202
+ if (states.get(file) && !current.has(file)) throw Error(`checkpoint-content-changed: ${file}`);
203
+ if (!restore.includes(file)) continue;
204
+ const entry = entries.get(file); if (!entry) throw Error('checkpoint-tree-invalid');
205
+ objects.push(entry.object);
206
+ }
207
+ // Every object must be readable before the first file changes; contents are
208
+ // then streamed straight into each replacement, whatever their size.
209
+ if (objects.length) {
210
+ const missing = this.#run(['cat-file', '--batch-check=%(objectname) %(objecttype)'], Buffer.from(objects.join('\n') + '\n'))
211
+ .toString().trim().split('\n').filter(line => !/^[a-f0-9]+ blob$/.test(line));
212
+ if (missing.length) throw Error('checkpoint-object-missing');
213
+ }
214
+ // Preflight every file before the first effect; recheck it again immediately
215
+ // before its atomic replacement. No Git checkout/filter can bypass policy.
216
+ for (const file of files) {
217
+ this.#flags(file, true);
218
+ if ((this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
219
+ }
220
+ // An error after the first file changed says so (checkpoint-partial), so
221
+ // the caller reports an uncertain result rather than a clean refusal.
222
+ let changed = 0;
223
+ try { for (const file of files) {
224
+ this.#flags(file, true);
225
+ if ((this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
226
+ const target = path.join(this.root, file);
227
+ if (remove.includes(file)) { if (states.get(file)) { fs.unlinkSync(target); changed++; this.#prune(file, entries); } continue; }
228
+ const parts = file.split('/');
229
+ for (let i = 1; i < parts.length; i++) {
230
+ const parent = parts.slice(0, i).join('/'); this.#flags(parent, true);
231
+ try { fs.mkdirSync(path.join(this.root, parent)); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; }
232
+ const stat = inspectWorkspaceEntry(this.root, parent);
233
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('checkpoint-unsafe-parent');
234
+ }
235
+ const parent = path.dirname(target), parentStat = fs.lstatSync(parent);
236
+ const temporary = path.join(parent, `.piagent-checkpoint-${randomUUID()}`), entry = entries.get(file)!;
237
+ try {
238
+ if (entry.mode === '120000') fs.symlinkSync(this.#run(['cat-file', 'blob', entry.object]).toString('utf8'), temporary);
239
+ else {
240
+ const fd = fs.openSync(temporary, 'wx', 0o600);
241
+ try {
242
+ this.#run(['cat-file', 'blob', entry.object], undefined, undefined, this.root, this.#objects, fd);
243
+ // A file the turn deleted comes back as a new file would be made.
244
+ const permissions = states.get(file)?.permissions ?? 0o666 & ~process.umask();
245
+ fs.fchmodSync(fd, (permissions & 0o666) | (entry.mode === '100755' ? 0o111 : 0));
246
+ fs.fsyncSync(fd);
247
+ } finally { fs.closeSync(fd); }
248
+ }
249
+ this.#flags(file, true);
250
+ const currentParent = fs.lstatSync(parent);
251
+ if (fs.realpathSync(parent) !== parent || currentParent.dev !== parentStat.dev || currentParent.ino !== parentStat.ino
252
+ || (this.#state(file)?.fingerprint ?? null) !== (states.get(file)?.fingerprint ?? null)) throw Error(`checkpoint-content-changed: ${file}`);
253
+ fs.renameSync(temporary, target); changed++;
254
+ } finally { fs.rmSync(temporary, { force: true }); }
255
+ } } catch (error) { throw changed ? Error(`checkpoint-partial: ${(error as Error).message}`) : error; }
256
+ }
257
+ // Folders the turn created and its undo emptied go too; a folder that held
258
+ // files before the turn, or holds anything now, stays.
259
+ #prune(file: string, before: Map<string, TreeEntry>) {
260
+ const parts = file.split('/');
261
+ for (let i = parts.length - 1; i > 0; i--) {
262
+ const folder = parts.slice(0, i).join('/');
263
+ if ([...before.keys()].some(entry => entry.startsWith(folder + '/'))) return;
264
+ try { fs.rmdirSync(path.join(this.root, folder)); } catch { return; }
265
+ }
266
+ }
267
+ // Only the ref manager receives this plumbing port; it never accepts shell.
268
+ readonly refsExecutor = { run: (args: string[], input?: Buffer) => {
269
+ if (!this.#top() || !['for-each-ref', 'update-ref'].includes(args[0] ?? '')) throw Error('checkpoint-ref-command-invalid');
270
+ return this.#run(args, input).toString('utf8');
271
+ } };
272
+ }
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs';
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import type { WorkspacePolicy } from '../../extensions/workspace-inspection.ts';
4
+ import { isCompanySession } from '../../extensions/company-session-guard.ts';
5
+ import { PersonalCheckpointGit } from './personal-git.ts';
6
+ import { CheckpointStore } from './checkpoint-store.mjs';
7
+ import { CheckpointRefs } from './checkpoint-refs.mjs';
8
+ import { PersistentCheckpointHistory } from './persistent-history.mjs';
9
+ import { pruneStaleCheckpointSessions } from './prune.mjs';
10
+ import { TurnCheckpointService, checkpointOwnershipError } from './turn-checkpoints.mjs';
11
+ import { checkpointAuthority } from './checkpoint-authority.ts';
12
+ import { workspaceMemberEditsSince } from '../session/workspace-member-edits.mjs';
13
+
14
+ type Run = { turnRef: string; checkpoint?: unknown };
15
+ type Binding = { ctx: ExtensionContext; service: TurnCheckpointService; run?: Run; active: boolean; guard?: () => void; unbind(): void };
16
+
17
+ // The extension owns these bindings; refreshed contexts supply live permissions.
18
+ export class PersonalCheckpointRuntime {
19
+ #bindings = new Map<string, Binding>();
20
+ #pruned = new Set<string>();
21
+ #tail = Promise.resolve();
22
+ #pending = 0;
23
+ private policy: (ctx: ExtensionContext) => WorkspacePolicy;
24
+ constructor(policy: (ctx: ExtensionContext) => WorkspacePolicy) { this.policy = policy; }
25
+ #key(ctx: ExtensionContext) {
26
+ if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) throw Error('checkpoint-company-boundary-required');
27
+ const session = ctx.sessionManager.getSessionId();
28
+ if (!session) throw Error('checkpoint-session-unavailable');
29
+ return `${fs.realpathSync(ctx.cwd)}\0${session}`;
30
+ }
31
+ #binding(ctx: ExtensionContext): Binding {
32
+ const key = this.#key(ctx);
33
+ let binding = this.#bindings.get(key);
34
+ if (!binding) {
35
+ if (this.#bindings.size >= 64) throw Error('checkpoint-session-capacity');
36
+ const state = { ctx, active: true } as Binding;
37
+ const git = new PersonalCheckpointGit(ctx.cwd, () => {
38
+ if (!state.active) throw Error('checkpoint-authority-changed');
39
+ state.guard?.();
40
+ return this.policy(state.ctx);
41
+ }, { store: true });
42
+ const store = new CheckpointStore(ctx.cwd, ctx.sessionManager.getSessionId());
43
+ if (!this.#pruned.has(store.directory)) {
44
+ this.#pruned.add(store.directory);
45
+ try { if (git.available()) pruneStaleCheckpointSessions(store.directory, git.refsExecutor, store.key); } catch { /* best effort */ }
46
+ }
47
+ state.service = new TurnCheckpointService(git, {
48
+ busy: () => { state.guard?.(); return !state.active || !state.ctx.isIdle(); },
49
+ history: new PersistentCheckpointHistory(store, new CheckpointRefs(git.refsExecutor, store.key)),
50
+ memberEdits: since => workspaceMemberEditsSince(state.ctx.sessionManager, since),
51
+ });
52
+ state.unbind = checkpointAuthority.bind(ctx.cwd, ctx.sessionManager.getSessionId(), {
53
+ revoke: () => { state.active = false; state.service.revoke(); },
54
+ port: {
55
+ list: () => this.list(state.ctx),
56
+ preview: target => this.preview(state.ctx, target),
57
+ restoreTo: (target, expected, guard) => this.restoreTo(state.ctx, target, expected, guard),
58
+ },
59
+ });
60
+ binding = state;
61
+ this.#bindings.set(key, binding);
62
+ }
63
+ if (!binding.active) throw Error('checkpoint-authority-changed');
64
+ binding.ctx = ctx;
65
+ return binding;
66
+ }
67
+ bind(ctx: ExtensionContext) {
68
+ if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
69
+ this.#binding(ctx);
70
+ }
71
+ async #settledBinding(ctx: ExtensionContext) {
72
+ const binding = this.#binding(ctx);
73
+ if (ctx.isIdle() && binding.run) {
74
+ await binding.service.finish(binding.run);
75
+ binding.run = undefined;
76
+ }
77
+ return binding;
78
+ }
79
+ list(ctx: ExtensionContext) {
80
+ return this.#serial(async () => (await this.#settledBinding(ctx)).service.list());
81
+ }
82
+ preview(ctx: ExtensionContext, target: string) {
83
+ return this.#serial(async () => (await this.#settledBinding(ctx)).service.preview(target));
84
+ }
85
+ restoreTo(ctx: ExtensionContext, target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void) {
86
+ return this.#serial(async () => {
87
+ guard?.();
88
+ const binding = await this.#settledBinding(ctx);
89
+ binding.guard = guard;
90
+ try { guard?.(); return await binding.service.restoreTo(target, expected); }
91
+ finally { binding.guard = undefined; }
92
+ });
93
+ }
94
+ #serial<T>(action: () => Promise<T>): Promise<T> {
95
+ if (this.#pending >= 128) return Promise.reject(Error('checkpoint-command-capacity'));
96
+ this.#pending++;
97
+ const operation = this.#tail.then(action, action);
98
+ this.#tail = operation.then(() => {}, () => {});
99
+ return operation.finally(() => { this.#pending--; });
100
+ }
101
+ beforeMutation(ctx: ExtensionContext) {
102
+ return this.#serial(async () => {
103
+ const binding = this.#binding(ctx);
104
+ let turn: string | undefined;
105
+ try {
106
+ const user = [...ctx.sessionManager.getBranch()].reverse().find(entry => entry.type === 'message' && entry.message.role === 'user');
107
+ turn = user?.id;
108
+ if (!user?.id) throw Error('checkpoint-turn-unavailable');
109
+ if (binding.run?.turnRef !== user.id) {
110
+ // Steering can add a user turn before the enclosing agent settles.
111
+ if (binding.run) await binding.service.finish(binding.run);
112
+ binding.run = { turnRef: user.id };
113
+ }
114
+ await binding.service.take(binding.run);
115
+ } catch (error) {
116
+ // Only a turn that already has its checkpoint may change files unrecorded.
117
+ if (!checkpointOwnershipError(error) && (!turn || binding.run?.turnRef !== turn || binding.run.checkpoint === undefined)) binding.service.markGap();
118
+ throw error;
119
+ }
120
+ if (!binding.active) throw Error('checkpoint-authority-changed');
121
+ });
122
+ }
123
+ finish(ctx: ExtensionContext) {
124
+ return this.#serial(async () => {
125
+ if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
126
+ const binding = this.#bindings.get(this.#key(ctx));
127
+ if (!binding) return;
128
+ if (!binding.active) throw Error('checkpoint-authority-changed');
129
+ binding.ctx = ctx;
130
+ if (binding.run) await binding.service.finish(binding.run);
131
+ binding.run = undefined;
132
+ });
133
+ }
134
+ undo(ctx: ExtensionContext) {
135
+ return this.#serial(async () => {
136
+ const binding = this.#binding(ctx);
137
+ // A failed settlement must be resolved before exposing the older stack.
138
+ if (ctx.isIdle() && binding.run) {
139
+ await binding.service.finish(binding.run);
140
+ binding.run = undefined;
141
+ }
142
+ return await binding.service.undo();
143
+ });
144
+ }
145
+ close(ctx: ExtensionContext) {
146
+ return this.#serial(async () => {
147
+ if (ctx.model?.provider === 'agent_watch_managed' || isCompanySession(ctx)) return;
148
+ const key = this.#key(ctx), binding = this.#bindings.get(key);
149
+ if (!binding) return;
150
+ binding.ctx = ctx;
151
+ try { if (binding.active && binding.run) await binding.service.finish(binding.run); }
152
+ finally {
153
+ try { await binding.service.close(); }
154
+ finally { binding.unbind(); this.#bindings.delete(key); }
155
+ }
156
+ });
157
+ }
158
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const STALE_MS = 30 * 24 * 60 * 60 * 1000;
5
+
6
+ // Conversations nobody has touched for 30 days give up their checkpoints: the
7
+ // state file goes, and with it the refs that kept their trees alive, so the
8
+ // private store does not grow with every conversation ever held in a project.
9
+ // A session holding its lock, and the caller's own session, are left alone.
10
+ export function pruneStaleCheckpointSessions(directory, refs, keep, now = Date.now()) {
11
+ let names;
12
+ try { names = fs.readdirSync(directory); } catch { return 0; }
13
+ let pruned = 0;
14
+ for (const name of names) {
15
+ const key = /^([a-f0-9]{64})\.json$/.exec(name)?.[1];
16
+ if (!key || key === keep || names.includes(`${key}.lock`)) continue;
17
+ const file = path.join(directory, name);
18
+ try {
19
+ const stat = fs.lstatSync(file);
20
+ if (!stat.isFile() || now - stat.mtimeMs < STALE_MS) continue;
21
+ const prefix = `refs/piagent/checkpoints/${key}/`;
22
+ const lines = refs.run(['for-each-ref', '--format=%(refname) %(objectname)', prefix]).trim().split('\n').filter(Boolean)
23
+ .map(line => { const [ref, object] = line.split(' '); return `delete ${ref} ${object}`; });
24
+ if (lines.length) refs.run(['update-ref', '--stdin'], Buffer.from(['start', ...lines, 'prepare', 'commit', ''].join('\n')));
25
+ fs.unlinkSync(file); pruned++;
26
+ } catch { /* another process is using it; try again next time */ }
27
+ }
28
+ return pruned;
29
+ }
@@ -0,0 +1,46 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export const checkpointDigest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
4
+
5
+ // Compare each file against the virtual result of undoing newer turns. This
6
+ // preserves member edits between turns without writing preview trees or files.
7
+ export async function previewCheckpointRestore(git, points, targetId, busy, memberEdits = _since => new Set()) {
8
+ const revision = checkpointDigest(points);
9
+ const index = points.findIndex(point => point.id === targetId);
10
+ const base = { targetId, revision, tree: null, steps: [], files: [], conflicts: [], stoppedAt: null };
11
+ if (busy) return { ...base, result: 'busy', digest: null };
12
+ if (index < 0) return { ...base, result: 'none', digest: null };
13
+ const tree = await git.tree();
14
+ if (!tree) return { ...base, result: 'unavailable', digest: null };
15
+ const sources = new Map(), files = new Set(), steps = [];
16
+ let conflicts = [], stoppedAt = null;
17
+ for (const point of points.slice(index).reverse()) {
18
+ const changes = await git.changed(point.before, point.after);
19
+ for (const change of changes) files.add(change.path);
20
+ if (files.size > 10000) throw Error('checkpoint-preview-file-limit');
21
+ const groups = new Map();
22
+ for (const { path } of changes) {
23
+ const source = sources.get(path) ?? tree;
24
+ if (!groups.has(source)) groups.set(source, new Set());
25
+ groups.get(source).add(path);
26
+ }
27
+ // A path already back as it was before this turn (an undo that stopped
28
+ // midway) needs nothing and conflicts with nothing.
29
+ const back = new Set(), edited = memberEdits(point.at);
30
+ for (const [source, paths] of groups) {
31
+ const differs = new Set((await git.changed(point.before, source)).map(change => change.path));
32
+ for (const path of paths) if (!differs.has(path)) back.add(path);
33
+ const drift = await git.changed(point.after, source);
34
+ conflicts.push(...drift.filter(change => paths.has(change.path) && differs.has(change.path)).map(change => change.path));
35
+ }
36
+ conflicts.push(...changes.filter(change => edited.has(change.path) && !back.has(change.path)).map(change => change.path));
37
+ if (conflicts.length) { stoppedAt = point.id; break; }
38
+ const pending = changes.filter(change => !back.has(change.path));
39
+ steps.push({ id: point.id, turnRef: point.turnRef, restore: pending.filter(change => change.status !== 'A').map(change => change.path),
40
+ remove: pending.filter(change => change.status === 'A').map(change => change.path) });
41
+ for (const { path } of changes) sources.set(path, point.before);
42
+ }
43
+ const preview = { ...base, tree, steps, files: [...files].sort(), conflicts: [...new Set(conflicts)].sort(), stoppedAt,
44
+ result: conflicts.length ? 'conflict' : 'ready' };
45
+ return { ...preview, digest: checkpointDigest(preview) };
46
+ }