@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
@@ -15,6 +15,7 @@ import { HELPER_CALLS, HELPER_SETUP, READ_TOOLS, countedCheck } from './helper-r
15
15
  import { trackToolFailures } from './tool-failures.mjs';
16
16
  import { helperMemoryTools, renewer } from './memory.mjs';
17
17
  import { answeredSettlement, failedSettlement, settleHelper, OUTCOME_WORDS } from './helper-settlement.mjs';
18
+ import { viaLink } from './studio-link.mjs';
18
19
 
19
20
  const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
20
21
  // A helper failure that another model of its role may not have: the model's
@@ -53,7 +54,16 @@ export async function runHelper(managed, role, task, signal, harness, { verifyGr
53
54
  }
54
55
  }
55
56
  }
57
+ // A helper's role closes through the same wait as the run's close: a passing
58
+ // failure of Agent Watch's way to Studio is asked again. A close that still
59
+ // fails leaves the helper's answer standing: the role closes with the run,
60
+ // and the next helper of the role closes it first.
61
+ export async function closeRole(managed, role) {
62
+ try { await viaLink(managed.studioLink, () => managed.broker.request('close', { role })); managed.unclosed?.delete(role); }
63
+ catch { (managed.unclosed ??= new Set()).add(role); }
64
+ }
56
65
  async function helperAttempt(managed, role, task, signal, harness, snapshot, parts, { verifyGrant, patiently, title }) {
66
+ if (managed.unclosed?.has(role)) await closeRole(managed, role);
57
67
  // A long local tool step may outlive the main lease. Refresh authority
58
68
  // before asking for a child; the broker serializes concurrent renewals,
59
69
  // and main's own searches running beside it share managed renew.
@@ -143,6 +153,6 @@ async function helperAttempt(managed, role, task, signal, harness, snapshot, par
143
153
  finally {
144
154
  for (const session of sessions) session.dispose(); await boundary?.dispose();
145
155
  await settleHelper(managed, renew.lease, report); // with the role's current token, before it closes
146
- await managed.broker.request('close', { role });
156
+ await closeRole(managed, role);
147
157
  }
148
158
  }
@@ -78,8 +78,11 @@ export function failedSettlement(role, error, signal, spec = {}) {
78
78
  evidence: [], files: [], ...(Object.keys(spec ?? {}).length ? { spec } : {}) };
79
79
  }
80
80
 
81
- // Sends the report with the attempt's own token; never fails the helper.
82
- export async function settleHelper(managed, grant, report, fetchImpl = (...args) => globalThis.fetch(...args)) {
81
+ // Sends the report with the attempt's own token; never fails the helper. A
82
+ // passing failure (no answer, 5xx, 429) is sent again after these pauses:
83
+ // Studio keeps one report per role, so a repeat is safe.
84
+ const SETTLE_PAUSES_MS = [500, 2_000];
85
+ export async function settleHelper(managed, grant, report, fetchImpl = (...args) => globalThis.fetch(...args), pauses = SETTLE_PAUSES_MS) {
83
86
  if (!report || !hasCapability(managed, 'managed.settle.v1')) return false;
84
87
  let held = null;
85
88
  try {
@@ -87,10 +90,18 @@ export async function settleHelper(managed, grant, report, fetchImpl = (...args)
87
90
  // current one (the attempt's first token may be stale by now).
88
91
  if (typeof grant === 'function') { held = await grant(); grant = held.grant; }
89
92
  if (!grant?.token) return false;
90
- const response = await fetchImpl(`${managed.origin}/v1/managed/settle`, { method: 'POST', signal: AbortSignal.timeout(3_000),
91
- headers: { Authorization: `Bearer ${grant.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT }, body: JSON.stringify(report) });
92
- void response.body?.cancel?.().catch(() => {});
93
- return response.ok;
93
+ for (let attempt = 0; ; attempt += 1) {
94
+ let status = 0;
95
+ try {
96
+ const response = await fetchImpl(`${managed.origin}/v1/managed/settle`, { method: 'POST', signal: AbortSignal.timeout(3_000),
97
+ headers: { Authorization: `Bearer ${grant.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT }, body: JSON.stringify(report) });
98
+ void response.body?.cancel?.().catch(() => {});
99
+ if (response.ok) return true;
100
+ status = response.status;
101
+ } catch { /* no answer */ }
102
+ if ((status && status < 500 && status !== 429) || attempt >= pauses.length) return false;
103
+ await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
104
+ }
94
105
  } catch { return false; } finally { held?.release(); }
95
106
  }
96
107
 
@@ -0,0 +1,50 @@
1
+ import net from 'node:net';
2
+
3
+ // Whether an address is this machine, a private network or not routable on
4
+ // the internet (IANA special-purpose registries). One classifier for every
5
+ // outbound check (web-fetch, package-proxy). IPv6 forms that carry an IPv4
6
+ // address (mapped, compatible, translated, NAT64, 6to4), dotted or hex, are
7
+ // judged by that IPv4 address. Teredo, IPv6 outside 2000::/3, and anything
8
+ // that does not parse count as private: fail closed.
9
+ const V4_RANGES = [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
10
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
11
+ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]].map(([base, bits]) => [v4Number(base), 2 ** (32 - bits)]);
12
+
13
+ function v4Number(text) {
14
+ const parts = String(text).split('.');
15
+ if (parts.length !== 4 || !parts.every(part => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255)) return null;
16
+ return parts.reduce((value, part) => value * 256 + Number(part), 0);
17
+ }
18
+ const v4Private = value => V4_RANGES.some(([base, size]) => value >= base && value < base + size);
19
+
20
+ // Eight 16-bit groups, or null: "::" expanded, a dotted IPv4 tail and a zone id accepted.
21
+ function v6Groups(text) {
22
+ let value = String(text).toLowerCase().replace(/%[^%]*$/, '');
23
+ const tail = /:(\d+\.\d+\.\d+\.\d+)$/.exec(value);
24
+ if (tail) {
25
+ const v4 = v4Number(tail[1]); if (v4 === null) return null;
26
+ value = value.slice(0, -tail[1].length) + `${(v4 >>> 16).toString(16)}:${(v4 & 0xffff).toString(16)}`;
27
+ }
28
+ const halves = value.split('::');
29
+ if (halves.length > 2) return null;
30
+ const head = halves[0] ? halves[0].split(':') : [], rest = halves[1] ? halves[1].split(':') : [];
31
+ const missing = 8 - head.length - rest.length;
32
+ if (halves.length === 2 ? missing < 1 : missing !== 0) return null;
33
+ const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...rest];
34
+ return groups.every(group => /^[0-9a-f]{1,4}$/.test(group)) ? groups.map(group => parseInt(group, 16)) : null;
35
+ }
36
+
37
+ export function privateAddress(address) {
38
+ if (net.isIPv4(String(address))) { const v4 = v4Number(address); return v4 === null || v4Private(v4); }
39
+ const g = v6Groups(address);
40
+ if (!g) return true;
41
+ const zero = (from, to) => g.slice(from, to).every(group => group === 0);
42
+ const v4 = (high, low) => v4Private(high * 0x10000 + low);
43
+ if (zero(0, 5) && (g[5] === 0xffff || g[5] === 0)) return v4(g[6], g[7]); // ::ffff:0:0/96 mapped, ::/96 compatible (with :: and ::1)
44
+ if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return v4(g[6], g[7]); // ::ffff:0:0:0/96 translated
45
+ if (g[0] === 0x64 && g[1] === 0xff9b) return zero(2, 6) ? v4(g[6], g[7]) : true; // NAT64 64:ff9b::/96; 64:ff9b:1::/48 local
46
+ if (g[0] === 0x2002) return v4(g[1], g[2]); // 6to4
47
+ if ((g[0] & 0xe000) !== 0x2000) return true; // not global unicast: 100::/64, fc00::/7, fe80::/10, fec0::/10, ff00::/8, ...
48
+ return g[0] === 0x2001 && (g[1] === 0 || g[1] === 0xdb8 || (g[1] === 2 && g[2] === 0)) // Teredo, documentation, benchmarking
49
+ || (g[0] & 0xfff0) === 0x3ff0; // 3fff::/20 documentation
50
+ }
@@ -43,6 +43,9 @@ function passed(output) {
43
43
  // What an error says, without what varies between runs: numbers, ids,
44
44
  // temporary paths and quoted values.
45
45
  export const errorShape = text => clean(text,180).replace(/\/(?:tmp|var\/folders|private)\/\S*/g,'<tmp>').replace(/\b[0-9a-f]{8,}\b/gi,'<id>').replace(/\d+/g,'N').replace(/(["'`])[^"'`]{1,80}\1/g,'$1…$1');
46
+ // A command without what varies between runs (temporary paths, ids,
47
+ // numbers): \`curl service-a\` and \`curl service-b\` stay two commands.
48
+ const commandShape = text => clean(text,160).replace(/\/(?:tmp|var\/folders|private)\/\S*/g,'<tmp>').replace(/\b[0-9a-f]{8,}\b/gi,'<id>').replace(/\d+/g,'N');
46
49
  const ERROR_LINE = /error|fail|exception|not found|no such|denied|refused|cannot|unknown (?:option|flag|command)|invalid|EADDRINUSE|ENOENT|EACCES/i;
47
50
  const MAX_LESSONS = 3;
48
51
 
@@ -55,7 +58,7 @@ export class CheckLessons {
55
58
  if(/managed[-_](?:tool[-_]cancelled|operation[-_]denied|helper[-_]cancelled)|Command aborted|Command timed out/i.test(output))return;
56
59
  const line=output.split('\n').find(l=>ERROR_LINE.test(l));
57
60
  if(!line)return;
58
- const command=clean(call.command,160).split(' ')[0];
61
+ const command=commandShape(call.command);
59
62
  if(!command)return;
60
63
  const shape=errorShape(line), key=hash(command+'\0'+shape), seen=this.errors.get(key);
61
64
  if(!seen){if(this.errors.size>=40)this.errors.delete(this.errors.keys().next().value);this.errors.set(key,{id:event.toolCallId,command:clean(call.command,160),error:clean(line,180),done:false});return;}
@@ -78,7 +78,7 @@ export class TeamMemory {
78
78
  const scope = this.scope(), id = scopeID(scope);
79
79
  if (id !== this.scopeHash) {
80
80
  this.scopeHash = id; this.outbox = new MemoryOutbox(this.stateDir, scope, this.now);
81
- this.protocol = 0; this.briefed = false; this.offUntil = 0; this.retain = true;
81
+ this.protocol = 0; this.briefed = false; this.offUntil = 0; this.retain = true; this.serves = new RoundServes(); // served under the old scope
82
82
  }
83
83
  return scope;
84
84
  }
@@ -3,6 +3,9 @@ import http from 'node:http';
3
3
  import net from 'node:net';
4
4
  import path from 'node:path';
5
5
  import { lookup } from 'node:dns/promises';
6
+ import { privateAddress } from './ip-policy.mjs';
7
+
8
+ export { privateAddress };
6
9
 
7
10
  // The package registries a company command reaches without asking: plain
8
11
  // bash has no network, except through this proxy, which the trusted runtime
@@ -10,6 +13,10 @@ import { lookup } from 'node:dns/promises';
10
13
  // HTTP to the hosts below and the member's own additions, on ports 443 and
11
14
  // 80 only, never to an IP literal or a name that resolves to this machine or
12
15
  // a private network (DNS rebinding), and connects to the address it checked.
16
+ // With a company upstream proxy (HTTPS_PROXY) the name is still looked up and
17
+ // checked here, and the upstream is asked for that checked address, never the
18
+ // name, so it cannot resolve it elsewhere; a name this machine cannot look up
19
+ // is refused (run_with_network still reaches it after the member approves).
13
20
  // Everything else is refused with 403 and remembered, so the command's
14
21
  // failure names the host and run_with_network (the member approves it).
15
22
  // Registries (and GitHub) take no upload without a token, and tokens stay
@@ -50,17 +57,8 @@ export function domainAllowed(host, domains) {
50
57
  return domains.some(rule => rule.startsWith('*.') ? name.endsWith(rule.slice(1)) && name.length > rule.length - 1 : name === rule);
51
58
  }
52
59
 
53
- // Addresses that are this machine, a private network or not routable.
54
- export function privateAddress(address) {
55
- if (net.isIPv4(address)) {
56
- const [a, b] = address.split('.').map(Number);
57
- return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b < 32)
58
- || (a === 192 && b === 168) || (a === 198 && (b === 18 || b === 19)) || a >= 224;
59
- }
60
- const v6 = address.toLowerCase();
61
- if (v6.startsWith('::ffff:')) return privateAddress(v6.slice(7));
62
- return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || v6.startsWith('ff');
63
- }
60
+ // host:port of a checked address, as a CONNECT target or URL host.
61
+ const authority = ({ host, port }) => `${net.isIPv6(host) ? `[${host}]` : host}:${port}`;
64
62
 
65
63
  function upstreamProxy(env) {
66
64
  for (const name of ['HTTPS_PROXY', 'https_proxy']) {
@@ -101,7 +99,6 @@ export class PackageProxy {
101
99
  // The checked public address of an allowed host, or null.
102
100
  async #target(host, port) {
103
101
  if (!this.ports.has(port) || !domainAllowed(host, this.domains)) { this.#deny(host); return null; }
104
- if (this.upstream) return { host, port };
105
102
  try {
106
103
  const addresses = await this.resolve(host);
107
104
  if (!addresses.length || addresses.some(entry => this.isPrivate(entry.address))) { this.#deny(host); return null; }
@@ -117,7 +114,7 @@ export class PackageProxy {
117
114
  upstream.setTimeout(10 * 60_000, () => upstream.destroy());
118
115
  upstream.once('connect', () => {
119
116
  if (this.upstream) {
120
- upstream.write(`CONNECT ${match[1]}:${match[2]} HTTP/1.1\r\nHost: ${match[1]}:${match[2]}\r\n\r\n`);
117
+ upstream.write(`CONNECT ${authority(target)} HTTP/1.1\r\nHost: ${authority(target)}\r\n\r\n`);
121
118
  let reply = Buffer.alloc(0);
122
119
  const onData = chunk => {
123
120
  reply = Buffer.concat([reply, chunk]);
@@ -144,8 +141,9 @@ export class PackageProxy {
144
141
  const target = await this.#target(url.hostname, Number(url.port) || 80);
145
142
  if (!target) { response.writeHead(403, { 'X-Piagent-Proxy': 'domain-not-allowed', 'Content-Type': 'text/plain; charset=utf-8' }).end(`piagent: ${url.hostname} is not an allowed package registry\n`); return; }
146
143
  const headers = { ...request.headers, host: url.host }; delete headers['proxy-connection']; delete headers['proxy-authorization'];
144
+ const pinned = new URL(url); pinned.host = authority(target);
147
145
  const outgoing = http.request(this.upstream
148
- ? { host: this.upstream.host, port: this.upstream.port, path: url.href, method: request.method, headers }
146
+ ? { host: this.upstream.host, port: this.upstream.port, path: pinned.href, method: request.method, headers }
149
147
  : { host: target.host, port: target.port, path: url.pathname + url.search, method: request.method, headers, servername: url.hostname }, incoming => {
150
148
  response.writeHead(incoming.statusCode ?? 502, incoming.headers); incoming.pipe(response);
151
149
  });
@@ -1,11 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { fileURLToPath } from 'node:url';
4
+ import { invokeReviewSnapshot } from './review-snapshot-request.mjs';
5
5
 
6
- const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
7
6
  const textContent = result => result.content?.filter(p => p.type === 'text').map(p => p.text).join('\n') ?? '';
8
- const WORKER = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
9
7
  const MAX_SNAPSHOT = 16 * 1024 * 1024;
10
8
 
11
9
  // The worker runs inside the tool boundary; what it prints is one short line.
@@ -20,8 +18,8 @@ async function worker(boundary, mode, scope = null, base = null) {
20
18
  fs.writeFileSync(path.join(boundary.temporary, scopeName), JSON.stringify([...scope]), { mode: 0o600, flag: 'wx' });
21
19
  }
22
20
  let result;
23
- const from = typeof base === 'string' && BASE.test(base) ? ' ' + base : '';
24
- try { result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} ${mode}${scopeName ? ' ' + scopeName : ''}${from}`, timeout: 60 }); }
21
+ const extra = [...(scopeName ? [scopeName] : []), ...(typeof base === 'string' && BASE.test(base) ? [base] : [])];
22
+ try { result = await invokeReviewSnapshot(boundary, mode, extra); }
25
23
  finally { if (scopeName) fs.rmSync(path.join(boundary.temporary, scopeName), { force: true }); }
26
24
  const text = textContent(result).trim();
27
25
  if (result.details?.truncation?.truncated || text.includes('\n')) throw Error(/review-(patch|file|untracked)-limit/.exec(text)?.[0] ?? 'managed-review-patch-unreadable');
@@ -37,7 +35,7 @@ export async function readPatchDigest(boundary, scope = null, base = null) { ret
37
35
  // The commit HEAD names now, or null (no repository, no commit yet).
38
36
  export async function readHead(boundary) {
39
37
  try {
40
- const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} head`, timeout: 60 });
38
+ const result = await invokeReviewSnapshot(boundary, 'head');
41
39
  const text = textContent(result).trim();
42
40
  return BASE.test(text) ? text : null;
43
41
  } catch { return null; }
@@ -48,7 +46,7 @@ export async function readHead(boundary) {
48
46
  export async function resolveReviewBase(boundary, ref) {
49
47
  if (typeof ref !== 'string' || !/^[A-Za-z0-9._\/~^@{}-]{1,200}$/.test(ref) || ref.startsWith('-')) return null;
50
48
  try {
51
- const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} since ${quote(ref)}`, timeout: 60 });
49
+ const result = await invokeReviewSnapshot(boundary, 'since', [ref]);
52
50
  const text = textContent(result).trim();
53
51
  return BASE.test(text) ? text : null;
54
52
  } catch { return null; }
@@ -57,7 +55,7 @@ export async function resolveReviewBase(boundary, ref) {
57
55
  // came since: { base, ref }, or null.
58
56
  export async function readBranchBase(boundary) {
59
57
  try {
60
- const result = await boundary.invoke('bash', { command: `${quote(process.execPath)} ${quote(WORKER)} branch-base`, timeout: 60 });
58
+ const result = await invokeReviewSnapshot(boundary, 'branch-base');
61
59
  const [base, ref] = textContent(result).trim().split(' ');
62
60
  return BASE.test(base ?? '') && ref ? { base, ref } : null;
63
61
  } catch { return null; }
@@ -1,12 +1,22 @@
1
1
  // A company conversation's access, chosen by the member and kept with the
2
2
  // conversation: "workspace-write" asks before every command that needs the
3
- // internet; "trusted-full-access" (Bypass) asks only for what must be
4
- // confirmed (runtime/policy/bypass-policy.mjs).
3
+ // internet; "trusted-full-access" (Full access) asks only for what must be
4
+ // confirmed (runtime/policy/bypass-policy.mjs); "bypass" asks nothing, as
5
+ // Codex's bypass mode, where the team's Harness allows it
6
+ // (harness.configuration.bypass_mode, an administrator's switch in Studio).
7
+ // The sandbox itself stays: bypass removes the questions, not the boundary.
5
8
  import { mustConfirm } from '../runtime/policy/bypass-policy.mjs';
6
9
 
7
- export const PERMISSIONS = ['workspace-write', 'trusted-full-access'];
10
+ export const PERMISSIONS = ['workspace-write', 'trusted-full-access', 'bypass'];
8
11
  const ENTRY = 'agent-watch-permission';
9
12
 
13
+ // Only an explicit yes from Studio: an Agent Watch that does not carry the
14
+ // field, or a Harness an administrator switched off, gives no bypass.
15
+ export const bypassAllowed = manifest => manifest?.harness?.configuration?.bypass_mode === true;
16
+ // What applies now: a conversation left in bypass after the administrator
17
+ // switched it off falls back to Full access.
18
+ export const effectivePermission = owner => owner.permission === 'bypass' && !bypassAllowed(owner.manifest) ? 'trusted-full-access' : owner.permission;
19
+
10
20
  export function restorePermission(manager) {
11
21
  const chosen = manager.getEntries().filter(e => e.type === 'custom' && e.customType === ENTRY).at(-1)?.data?.mode;
12
22
  return PERMISSIONS.includes(chosen) ? chosen : 'workspace-write';
@@ -14,9 +24,22 @@ export function restorePermission(manager) {
14
24
  export function permissionSetter(owner, manager) {
15
25
  return mode => {
16
26
  if (!PERMISSIONS.includes(mode)) throw Error('managed-permission-unavailable');
27
+ if (mode === 'bypass' && !bypassAllowed(owner.manifest)) throw Error('managed-bypass-unavailable');
17
28
  manager.appendCustomEntry(ENTRY, { mode, at: new Date().toISOString() }); owner.permission = mode;
18
29
  };
19
30
  }
20
- // Why a network command must still be asked about: null runs it in Bypass,
21
- // 'ask' when the member asks first.
22
- export const networkConfirmation = (owner, command) => owner.permission === 'trusted-full-access' ? mustConfirm(command) : 'ask';
31
+ // The conversation records whether its Harness allows Bypass, so the
32
+ // dashboard offers it (or not) without the runtime.
33
+ const ALLOWANCE = 'agent-watch-bypass';
34
+ export function recordBypass(owner, manager) {
35
+ const allowed = bypassAllowed(owner.manifest);
36
+ const last = manager.getEntries().filter(e => e.type === 'custom' && e.customType === ALLOWANCE).at(-1)?.data;
37
+ if ((last?.allowed === true) !== allowed) manager.appendCustomEntry(ALLOWANCE, { allowed, at: new Date().toISOString() });
38
+ }
39
+ // Whether this conversation may skip the questions Full access still asks.
40
+ export const asksNothing = owner => effectivePermission(owner) === 'bypass';
41
+ export const broadAccess = owner => ['trusted-full-access', 'bypass'].includes(effectivePermission(owner));
42
+ // Why a network command must still be asked about: null runs it in Full
43
+ // access or Bypass, 'ask' when the member asks first.
44
+ export const networkConfirmation = async (owner, command) => asksNothing(owner) ? null
45
+ : effectivePermission(owner) === 'trusted-full-access' ? mustConfirm(command) : 'ask';
@@ -1,3 +1,4 @@
1
+ import { broadAccess } from './permission.mjs';
1
2
  import fs from 'node:fs';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -112,7 +113,7 @@ export function fetchOriginTool(self) {
112
113
  parameters:{type:'object',properties:{path:{type:'string',maxLength:500,description:'Repository folder inside the conversation folder, such as "api"; omit when the conversation folder is the repository.'}},additionalProperties:false},
113
114
  execute:async(id,args,signal,_update,ctx)=>{
114
115
  const plan=await repositoryFetchPlan(self.boundary,args?.path);
115
- if(self.permission==='trusted-full-access'&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
116
+ if(broadAccess(self)&&!signal?.aborted)return executeRepositoryFetch(self.boundary,plan,signal);
116
117
  const {piApprovalBroker}=await import('../runtime/inspection/approval-broker.ts');
117
118
  const decision=await piApprovalBroker.request({cwd:self.cwd,rawSessionId:self.session.sessionManager.getSessionId(),toolCallId:id,
118
119
  action:{kind:'external-provider-action',preconditionClass:'runtime-only',toolName:'fetch_origin',rawAction:plan,
@@ -0,0 +1,21 @@
1
+ import { fileURLToPath } from 'node:url';
2
+
3
+ const worker = fileURLToPath(new URL('./review-snapshot-worker.mjs', import.meta.url));
4
+ const requests = new WeakSet();
5
+ const quote = value => "'" + value.replaceAll("'", "'\"'\"'") + "'";
6
+
7
+ // Only fixed runtime snapshot reads preserve provenance. JSON tool arguments
8
+ // cannot forge this identity; freezing prevents substitution before dispatch.
9
+ export function invokeReviewSnapshot(boundary, mode, extra = []) {
10
+ if (!['head', 'since', 'branch-base', 'digest', 'file', 'files'].includes(mode)
11
+ || !Array.isArray(extra) || extra.some(value => typeof value !== 'string')) throw Error('review-request-invalid');
12
+ const args = Object.freeze({ command: [process.execPath, worker, mode, ...extra].map(quote).join(' '), timeout: 60 });
13
+ requests.add(args);
14
+ return boundary.invoke('bash', args);
15
+ }
16
+
17
+ export function consumeReviewSnapshot(args) {
18
+ const trusted = requests.has(args);
19
+ requests.delete(args);
20
+ return trusted;
21
+ }
@@ -1,3 +1,5 @@
1
+ export { companyTerminalDiagnostic } from './terminal-runtime.mjs';
2
+
1
3
  // What a failed command tells the agent. Every environment failure names the
2
4
  // way on (owner, 2026-10-13): a tool that asks the member's approval
3
5
  // (run_with_network, run_with_docker, start_docker, fetch_origin, run_on_host)
@@ -0,0 +1,37 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { freshnessPath } from './tool-freshness.mjs';
3
+
4
+ // Trusted sandbox metadata only. No project filesystem access in the parent.
5
+ // Every consumer must revalidate policy and digests inside the boundary.
6
+ export class SessionMutationObservations {
7
+ #sessionId = randomUUID();
8
+ #files = new Map();
9
+ get(file) { return structuredClone(this.#files.get(file) ?? null); }
10
+ snapshot() { return structuredClone([...this.#files.entries()]); }
11
+ clear() { this.#files.clear(); }
12
+ prepare(cwd, name, args, enabled) {
13
+ const file = freshnessPath(cwd, name, args);
14
+ const request = enabled ? { sessionId: this.#sessionId, toolCallId: randomUUID(), prior: this.get(file) } : undefined;
15
+ if (['edit', 'write'].includes(name)) this.#files.delete(file);
16
+ if (name === 'bash') this.clear();
17
+ if (name === 'workspace' && args?.kind === 'write') this.#files.delete(args.payload?.path);
18
+ return request;
19
+ }
20
+ acceptRevert(args, trusted, result, update) {
21
+ if (args?.kind !== 'source' || args.request?.kind !== 'execute') return;
22
+ const file = args.request.target?.path, prior = this.#files.get(file);
23
+ this.#files.delete(file);
24
+ const next = update?.observation;
25
+ if (!trusted || result?.state !== 'settled' || update?.path !== file || !prior || !next || next.path !== file
26
+ || next.toolCallId !== prior.toolCallId || JSON.stringify(next.baseline) !== JSON.stringify(prior.baseline)
27
+ || !/^[a-f0-9]{64}$/.test(next.currentDigest) || !/^session-mutation\.[a-f0-9]{64}$/.test(next.evidenceRef)) return;
28
+ this.#files.set(file, structuredClone(next));
29
+ }
30
+ accept(cwd, name, args, request, observation) {
31
+ const file = freshnessPath(cwd, name, args);
32
+ if (!request || !file || observation?.path !== file || observation.toolCallId !== request.toolCallId
33
+ || typeof observation.baseline?.content !== 'string' || Buffer.byteLength(observation.baseline.content) > 256 * 1024) return;
34
+ this.#files.set(file, observation);
35
+ while (this.#files.size > 32) this.#files.delete(this.#files.keys().next().value);
36
+ }
37
+ }
@@ -13,7 +13,7 @@ import { managedThinkingLevels, nearestLevel } from './capabilities.mjs';
13
13
  import { nativeManagedModel, isVendor, piProvider, studioAPI, studioPath } from './native-catalog.mjs';
14
14
  import { fetchOriginTool } from './repository-operation.mjs';
15
15
  import { describeFailure, failureCode, failureText } from '../runtime/managed-failure.mjs';
16
- import { enableCompanyTurnRetry, wrapRoleStreams, conversationSession } from './request-stream.mjs'; import { StudioLink, viaLink } from './studio-link.mjs';
16
+ import { enableCompanyTurnRetry, wrapRoleStreams, conversationSession } from './request-stream.mjs'; import { StudioLink, closeRun } from './studio-link.mjs';
17
17
  import { trackToolFailures } from './tool-failures.mjs';
18
18
  import { fastServed, fastSetter, fastState, recordFast, restoreFast } from './fast-mode.mjs';
19
19
  import { runHelper } from './helper-run.mjs';
@@ -26,7 +26,7 @@ import { workflowPolicy, repositoryChecks, planTool, currentPlan, PLAN_ENTRY, wo
26
26
  import { beforeDelegate } from './objections.mjs';
27
27
  import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription } from './helper-roles.mjs'; import { delegateParameters, delegateArgs, SPEC_PROMPT } from './helper-settlement.mjs';
28
28
  import { loadAgentResources } from './agent-skills.mjs';
29
- import { restorePermission, permissionSetter } from './permission.mjs';
29
+ import { restorePermission, recordBypass, permissionSetter } from './permission.mjs';
30
30
  import { commandTools, servicesPrompt } from './command-tools.mjs'; import { attachMemory, memoryToolsFor, openRound, closeRound, prepareMemoryContext, trackMemoryLessons } from './memory.mjs';
31
31
 
32
32
  const PROVIDER = 'agent_watch_managed';
@@ -169,7 +169,7 @@ export class ManagedSession {
169
169
  self.session.managedExecution = true;
170
170
  self.turnRetry = enableCompanyTurnRetry(self.session, settings, self.studioLink);
171
171
  // The member's access for this conversation: ask first, or Bypass (permission.mjs).
172
- self.permission = restorePermission(manager); self.session.managedSetPermission = permissionSetter(self, manager);
172
+ self.permission = restorePermission(manager); recordBypass(self, manager); self.session.managedSetPermission = permissionSetter(self, manager);
173
173
  // Fast mode, where the Harness allows it (fast-mode.mjs): off until the member turns it on.
174
174
  self.fast = restoreFast(manager); recordFast(self, manager); self.session.managedSetFast = fastSetter(self, manager); self.session.managedFastState = () => fastState(self); // null where the Harness does not allow it
175
175
  self.session.managedFastServed = () => { const route = self.routes.get(self.modelRuntime); return route ? fastServed(route) : null; };
@@ -296,15 +296,14 @@ export class ManagedSession {
296
296
  // the newest version the broker advertises.
297
297
  const features = self.manifest.broker_features ?? [];
298
298
  const process = self.run && features.includes('process') ? self.run.report(currentPlan(manager), features.includes('process-v3') ? 3 : features.includes('process-v2') ? 2 : 1) : null;
299
- try { await viaLink(self.studioLink, () => self.broker.request('close', process ? { process } : {}), { conversation: conversationSession(self), runId: self.grant.run_id }); } // only a refused report closes again without it; an unconfirmed close fails the turn
300
- catch (error) { if (!process || String(error?.message) !== 'managed-broker:invalidResponse') throw error; await viaLink(self.studioLink, () => self.broker.request('close'), { conversation: conversationSession(self), runId: self.grant.run_id }); }
301
- manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
299
+ // A close Studio may not have had stays "active" here (closeRun).
300
+ if (await closeRun(self.studioLink, self.broker, features, process, { conversation: conversationSession(self), runId: self.grant.run_id })) manager.appendCustomEntry('agent-watch-run', { run_id: self.grant.run_id, state: 'closed' });
302
301
  }
303
302
  // A run that could not be closed (the network or Agent Watch went away)
304
303
  // ends the turn as failed; the run stays "active" here, so reopening
305
304
  // the conversation recovers or abandons it.
306
305
  } catch (error) { round.closeFailed = failureCode(failureText(error)) || 'close_failed'; }
307
- finally { self.grant = null; self.run = null; self.preflight = null; }
306
+ finally { self.grant = null; self.run = null; self.preflight = null; self.unclosed = null; }
308
307
  }
309
308
  };
310
309
  self.session.prompt = async (text, options) => {
@@ -5,9 +5,17 @@
5
5
  // Piagent never edits a skill itself and makes no model call for this.
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
8
9
 
9
10
  const MIN_FAILURES = 3, MIN_SHARE = 0.5, KEEP = 10, REPROPOSE_MS = 7 * 86400_000;
10
- const safeName = name => String(name).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 80);
11
+ // One file name per skill name: a name with other characters (or capitals,
12
+ // on a case-insensitive disk) gets a digest of itself, so \`a/b\` and \`a_b\`
13
+ // keep their own counts.
14
+ const safeName = name => {
15
+ name = String(name);
16
+ if (/^[a-z0-9_.-]{1,80}$/.test(name) && !/^\.+$/.test(name)) return name;
17
+ return `${name.replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 60)}~${createHash('sha256').update(name).digest('hex').slice(0, 12)}`;
18
+ };
11
19
 
12
20
  export class SkillFeedback {
13
21
  // A managed round's tracker, when the conversation has skills.
@@ -0,0 +1,21 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ // Authorization lives outside JSON arguments. Model tools cannot manufacture
4
+ // the object identity needed to obtain the boundary's key or observations.
5
+ export class SourceRevertRequests {
6
+ #key = randomBytes(32).toString('hex');
7
+ #requests = new WeakMap();
8
+ get revision() { return `source-control.${createHash("sha256").update(this.#key).digest("hex")}`; }
9
+ request(request, binding) {
10
+ const args = { kind: 'source', request: structuredClone(request) };
11
+ this.#requests.set(args, binding);
12
+ return args;
13
+ }
14
+ consume(args, observations) {
15
+ if (args?.kind !== 'source') return undefined;
16
+ const binding = this.#requests.get(args); this.#requests.delete(args);
17
+ if (!binding || !binding.current() || !binding.idle()) throw new Error('source-session-unavailable');
18
+ return { identity: structuredClone(binding.identity), rawSessionId: binding.rawSessionId,
19
+ controlRevision: binding.controlRevision, confirmationKey: this.#key, observations: observations.snapshot() };
20
+ }
21
+ }
@@ -269,3 +269,19 @@ export async function viaLink(link, ask, { conversation = null, runId = null } =
269
269
  }
270
270
  }
271
271
  }
272
+
273
+ // Closing a round's run, asked again through the link. Only a refused report
274
+ // closes again without it; an unconfirmed close throws (the turn fails). An
275
+ // Agent Watch without close-retry forgets a run whose close failed, so asking
276
+ // again "succeeds" without reaching Studio: false then, and the run stays
277
+ // "active" for the runtime (reopening recovers or abandons it).
278
+ export async function closeRun(link, broker, features, process, where) {
279
+ let unsent = false;
280
+ const close = args => viaLink(link, () => broker.request('close', args).catch(error => {
281
+ if (!features.includes('close-retry') && String(error?.message) !== 'managed-broker:invalidResponse') unsent = true;
282
+ throw error;
283
+ }), where);
284
+ try { await close(process ? { process } : {}); }
285
+ catch (error) { if (!process || String(error?.message) !== 'managed-broker:invalidResponse') throw error; await close(); }
286
+ return !unsent;
287
+ }
@@ -0,0 +1,88 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { BWRAP, bubblewrapArgs, credentialFiles } from './linux-sandbox.mjs';
8
+ import { companyTerminalRuntime } from './terminal-runtime.mjs';
9
+ import { terminalChannel } from './terminal-channel.mjs';
10
+ import { SEATBELT_TERMINAL_RULES, seatbeltTerminal } from './terminal-seatbelt.mjs';
11
+
12
+ const worker = fileURLToPath(new URL('./terminal-worker.mjs', import.meta.url));
13
+ const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
14
+ const inside = (file, root) => file === root || file.startsWith(root + path.sep);
15
+ const dimensions = ({ cols, rows }) => Number.isInteger(cols) && cols >= 2 && cols <= 500 && Number.isInteger(rows) && rows >= 1 && rows <= 200;
16
+
17
+ export class BoundaryTerminals {
18
+ #boundary; #runtime; #workerDigest; #entries = new Map(); #closed = false;
19
+ reasonCode = null;
20
+ constructor(boundary) {
21
+ this.#boundary = boundary;
22
+ try {
23
+ this.#runtime = companyTerminalRuntime();
24
+ if (inside(this.#runtime.root, boundary.cwd) || inside(worker, boundary.cwd)) throw Error('managed-terminal-runtime-overlap');
25
+ this.#workerDigest = hash(worker);
26
+ } catch (error) { this.reasonCode = /^managed-terminal-/.test(error.message) ? error.message : 'managed-terminal-runtime-invalid'; }
27
+ }
28
+ async open(size, emit) {
29
+ if (this.#closed) throw Error('managed-terminal-boundary-closed');
30
+ if (this.reasonCode) throw Error(this.reasonCode);
31
+ const boundary = this.#boundary;
32
+ if (!boundary.allowed.includes('bash')) throw Error('managed-terminal-permission-denied');
33
+ if (!dimensions(size)) throw Error('managed-terminal-invalid-size');
34
+ if (this.#entries.size >= 4) throw Error('managed-terminal-limit');
35
+ this.#runtime.assertCurrent();
36
+ if (hash(worker) !== this.#workerDigest) throw Error('managed-terminal-runtime-changed');
37
+ for (const [file, digest] of boundary.pins) if (hash(file) !== digest) throw Error('managed-runtime-changed');
38
+ const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'piagent-terminal-')));
39
+ fs.chmodSync(temporary, 0o700);
40
+ let channel;
41
+ try {
42
+ const home = path.join(temporary, 'home'), empty = path.join(temporary, 'empty');
43
+ fs.mkdirSync(home, { mode: 0o700 }); fs.writeFileSync(empty, '', { mode: 0o400 });
44
+ const env = Object.fromEntries(Object.entries(boundary.environment.offline)
45
+ .filter(([key]) => !/proxy/i.test(key) && !['NODE_OPTIONS', 'NODE_PATH', 'BASH_ENV', 'ENV', 'PIAGENT_TOOL_SCOPE'].includes(key))
46
+ .map(([key, value]) => [key, String(value).split(boundary.temporary).join(temporary)]));
47
+ Object.assign(env, { HOME: home, TMPDIR: temporary, TERM: 'xterm-256color', BASH_SILENCE_DEPRECATION_WARNING: '1',
48
+ PIAGENT_PROCESS_SCOPE: `${boundary.scope}:${randomUUID()}` });
49
+ // A separate temporary root excludes the boundary's package-proxy socket.
50
+ // Writes reach the project, its Git directories and the project's
51
+ // package caches, as the agent's own offline commands do.
52
+ if (!boundary.linux) channel = seatbeltTerminal({ entry: this.#runtime.entry, cwd: boundary.cwd, env, size, emit,
53
+ profile: boundary.terminalProfile(temporary) + SEATBELT_TERMINAL_RULES });
54
+ else {
55
+ const base = boundary.linuxBase;
56
+ const protectedPaths = (base.denied ?? []).filter(file => fs.existsSync(file))
57
+ .map(file => ({ path: file, directory: fs.statSync(file).isDirectory() }));
58
+ const args = bubblewrapArgs({ ...base, temporary, empty, network: false, proxySocket: null, docker: null,
59
+ toolchains: [...base.toolchains, this.#runtime.root],
60
+ credentials: [...credentialFiles([boundary.cwd, ...base.repository, ...base.references]), ...protectedPaths] });
61
+ // There is intentionally no alternative spawn path or injected host shell.
62
+ const child = spawn(BWRAP, [...args, boundary.node, worker, this.#runtime.entry,
63
+ fs.readlinkSync('/proc/self/ns/pid'), fs.readlinkSync('/proc/self/ns/net'), String(size.cols), String(size.rows)],
64
+ { cwd: boundary.cwd, detached: true, stdio: ['pipe', 'pipe', 'pipe'], env });
65
+ channel = terminalChannel(child, emit);
66
+ }
67
+ const id = randomUUID();
68
+ this.#entries.set(id, channel);
69
+ void channel.completed.then(() => { this.#entries.delete(id); fs.rmSync(temporary, { recursive: true, force: true }); }).catch(() => {});
70
+ await channel.initialized;
71
+ return { id, input(data) {
72
+ if (typeof data !== 'string' || Buffer.byteLength(data) > 16384) return Promise.reject(Error('managed-terminal-input-limit'));
73
+ return channel.execute({ kind: 'input', data });
74
+ }, resize(size) {
75
+ if (!dimensions(size)) return Promise.reject(Error('managed-terminal-invalid-size'));
76
+ return channel.execute({ kind: 'resize', cols: size.cols, rows: size.rows });
77
+ }, close: channel.close };
78
+ } catch (error) {
79
+ if (channel) await channel.close();
80
+ else fs.rmSync(temporary, { recursive: true, force: true });
81
+ throw error;
82
+ }
83
+ }
84
+ async dispose() {
85
+ this.#closed = true;
86
+ await Promise.all([...this.#entries.values()].map(channel => channel.close()));
87
+ }
88
+ }