@selesai/code 0.13.16 → 0.13.19

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 (479) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +2 -14
  3. package/dist/bun/cli.d.ts +3 -1
  4. package/dist/bun/cli.js +3 -9
  5. package/dist/bun/{register-bedrock.js → runtime-setup.js} +5 -0
  6. package/dist/bun/sandbox-env-setup.d.ts +1 -0
  7. package/dist/bun/sandbox-env-setup.js +3 -0
  8. package/dist/cli/auth-command.d.ts +23 -0
  9. package/dist/cli/auth-command.js +102 -0
  10. package/dist/cli/config-selector.js +2 -1
  11. package/dist/cli/setup.d.ts +1 -0
  12. package/dist/cli/setup.js +11 -0
  13. package/dist/cli.js +2 -14
  14. package/dist/config.d.ts +3 -2
  15. package/dist/config.js +9 -4
  16. package/dist/core/agent-session-runtime.js +15 -7
  17. package/dist/core/agent-session.d.ts +2 -6
  18. package/dist/core/agent-session.js +15 -4
  19. package/dist/core/compaction/branch-summarization.d.ts +1 -1
  20. package/dist/core/compaction/branch-summarization.js +2 -1
  21. package/dist/core/extensions/loader.js +3 -0
  22. package/dist/core/extensions/types.d.ts +16 -2
  23. package/dist/core/http-dispatcher.js +2 -0
  24. package/dist/core/keybindings.d.ts +5 -0
  25. package/dist/core/keybindings.js +4 -0
  26. package/dist/core/messages.d.ts +1 -1
  27. package/dist/core/model-config.d.ts +15 -0
  28. package/dist/core/model-config.js +3 -0
  29. package/dist/core/model-runtime.d.ts +1 -0
  30. package/dist/core/model-runtime.js +5 -2
  31. package/dist/core/provider-composer.js +8 -2
  32. package/dist/core/session-export.d.ts +3 -0
  33. package/dist/core/session-export.js +31 -0
  34. package/dist/core/session-manager.d.ts +3 -2
  35. package/dist/core/session-manager.js +40 -13
  36. package/dist/core/skills.d.ts +1 -1
  37. package/dist/core/skills.js +4 -2
  38. package/dist/core/system-prompt.js +10 -10
  39. package/dist/core/tools/bash.js +3 -117
  40. package/dist/core/tools/edit.d.ts +1 -12
  41. package/dist/core/tools/edit.js +5 -163
  42. package/dist/core/tools/find.js +4 -55
  43. package/dist/core/tools/grep.js +4 -60
  44. package/dist/core/tools/ls.js +4 -49
  45. package/dist/core/tools/read.d.ts +4 -0
  46. package/dist/core/tools/read.js +10 -122
  47. package/dist/core/tools/renderers/bash.d.ts +11 -0
  48. package/dist/core/tools/renderers/bash.js +129 -0
  49. package/dist/core/tools/renderers/edit.d.ts +22 -0
  50. package/dist/core/tools/renderers/edit.js +166 -0
  51. package/dist/core/tools/renderers/find.d.ts +9 -0
  52. package/dist/core/tools/renderers/find.js +63 -0
  53. package/dist/core/tools/renderers/grep.d.ts +9 -0
  54. package/dist/core/tools/renderers/grep.js +68 -0
  55. package/dist/core/tools/renderers/index.d.ts +27 -0
  56. package/dist/core/tools/renderers/index.js +46 -0
  57. package/dist/core/tools/renderers/ls.d.ts +9 -0
  58. package/dist/core/tools/renderers/ls.js +57 -0
  59. package/dist/core/tools/renderers/read.d.ts +10 -0
  60. package/dist/core/tools/renderers/read.js +128 -0
  61. package/dist/core/tools/renderers/write.d.ts +9 -0
  62. package/dist/core/tools/renderers/write.js +152 -0
  63. package/dist/core/tools/write.js +5 -146
  64. package/dist/extensions/agent-browser.ts +1 -2
  65. package/dist/extensions/package.json +0 -1
  66. package/dist/extensions/pi-hermes-memory/CHANGELOG.md +18 -0
  67. package/dist/extensions/pi-hermes-memory/README.md +16 -12
  68. package/dist/extensions/pi-hermes-memory/docs/ROADMAP.md +1 -1
  69. package/dist/extensions/pi-hermes-memory/package-lock.json +587 -62
  70. package/dist/extensions/pi-hermes-memory/package.json +4 -4
  71. package/dist/extensions/pi-hermes-memory/src/config.ts +32 -0
  72. package/dist/extensions/pi-hermes-memory/src/constants.ts +12 -2
  73. package/dist/extensions/pi-hermes-memory/src/handlers/background-review.ts +109 -51
  74. package/dist/extensions/pi-hermes-memory/src/handlers/index-sessions.ts +9 -3
  75. package/dist/extensions/pi-hermes-memory/src/handlers/review-memory-ops.ts +257 -98
  76. package/dist/extensions/pi-hermes-memory/src/handlers/session-backfill.ts +9 -2
  77. package/dist/extensions/pi-hermes-memory/src/handlers/sync-markdown-memories.ts +5 -0
  78. package/dist/extensions/pi-hermes-memory/src/index.ts +22 -2
  79. package/dist/extensions/pi-hermes-memory/src/paths.ts +1 -1
  80. package/dist/extensions/pi-hermes-memory/src/store/db.ts +6 -0
  81. package/dist/extensions/pi-hermes-memory/src/store/fts-query.ts +58 -1
  82. package/dist/extensions/pi-hermes-memory/src/store/memory-store.ts +60 -8
  83. package/dist/extensions/pi-hermes-memory/src/store/session-indexer.ts +169 -7
  84. package/dist/extensions/pi-hermes-memory/src/store/session-search.ts +7 -21
  85. package/dist/extensions/pi-hermes-memory/src/store/sqlite-memory-store.ts +134 -15
  86. package/dist/extensions/pi-hermes-memory/src/tools/memory-search-tool.ts +4 -2
  87. package/dist/extensions/pi-hermes-memory/src/tools/memory-tool.ts +24 -9
  88. package/dist/extensions/pi-hermes-memory/src/types.ts +11 -0
  89. package/dist/extensions/pi-hermes-memory/tests/config.test.ts +42 -0
  90. package/dist/extensions/pi-hermes-memory/tests/handlers/background-review.test.ts +159 -3
  91. package/dist/extensions/pi-hermes-memory/tests/handlers/review-memory-ops.test.ts +452 -0
  92. package/dist/extensions/pi-hermes-memory/tests/handlers/session-backfill.test.ts +39 -0
  93. package/dist/extensions/pi-hermes-memory/tests/integration/corruption-recovery-integration.test.ts +91 -0
  94. package/dist/extensions/pi-hermes-memory/tests/store/db.test.ts +29 -0
  95. package/dist/extensions/pi-hermes-memory/tests/store/fts-query.test.ts +116 -0
  96. package/dist/extensions/pi-hermes-memory/tests/store/memory-store.test.ts +163 -0
  97. package/dist/extensions/pi-hermes-memory/tests/store/session-indexer.test.ts +233 -0
  98. package/dist/extensions/pi-hermes-memory/tests/store/session-search.test.ts +12 -0
  99. package/dist/extensions/pi-hermes-memory/tests/store/sqlite-memory-store.test.ts +175 -0
  100. package/dist/extensions/pi-hermes-memory/tests/store/sqlite-native.test.ts +2 -2
  101. package/dist/extensions/pi-hermes-memory/tests/tools/memory-search-tool.test.ts +22 -0
  102. package/dist/extensions/pi-hermes-memory/tests/tools/memory-tool.test.ts +37 -1
  103. package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
  104. package/dist/extensions/pi-intercom/README.md +16 -2
  105. package/dist/extensions/pi-intercom/broker/spawn.test.ts +19 -2
  106. package/dist/extensions/pi-intercom/broker/spawn.ts +4 -4
  107. package/dist/extensions/pi-intercom/index.ts +1 -1
  108. package/dist/extensions/pi-intercom/intercom.integration.test.ts +121 -2
  109. package/dist/extensions/pi-intercom/package.json +1 -1
  110. package/dist/extensions/pi-intercom/project-agent.test.ts +1 -1
  111. package/dist/extensions/pi-intercom/project-agent.ts +1 -6
  112. package/dist/extensions/pi-intercom/skills/pi-intercom/SKILL.md +7 -6
  113. package/dist/extensions/pi-rewind-hook/index.test.ts +7 -0
  114. package/dist/extensions/pi-subagents/.github/workflows/test.yml +36 -4
  115. package/dist/extensions/pi-subagents/CHANGELOG.md +120 -0
  116. package/dist/extensions/pi-subagents/README.md +3 -3
  117. package/dist/extensions/pi-subagents/agents/researcher.md +23 -13
  118. package/dist/extensions/pi-subagents/agents/reviewer.md +1 -1
  119. package/dist/extensions/pi-subagents/agents/scout.md +1 -1
  120. package/dist/extensions/pi-subagents/docs/agents.md +32 -16
  121. package/dist/extensions/pi-subagents/docs/configuration.md +41 -15
  122. package/dist/extensions/pi-subagents/docs/extension-api.md +109 -6
  123. package/dist/extensions/pi-subagents/docs/missions.md +2 -0
  124. package/dist/extensions/pi-subagents/docs/models.md +58 -1
  125. package/dist/extensions/pi-subagents/docs/observability.md +50 -12
  126. package/dist/extensions/pi-subagents/docs/tool-reference.md +17 -10
  127. package/dist/extensions/pi-subagents/docs/watchdog.md +1 -1
  128. package/dist/extensions/pi-subagents/docs/workflows.md +26 -9
  129. package/dist/extensions/pi-subagents/package-lock.json +747 -177
  130. package/dist/extensions/pi-subagents/package.json +6 -4
  131. package/dist/extensions/pi-subagents/runner-server-preload.mjs +13 -0
  132. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -1
  133. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
  134. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +21 -4
  135. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  136. package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
  137. package/dist/extensions/pi-subagents/src/agents/advertised-agent-prompt.ts +63 -0
  138. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +61 -16
  139. package/dist/extensions/pi-subagents/src/agents/agent-serializer.ts +2 -0
  140. package/dist/extensions/pi-subagents/src/agents/agents.ts +8 -0
  141. package/dist/extensions/pi-subagents/src/api/capability-ceiling.ts +0 -1
  142. package/dist/extensions/pi-subagents/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
  143. package/dist/extensions/pi-subagents/src/api/preflight.ts +7 -4
  144. package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -2
  145. package/dist/extensions/pi-subagents/src/api/workflow-resources.ts +6 -0
  146. package/dist/extensions/pi-subagents/src/extension/config.ts +4 -2
  147. package/dist/extensions/pi-subagents/src/extension/doctor.ts +2 -10
  148. package/dist/extensions/pi-subagents/src/extension/fanout-child.ts +9 -11
  149. package/dist/extensions/pi-subagents/src/extension/index.ts +99 -10
  150. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +13 -0
  151. package/dist/extensions/pi-subagents/src/extension/rpc.ts +7 -23
  152. package/dist/extensions/pi-subagents/src/extension/schemas.ts +11 -8
  153. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +25 -7
  154. package/dist/extensions/pi-subagents/src/integrations/pi-web-session-liveness.ts +73 -0
  155. package/dist/extensions/pi-subagents/src/intercom/native-supervisor-channel.ts +228 -136
  156. package/dist/extensions/pi-subagents/src/intercom/supervisor-ui.ts +244 -0
  157. package/dist/extensions/pi-subagents/src/missions/workflow-state.ts +37 -16
  158. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +18 -18
  159. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +51 -25
  160. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +46 -3
  161. package/dist/extensions/pi-subagents/src/runs/background/async-resume.ts +14 -3
  162. package/dist/extensions/pi-subagents/src/runs/background/async-retention.ts +9 -0
  163. package/dist/extensions/pi-subagents/src/runs/background/async-status-snapshot.ts +10 -12
  164. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +18 -15
  165. package/dist/extensions/pi-subagents/src/runs/background/auto-drain.ts +40 -29
  166. package/dist/extensions/pi-subagents/src/runs/background/chain-root-attachment.ts +8 -0
  167. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +79 -247
  168. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +88 -12
  169. package/dist/extensions/pi-subagents/src/runs/background/owned-process-tree.ts +6 -6
  170. package/dist/extensions/pi-subagents/src/runs/background/process-terminal.ts +24 -24
  171. package/dist/extensions/pi-subagents/src/runs/background/retained-nested-route-tracker.ts +96 -0
  172. package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +642 -0
  173. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +75 -6
  174. package/dist/extensions/pi-subagents/src/runs/background/runner-aliases.ts +163 -0
  175. package/dist/extensions/pi-subagents/src/runs/background/runner-child-launch.ts +86 -0
  176. package/dist/extensions/pi-subagents/src/runs/background/runner-child-sessions.ts +31 -0
  177. package/dist/extensions/pi-subagents/src/runs/background/scheduled-runs.ts +18 -4
  178. package/dist/extensions/pi-subagents/src/runs/background/stale-run-reconciler.ts +3 -1
  179. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +597 -1092
  180. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +3 -0
  181. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +4 -0
  182. package/dist/extensions/pi-subagents/src/runs/foreground/async-steering-action.ts +20 -17
  183. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +296 -394
  184. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-control.ts +4 -0
  185. package/dist/extensions/pi-subagents/src/runs/foreground/foreground-history.ts +3 -1
  186. package/dist/extensions/pi-subagents/src/runs/foreground/prompt-audit.ts +9 -5
  187. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +630 -283
  188. package/dist/extensions/pi-subagents/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  189. package/dist/extensions/pi-subagents/src/runs/foreground/workflow-foreground-steering.ts +78 -98
  190. package/dist/extensions/pi-subagents/src/runs/shared/abort-recovery.ts +3 -3
  191. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +16 -3
  192. package/dist/extensions/pi-subagents/src/runs/shared/agent-contract.ts +1 -1
  193. package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +47 -47
  194. package/dist/extensions/pi-subagents/src/runs/shared/capability-ceiling.ts +1 -2
  195. package/dist/extensions/pi-subagents/src/runs/shared/child-hooks.ts +174 -0
  196. package/dist/extensions/pi-subagents/src/runs/shared/child-identity.ts +14 -3
  197. package/dist/extensions/pi-subagents/src/runs/shared/child-launch.ts +319 -0
  198. package/dist/extensions/pi-subagents/src/runs/shared/child-lifecycle.ts +25 -0
  199. package/dist/extensions/pi-subagents/src/runs/shared/child-runtime-config.ts +126 -0
  200. package/dist/extensions/pi-subagents/src/runs/shared/child-session.ts +373 -0
  201. package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +530 -0
  202. package/dist/extensions/pi-subagents/src/runs/shared/completion-evidence.ts +2 -2
  203. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +2 -1
  204. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-preflight.ts +16 -0
  205. package/dist/extensions/pi-subagents/src/runs/shared/host-step-status.ts +11 -11
  206. package/dist/extensions/pi-subagents/src/runs/shared/llm-intent-arbiter.ts +30 -20
  207. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
  208. package/dist/extensions/pi-subagents/src/runs/shared/model-exclusions.ts +84 -15
  209. package/dist/extensions/pi-subagents/src/runs/shared/model-fallback.ts +88 -12
  210. package/dist/extensions/pi-subagents/src/runs/shared/nested-events.ts +37 -53
  211. package/dist/extensions/pi-subagents/src/runs/shared/nested-path.ts +1 -1
  212. package/dist/extensions/pi-subagents/src/runs/shared/orca-progress-tabs.ts +18 -7
  213. package/dist/extensions/pi-subagents/src/runs/shared/parallel-handoff.ts +57 -12
  214. package/dist/extensions/pi-subagents/src/runs/shared/parallel-utils.ts +3 -4
  215. package/dist/extensions/pi-subagents/src/runs/shared/permissions.ts +0 -11
  216. package/dist/extensions/pi-subagents/src/runs/shared/process-signal.ts +4 -1
  217. package/dist/extensions/pi-subagents/src/runs/shared/readonly-drain-observation.ts +42 -0
  218. package/dist/extensions/pi-subagents/src/runs/shared/readonly-model-continuation.ts +69 -0
  219. package/dist/extensions/pi-subagents/src/runs/shared/readonly-session-evidence.ts +307 -0
  220. package/dist/extensions/pi-subagents/src/runs/shared/run-fanout-budget.ts +8 -21
  221. package/dist/extensions/pi-subagents/src/runs/shared/runtime-acknowledged-extensions.ts +3 -30
  222. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +17 -4
  223. package/dist/extensions/pi-subagents/src/runs/shared/subagent-control.ts +2 -0
  224. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +99 -386
  225. package/dist/extensions/pi-subagents/src/runs/shared/tool-availability.ts +18 -62
  226. package/dist/extensions/pi-subagents/src/runs/shared/tool-budget.ts +0 -14
  227. package/dist/extensions/pi-subagents/src/runs/shared/worktree-cleanup-plan.ts +31 -9
  228. package/dist/extensions/pi-subagents/src/runs/shared/worktree-setup-command.ts +190 -0
  229. package/dist/extensions/pi-subagents/src/runs/shared/worktree.ts +506 -226
  230. package/dist/extensions/pi-subagents/src/shared/child-session-name.ts +1 -1
  231. package/dist/extensions/pi-subagents/src/shared/jsonl-writer.ts +11 -0
  232. package/dist/extensions/pi-subagents/src/shared/model-response-aliases.ts +13 -0
  233. package/dist/extensions/pi-subagents/src/shared/thinking-ceiling.ts +0 -6
  234. package/dist/extensions/pi-subagents/src/shared/types.ts +145 -89
  235. package/dist/extensions/pi-subagents/src/shared/utils.ts +13 -6
  236. package/dist/extensions/pi-subagents/src/shared/watch-strategy.ts +2 -0
  237. package/dist/extensions/pi-subagents/src/shared/workflow-child-permit.ts +18 -13
  238. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +0 -6
  239. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +1 -1
  240. package/dist/extensions/pi-subagents/src/tui/fleet.ts +11 -6
  241. package/dist/extensions/pi-subagents/src/tui/render.ts +277 -46
  242. package/dist/extensions/pi-subagents/src/watchdog/child-status.ts +0 -1
  243. package/dist/extensions/pi-subagents/src/watchdog/register-child.ts +12 -12
  244. package/dist/extensions/pi-subagents/src/workflows/chat-progress.ts +3 -3
  245. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +116 -15
  246. package/dist/extensions/pi-subagents/src/workflows/workflow-checklist.ts +15 -18
  247. package/dist/extensions/pi-subagents/src/workflows/workflow-child-summary.ts +57 -8
  248. package/dist/extensions/pi-subagents/src/workflows/workflow-preflight.ts +19 -19
  249. package/dist/extensions/pi-subagents/src/workflows/workflow-receipt.ts +3 -3
  250. package/dist/extensions/pi-subagents/src/workflows/workflow-resources.ts +97 -22
  251. package/dist/extensions/pi-subagents/src/workflows/workflow-settlement.ts +3 -0
  252. package/dist/extensions/pi-subagents/test/integration/acceptance-file-report.test.ts +336 -21
  253. package/dist/extensions/pi-subagents/test/integration/async-execution.part-1.test.ts +1181 -0
  254. package/dist/extensions/pi-subagents/test/integration/async-execution.part-2.test.ts +2240 -0
  255. package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +2268 -0
  256. package/dist/extensions/pi-subagents/test/integration/async-execution.part-4.test.ts +1512 -0
  257. package/dist/extensions/pi-subagents/test/integration/async-job-tracker.test.ts +231 -16
  258. package/dist/extensions/pi-subagents/test/integration/error-handling.test.ts +4 -18
  259. package/dist/extensions/pi-subagents/test/integration/foreign-workflow-steering.test.ts +95 -0
  260. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +9 -15
  261. package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +374 -0
  262. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -10
  263. package/dist/extensions/pi-subagents/test/integration/orca-progress-tabs.test.ts +18 -6
  264. package/dist/extensions/pi-subagents/test/integration/recovered-assistant-error.test.ts +151 -0
  265. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +42 -1
  266. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +150 -25
  267. package/dist/extensions/pi-subagents/test/integration/result-publication.test.ts +354 -0
  268. package/dist/extensions/pi-subagents/test/integration/shared-cwd-terminal-evidence.test.ts +117 -0
  269. package/dist/extensions/pi-subagents/test/integration/single-execution.part-1.test.ts +3678 -0
  270. package/dist/extensions/pi-subagents/test/integration/{single-execution.test.ts → single-execution.part-2.test.ts} +500 -4207
  271. package/dist/extensions/pi-subagents/test/integration/slash-commands.test.ts +1 -2
  272. package/dist/extensions/pi-subagents/test/integration/workflow-result-publication.test.ts +127 -0
  273. package/dist/extensions/pi-subagents/test/integration/workflow-steer-inbox.test.ts +365 -0
  274. package/dist/extensions/pi-subagents/test/smoke/pi085-child.ts +31 -0
  275. package/dist/extensions/pi-subagents/test/smoke/pi085-clean-install.mjs +74 -0
  276. package/dist/extensions/pi-subagents/test/smoke/pi085-extension.ts +16 -0
  277. package/dist/extensions/pi-subagents/test/support/async-execution-fixture.ts +699 -0
  278. package/dist/extensions/pi-subagents/test/support/fake-child-session.ts +418 -0
  279. package/dist/extensions/pi-subagents/test/support/foreign-workflow-steer-host.mjs +13 -0
  280. package/dist/extensions/pi-subagents/test/support/mock-pi.ts +30 -76
  281. package/dist/extensions/pi-subagents/test/support/notify-diagnostics-fixture.ts +84 -0
  282. package/dist/extensions/pi-subagents/test/support/result-publication-capacity-preload.mjs +70 -0
  283. package/dist/extensions/pi-subagents/test/support/runner-child-session-factory.ts +17 -0
  284. package/dist/extensions/pi-subagents/test/support/single-execution-fixture.ts +390 -0
  285. package/dist/extensions/pi-subagents/test/unit/acceptance-compaction.test.ts +120 -0
  286. package/dist/extensions/pi-subagents/test/unit/acceptance.test.ts +24 -3
  287. package/dist/extensions/pi-subagents/test/unit/advertised-agent-prompt.test.ts +87 -0
  288. package/dist/extensions/pi-subagents/test/unit/advertised-agent-refresh.test.ts +163 -0
  289. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -31
  290. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +78 -3
  291. package/dist/extensions/pi-subagents/test/unit/agent-overrides.test.ts +2 -8
  292. package/dist/extensions/pi-subagents/test/unit/async-execution.test.ts +1 -1
  293. package/dist/extensions/pi-subagents/test/unit/async-interrupt-action.test.ts +73 -66
  294. package/dist/extensions/pi-subagents/test/unit/async-recovery-descriptor.test.ts +51 -1
  295. package/dist/extensions/pi-subagents/test/unit/async-retention.test.ts +49 -1
  296. package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +103 -0
  297. package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +3 -3
  298. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +9 -8
  299. package/dist/extensions/pi-subagents/test/unit/capability-ceiling.test.ts +10 -7
  300. package/dist/extensions/pi-subagents/test/unit/chain-root-attachment.test.ts +48 -6
  301. package/dist/extensions/pi-subagents/test/unit/child-runtime-config.test.ts +90 -0
  302. package/dist/extensions/pi-subagents/test/unit/{pi-args-permission-system.test.ts → child-tool-plan-permission-system.test.ts} +48 -86
  303. package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +33 -0
  304. package/dist/extensions/pi-subagents/test/unit/compaction-resume.test.ts +18 -18
  305. package/dist/extensions/pi-subagents/test/unit/completion-evidence.test.ts +9 -9
  306. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +8 -7
  307. package/dist/extensions/pi-subagents/test/unit/control-channel.test.ts +225 -65
  308. package/dist/extensions/pi-subagents/test/unit/extension-bindings.test.ts +17 -22
  309. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +11 -1
  310. package/dist/extensions/pi-subagents/test/unit/fleet.test.ts +47 -3
  311. package/dist/extensions/pi-subagents/test/unit/foreign-workflow-steering.test.ts +74 -0
  312. package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +23 -29
  313. package/dist/extensions/pi-subagents/test/unit/host-peer-runtime-imports.test.ts +96 -4
  314. package/dist/extensions/pi-subagents/test/unit/host-step-status.test.ts +4 -4
  315. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +116 -55
  316. package/dist/extensions/pi-subagents/test/unit/index-segment.test.ts +58 -1
  317. package/dist/extensions/pi-subagents/test/unit/llm-intent-arbiter.test.ts +104 -3
  318. package/dist/extensions/pi-subagents/test/unit/mission-lifecycle.test.ts +10 -1
  319. package/dist/extensions/pi-subagents/test/unit/mission-store.test.ts +31 -1
  320. package/dist/extensions/pi-subagents/test/unit/model-exclusions.test.ts +102 -51
  321. package/dist/extensions/pi-subagents/test/unit/model-fallback.test.ts +196 -6
  322. package/dist/extensions/pi-subagents/test/unit/native-supervisor-channel.test.ts +181 -38
  323. package/dist/extensions/pi-subagents/test/unit/nested-control.test.ts +62 -70
  324. package/dist/extensions/pi-subagents/test/unit/nested-events.test.ts +58 -53
  325. package/dist/extensions/pi-subagents/test/unit/notify-diagnostics.test.ts +42 -0
  326. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +84 -0
  327. package/dist/extensions/pi-subagents/test/unit/orca-progress-tabs.test.ts +13 -5
  328. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +21 -5
  329. package/dist/extensions/pi-subagents/test/unit/parallel-handoff.test.ts +5 -4
  330. package/dist/extensions/pi-subagents/test/unit/permissions.test.ts +2 -6
  331. package/dist/extensions/pi-subagents/test/unit/pi-coding-agent-dir.test.ts +28 -2
  332. package/dist/extensions/pi-subagents/test/unit/pi-web-session-liveness.test.ts +209 -0
  333. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +38 -1
  334. package/dist/extensions/pi-subagents/test/unit/prompt-audit-optional-peers.test.ts +43 -0
  335. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +24 -2
  336. package/dist/extensions/pi-subagents/test/unit/readonly-drain-observation.test.ts +174 -0
  337. package/dist/extensions/pi-subagents/test/unit/readonly-model-continuation.test.ts +94 -0
  338. package/dist/extensions/pi-subagents/test/unit/readonly-session-evidence.test.ts +1329 -0
  339. package/dist/extensions/pi-subagents/test/unit/recursion-guard.test.ts +29 -80
  340. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +11 -8
  341. package/dist/extensions/pi-subagents/test/unit/result-files.test.ts +2 -2
  342. package/dist/extensions/pi-subagents/test/unit/retained-nested-route-tracker.test.ts +175 -0
  343. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +38 -12
  344. package/dist/extensions/pi-subagents/test/unit/run-fanout-budget.test.ts +2 -5
  345. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +89 -1
  346. package/dist/extensions/pi-subagents/test/unit/runtime-acknowledged-extensions.test.ts +2 -16
  347. package/dist/extensions/pi-subagents/test/unit/scheduled-runs.test.ts +27 -3
  348. package/dist/extensions/pi-subagents/test/unit/scheduled-supervisor-demand.test.ts +215 -0
  349. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +15 -11
  350. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +237 -1
  351. package/dist/extensions/pi-subagents/test/unit/steering-action.test.ts +2 -5
  352. package/dist/extensions/pi-subagents/test/unit/streamed-progress-bounds.test.ts +2 -2
  353. package/dist/extensions/pi-subagents/test/unit/subagent-guide.test.ts +14 -1
  354. package/dist/extensions/pi-subagents/test/unit/subagent-prompt-runtime.test.ts +130 -739
  355. package/dist/extensions/pi-subagents/test/unit/supervisor-ask-registration.test.ts +682 -0
  356. package/dist/extensions/pi-subagents/test/unit/supervisor-ui.test.ts +137 -0
  357. package/dist/extensions/pi-subagents/test/unit/tool-budget.test.ts +1 -11
  358. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +23 -3
  359. package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +8 -1
  360. package/dist/extensions/pi-subagents/test/unit/watchdog-tool-actions.test.ts +19 -1
  361. package/dist/extensions/pi-subagents/test/unit/workflow-chat-progress.test.ts +72 -4
  362. package/dist/extensions/pi-subagents/test/unit/workflow-checklist.test.ts +17 -3
  363. package/dist/extensions/pi-subagents/test/unit/workflow-child-summary.test.ts +34 -0
  364. package/dist/extensions/pi-subagents/test/unit/workflow-detach-reconcile.test.ts +12 -2
  365. package/dist/extensions/pi-subagents/test/unit/workflow-launch-params.test.ts +46 -6
  366. package/dist/extensions/pi-subagents/test/unit/workflow-receipt.test.ts +3 -3
  367. package/dist/extensions/pi-subagents/test/unit/workflow-resources.test.ts +133 -1
  368. package/dist/extensions/pi-subagents/test/unit/workflow-resume-hint.test.ts +53 -2
  369. package/dist/extensions/pi-subagents/test/unit/worktree-cleanup-plan.test.ts +222 -14
  370. package/dist/extensions/pi-subagents/test/unit/worktree.test.ts +526 -85
  371. package/dist/extensions/pi-web-agent/README.md +6 -5
  372. package/dist/extensions/pi-web-agent/src/backends/config.ts +12 -6
  373. package/dist/extensions/pi-web-agent/src/backends/factory.ts +2 -2
  374. package/dist/extensions/pi-web-agent/src/backends/settings-reader.ts +4 -5
  375. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +8 -8
  376. package/dist/extensions/pi-web-agent/src/extension.ts +3 -3
  377. package/dist/extensions/pi-web-agent/src/presentation/config-store.ts +2 -2
  378. package/dist/extensions/pi-web-agent/src/search/tokenin.ts +14 -3
  379. package/dist/extensions/pi-zentui/README.md +17 -0
  380. package/dist/extensions/pi-zentui/assets/screenshots/footer-status-hyperlink.png +0 -0
  381. package/dist/extensions/pi-zentui/docs/configuration.md +71 -2
  382. package/dist/extensions/pi-zentui/extensions/zentui/config.ts +11 -0
  383. package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +14 -0
  384. package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +29 -13
  385. package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +5 -4
  386. package/dist/extensions/pi-zentui/extensions/zentui/footer-text.ts +11 -0
  387. package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +11 -10
  388. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +77 -3
  389. package/dist/extensions/pi-zentui/extensions/zentui/presets.ts +77 -0
  390. package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +103 -2
  391. package/dist/extensions/pi-zentui/extensions/zentui/working-line-extension-segments.ts +115 -0
  392. package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +87 -5
  393. package/dist/extensions/pi-zentui/package-lock.json +277 -517
  394. package/dist/extensions/pi-zentui/package.json +7 -4
  395. package/dist/extensions/pi-zentui/test/config-presets.test.ts +238 -0
  396. package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +24 -1
  397. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +570 -2
  398. package/dist/extensions/pi-zentui/test/extension-status.test.ts +90 -0
  399. package/dist/extensions/pi-zentui/test/footer-hyperlink.test.ts +243 -0
  400. package/dist/extensions/pi-zentui/test/package-contents.mjs +1 -0
  401. package/dist/extensions/pi-zentui/test/presets.test.ts +121 -0
  402. package/dist/extensions/pi-zentui/test/settings-command.test.ts +205 -1
  403. package/dist/extensions/pi-zentui/test/working-line-extension-segments.test.ts +139 -0
  404. package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +237 -3
  405. package/dist/extensions/pi-zentui/test/working-line.test.ts +94 -2
  406. package/dist/index.d.ts +1 -1
  407. package/dist/main.js +4 -1
  408. package/dist/modes/interactive/chat-viewport.d.ts +19 -0
  409. package/dist/modes/interactive/chat-viewport.js +27 -0
  410. package/dist/modes/interactive/components/assistant-message.d.ts +6 -2
  411. package/dist/modes/interactive/components/assistant-message.js +30 -13
  412. package/dist/modes/interactive/components/custom-editor.d.ts +10 -1
  413. package/dist/modes/interactive/components/custom-editor.js +39 -1
  414. package/dist/modes/interactive/components/first-time-setup.js +1 -1
  415. package/dist/modes/interactive/components/index.d.ts +1 -1
  416. package/dist/modes/interactive/components/markdown-transform.d.ts +2 -0
  417. package/dist/modes/interactive/components/markdown-transform.js +18 -0
  418. package/dist/modes/interactive/components/model-selector.js +19 -17
  419. package/dist/modes/interactive/components/scoped-models-selector.js +21 -23
  420. package/dist/modes/interactive/components/settings-selector.d.ts +7 -1
  421. package/dist/modes/interactive/components/settings-selector.js +128 -20
  422. package/dist/modes/interactive/components/settings-submenu.d.ts +71 -0
  423. package/dist/modes/interactive/components/settings-submenu.js +164 -0
  424. package/dist/modes/interactive/components/status-indicator.d.ts +3 -1
  425. package/dist/modes/interactive/components/status-indicator.js +10 -3
  426. package/dist/modes/interactive/components/thinking-selector.d.ts +15 -3
  427. package/dist/modes/interactive/components/thinking-selector.js +75 -18
  428. package/dist/modes/interactive/components/tool-execution.d.ts +21 -4
  429. package/dist/modes/interactive/components/tool-execution.js +35 -33
  430. package/dist/modes/interactive/components/trust-selector.js +2 -2
  431. package/dist/modes/interactive/interactive-mode.d.ts +13 -15
  432. package/dist/modes/interactive/interactive-mode.js +144 -80
  433. package/dist/modes/interactive/session-share.d.ts +15 -0
  434. package/dist/modes/interactive/session-share.js +183 -0
  435. package/dist/modes/interactive/theme/dark.json +2 -1
  436. package/dist/modes/interactive/theme/light.json +2 -1
  437. package/dist/modes/interactive/theme/theme-controller.d.ts +1 -0
  438. package/dist/modes/interactive/theme/theme-controller.js +5 -0
  439. package/dist/modes/interactive/theme/theme-json.d.ts +83 -0
  440. package/dist/modes/interactive/theme/theme-json.js +129 -0
  441. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  442. package/dist/modes/interactive/theme/theme.d.ts +14 -5
  443. package/dist/modes/interactive/theme/theme.js +18 -119
  444. package/dist/modes/interactive/tui-renderer.d.ts +20 -0
  445. package/dist/modes/interactive/tui-renderer.js +65 -0
  446. package/dist/utils/exif-orientation.js +2 -3
  447. package/dist/utils/management-http.d.ts +24 -0
  448. package/dist/utils/management-http.js +53 -0
  449. package/dist/utils/text.d.ts +7 -0
  450. package/dist/utils/text.js +8 -0
  451. package/dist/utils/tools-manager.d.ts +1 -0
  452. package/dist/utils/tools-manager.js +43 -21
  453. package/docs/docs.json +0 -4
  454. package/package.json +6 -6
  455. package/dist/extensions/pi-subagents/src/runs/shared/child-protocol.ts +0 -415
  456. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +0 -1059
  457. package/dist/extensions/pi-subagents/src/runs/shared/subagent-startup-retry.ts +0 -116
  458. package/dist/extensions/pi-subagents/src/shared/post-exit-stdio-guard.ts +0 -85
  459. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +0 -224
  460. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +0 -6986
  461. package/dist/extensions/pi-subagents/test/support/mock-pi-script.mjs +0 -447
  462. package/dist/extensions/pi-subagents/test/support/real-session-child-cli.mjs +0 -185
  463. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +0 -311
  464. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-pi-args.test.ts +0 -84
  465. package/dist/extensions/pi-subagents/test/unit/child-protocol.test.ts +0 -202
  466. package/dist/extensions/pi-subagents/test/unit/close-grace-timer.test.ts +0 -101
  467. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +0 -2347
  468. package/dist/extensions/pi-subagents/test/unit/subagent-startup-retry.test.ts +0 -104
  469. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +0 -49
  470. package/dist/extensions/web-agent-onboarding.test.ts +0 -415
  471. package/dist/extensions/web-agent-onboarding.ts +0 -222
  472. package/dist/skills/imagegen-frontend-mobile/SKILL.md +0 -1465
  473. package/dist/skills/imagegen-frontend-web/SKILL.md +0 -987
  474. package/dist/skills/implanger/SKILL.md +0 -70
  475. package/dist/skills/output/SKILL.md +0 -49
  476. package/dist/skills/planger/SKILL.md +0 -167
  477. package/dist/skills/workflow/SKILL.md +0 -95
  478. package/docs/workflows.md +0 -18
  479. /package/dist/bun/{register-bedrock.d.ts → runtime-setup.d.ts} +0 -0
@@ -1,9 +1,11 @@
1
1
  import assert from "node:assert/strict";
2
- import { spawnSync } from "node:child_process";
2
+ import { execFile, spawnSync } from "node:child_process";
3
3
  import * as fs from "node:fs";
4
4
  import * as os from "node:os";
5
5
  import * as path from "node:path";
6
6
  import { describe, it } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+ import { promisify } from "node:util";
7
9
  import {
8
10
  cleanupWorktrees,
9
11
  createWorktrees,
@@ -11,11 +13,13 @@ import {
11
13
  findWorktreeTaskCwdConflict,
12
14
  formatWorktreeDiffSummary,
13
15
  buildWorktreeNaming,
16
+ normalizeWorktreeBaseRef,
14
17
  normalizeWorktreeBranchPrefix,
15
18
  resolveExpectedWorktreeAgentCwd,
16
19
  resolveWorktreeProvider,
17
20
  sanitizeWorktreePathComponent,
18
21
  shouldDeferWorktreeCwd,
22
+ WorktreeSetupError,
19
23
  type WorktreeSetup,
20
24
  } from "../../src/runs/shared/worktree.ts";
21
25
 
@@ -37,13 +41,21 @@ function createRepo(prefix: string): string {
37
41
  fs.writeFileSync(path.join(repoDir, "tracked.txt"), "initial\n", "utf-8");
38
42
  git(repoDir, ["add", "-A"]);
39
43
  git(repoDir, ["commit", "-m", "initial commit"]);
40
- return repoDir;
44
+ // Match Git's canonical top-level path for macOS symlinked temp roots.
45
+ return fs.realpathSync(repoDir);
41
46
  }
42
47
 
43
- function cleanupRepo(repoDir: string): void {
48
+ function cleanupRepo(repoDir: string, baseDir?: string): void {
49
+ cleanupProjectWorktrees(repoDir, baseDir);
44
50
  try { fs.rmSync(repoDir, { recursive: true, force: true }); } catch {}
45
51
  }
46
52
 
53
+ /** Removes only this repo's nested project folder; never the shared dedicated root. */
54
+ function cleanupProjectWorktrees(repoDir: string, baseDir?: string): void {
55
+ const dedicatedRoot = baseDir ?? path.join(path.dirname(repoDir), "worktrees");
56
+ try { fs.rmSync(path.join(dedicatedRoot, path.basename(repoDir)), { recursive: true, force: true }); } catch {}
57
+ }
58
+
47
59
  function createHookScript(_repoDir: string, fileName: string, source: string): string {
48
60
  const hooksDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-hook-script-"));
49
61
  const hookPath = path.join(hooksDir, fileName);
@@ -59,12 +71,41 @@ const worktrunkShimSkip = process.platform === "win32"
59
71
  ? "Windows Terminal installs a wt.exe app alias that can outrank test command shims."
60
72
  : undefined;
61
73
 
74
+ // Unknown settlement intentionally poisons the process. Re-run only that case in
75
+ // an awaited real child so subsequent tests retain normal mutation admission.
76
+ async function runPoisonCaseInChild(name: string): Promise<boolean> {
77
+ if (process.env.PI_WORKTREE_POISON_CASE === name) return false;
78
+ await promisify(execFile)(process.execPath, [
79
+ "--experimental-strip-types", "--import", "./test/support/register-loader.mjs",
80
+ "--test", `--test-name-pattern=${name}`, fileURLToPath(import.meta.url),
81
+ ], { env: { ...process.env, NODE_TEST_CONTEXT: undefined, PI_WORKTREE_POISON_CASE: name } });
82
+ return true;
83
+ }
84
+
85
+ function assertRetainedHookFailure(error: unknown, message: RegExp): true {
86
+ assert.ok(error instanceof WorktreeSetupError);
87
+ assert.match(error.message, message);
88
+ assert.match(error.message, /manual reconciliation required/i);
89
+ assert.ok(error.snapshot.unknown);
90
+ assert.equal(error.snapshot.cleanup?.state, "partial");
91
+ assert.equal(error.snapshot.cleanup?.pruned, false);
92
+ const worktree = error.snapshot.setup.worktrees[0]!;
93
+ assert.ok(worktree);
94
+ assert.equal(error.snapshot.attempts[0]?.validated, true);
95
+ assert.equal(fs.existsSync(worktree.path), true);
96
+ assert.equal(git(worktree.path, ["branch", "--show-current"]), worktree.branch);
97
+ assert.equal(fs.readFileSync(path.join(worktree.path, "tracked.txt"), "utf-8"), "initial\n");
98
+ assert.throws(() => cleanupWorktrees(error.snapshot.setup, { kind: "setup-rollback" }), /Worktree mutation blocked/i);
99
+ assert.equal(fs.existsSync(worktree.path), true);
100
+ return true;
101
+ }
102
+
62
103
  describe("worktree", () => {
63
- it("createWorktrees returns expected structure", () => {
104
+ it("createWorktrees returns expected structure", async () => {
64
105
  const repoDir = createRepo("pi-worktree-structure-");
65
106
  let setup: WorktreeSetup | undefined;
66
107
  try {
67
- setup = createWorktrees(repoDir, "structure", 2, { provider: "native" });
108
+ setup = await createWorktrees(repoDir, "structure", 2, { provider: "native" });
68
109
  assert.equal(setup.worktrees.length, 2);
69
110
  assert.equal(setup.cwd, git(repoDir, ["rev-parse", "--show-toplevel"]));
70
111
  for (let i = 0; i < setup.worktrees.length; i++) {
@@ -82,21 +123,32 @@ describe("worktree", () => {
82
123
  }
83
124
  });
84
125
 
85
- it("invokes beforeCreate with deterministic ownership metadata before creating worktrees", () => {
126
+ it("reports attempted ownership before spawn and only validated worktrees after allocation", async () => {
86
127
  const repoDir = createRepo("pi-worktree-before-create-");
87
128
  let setup: WorktreeSetup | undefined;
88
- let planned: WorktreeSetup | undefined;
129
+ let attemptedPath: string | undefined;
130
+ let validated: WorktreeSetup | undefined;
89
131
  try {
90
- setup = createWorktrees(repoDir, "before-create", 1, {
132
+ setup = await createWorktrees(repoDir, "before-create", 1, {
91
133
  provider: "native",
92
- beforeCreate: (candidate) => {
93
- planned = candidate;
94
- assert.equal(fs.existsSync(candidate.worktrees[0]!.path), false);
95
- assert.equal(candidate.worktrees[0]!.branch, "pi-subagents/task-before-creat-s0-t0");
134
+ onProgress: (snapshot) => {
135
+ const attempt = snapshot.attempts[0];
136
+ if (attempt && !attempt.command?.pid && !attempt.validated) {
137
+ attemptedPath = attempt.path;
138
+ assert.ok(attemptedPath);
139
+ assert.equal(fs.existsSync(attemptedPath), false);
140
+ assert.equal(attempt.branch, "pi-subagents/task-before-creat-s0-t0");
141
+ assert.deepEqual(snapshot.setup.worktrees, []);
142
+ }
143
+ if (snapshot.setup.worktrees.length) {
144
+ assert.equal(attempt?.validated, true);
145
+ validated = snapshot.setup;
146
+ }
96
147
  },
97
148
  });
98
- assert.equal(planned?.baseCommit, setup.baseCommit);
99
- assert.equal(planned?.worktrees[0]?.path, setup.worktrees[0]?.path);
149
+ assert.equal(attemptedPath, setup.worktrees[0]?.path);
150
+ assert.equal(validated?.baseCommit, setup.baseCommit);
151
+ assert.equal(validated?.worktrees[0]?.path, setup.worktrees[0]?.path);
100
152
  assert.equal(fs.existsSync(setup.worktrees[0]!.path), true);
101
153
  } finally {
102
154
  if (setup) cleanupWorktrees(setup);
@@ -104,12 +156,39 @@ describe("worktree", () => {
104
156
  }
105
157
  });
106
158
 
107
- it("records Worktrunk ownership and uses its returned path", () => {
159
+ it("compensates a rejected ready publication without retaining command output in snapshots", async () => {
160
+ const repoDir = createRepo("pi-worktree-ready-rejected-");
161
+ try {
162
+ await assert.rejects(() => createWorktrees(repoDir, "ready-rejected", 1, {
163
+ provider: "native",
164
+ onProgress: (snapshot) => {
165
+ if (snapshot.command?.result) {
166
+ assert.equal("stdout" in snapshot.command.result, false);
167
+ assert.equal("stderr" in snapshot.command.result, false);
168
+ }
169
+ if (snapshot.phase === "ready") throw new Error("ready publication rejected");
170
+ },
171
+ }), (error: unknown) => {
172
+ assert.ok(error instanceof WorktreeSetupError);
173
+ assert.match(error.message, /ready publication rejected/);
174
+ assert.equal(error.snapshot.cleanup?.state, "complete");
175
+ assert.equal(error.snapshot.cleanup.tasks.length, 1);
176
+ const task = error.snapshot.cleanup.tasks[0]!;
177
+ assert.equal(task.worktreeRemoved, true);
178
+ assert.equal(task.branchRemoved, true);
179
+ assert.equal(fs.existsSync(task.path), false);
180
+ assert.equal(git(repoDir, ["branch", "--list", task.branch]), "");
181
+ return true;
182
+ });
183
+ } finally { cleanupRepo(repoDir); }
184
+ });
185
+
186
+ it("records Worktrunk ownership and uses its returned path", async () => {
108
187
  try { resolveWorktreeProvider("worktrunk"); } catch { return; }
109
188
  const repoDir = createRepo("pi-worktree-worktrunk-");
110
189
  let setup: WorktreeSetup | undefined;
111
190
  try {
112
- setup = createWorktrees(repoDir, "worktrunk-s0", 1, {
191
+ setup = await createWorktrees(repoDir, "worktrunk-s0", 1, {
113
192
  provider: "worktrunk",
114
193
  agents: ["worker"],
115
194
  labels: ["Review API"],
@@ -128,7 +207,8 @@ describe("worktree", () => {
128
207
  }
129
208
  });
130
209
 
131
- it("rejects Worktrunk responses that point at the source checkout", { skip: worktrunkShimSkip }, () => {
210
+ it("rejects Worktrunk responses that point at the source checkout", { skip: worktrunkShimSkip }, async () => {
211
+ if (await runPoisonCaseInChild("rejects Worktrunk responses that point at the source checkout")) return;
132
212
  const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-fake-source-wt-"));
133
213
  const fakeScript = path.join(fakeBin, "wt.cjs");
134
214
  const fakeWt = path.join(fakeBin, process.platform === "win32" ? "wt.cmd" : "wt");
@@ -153,16 +233,30 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
153
233
  const previousPathExt = process.env.PATHEXT;
154
234
  const repoDir = createRepo("pi-worktree-source-wt-");
155
235
  try {
156
- const originalBranch = git(repoDir, ["branch", "--show-current"]);
236
+ const originalHead = git(repoDir, ["rev-parse", "HEAD"]);
157
237
  process.env.PATH = `${fakeBin}${path.delimiter}${previousPath ?? ""}`;
158
238
  process.env.Path = `${fakeBin}${path.delimiter}${previousWindowsPath ?? previousPath ?? ""}`;
159
239
  process.env.PATHEXT = [".CMD", ".EXE", ".BAT", previousPathExt ?? ""].filter(Boolean).join(path.delimiter);
160
- assert.throws(
240
+ await assert.rejects(
161
241
  () => createWorktrees(repoDir, "source-path", 1, { provider: "worktrunk" }),
162
- /source checkout path/i,
242
+ (error: unknown) => {
243
+ assert.ok(error instanceof WorktreeSetupError);
244
+ assert.match(error.message, /source checkout path/i);
245
+ assert.match(error.message, /manual reconciliation required/i);
246
+ assert.ok(error.snapshot.unknown);
247
+ assert.deepEqual(error.snapshot.setup.worktrees, []);
248
+ assert.equal(error.snapshot.attempts[0]?.validated, false);
249
+ assert.equal(error.snapshot.attempts[0]?.branch, "pi-subagents/task-source-path-s0-t0");
250
+ assert.equal(error.snapshot.cleanup?.pruned, false);
251
+ return true;
252
+ },
163
253
  );
164
- assert.equal(git(repoDir, ["branch", "--show-current"]), originalBranch);
165
- assert.equal(git(repoDir, ["branch", "--list", "pi-subagents/task-source-path-s0-t0"]), "");
254
+ // Invalid output grants no authority to restore or delete source state.
255
+ assert.equal(git(repoDir, ["branch", "--show-current"]), "pi-subagents/task-source-path-s0-t0");
256
+ assert.equal(git(repoDir, ["rev-parse", "HEAD"]), originalHead);
257
+ assert.equal(fs.readFileSync(path.join(repoDir, "tracked.txt"), "utf-8"), "initial\n");
258
+ await assert.rejects(() => createWorktrees(repoDir, "after-unknown", 1, { provider: "native" }), /unknown|manual reconciliation/i);
259
+ assert.equal(git(repoDir, ["branch", "--list", "pi-subagents/task-after-unknow-s0-t0"]), "");
166
260
  } finally {
167
261
  if (previousPath === undefined) delete process.env.PATH;
168
262
  else process.env.PATH = previousPath;
@@ -175,7 +269,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
175
269
  }
176
270
  });
177
271
 
178
- it("falls back to native only when Worktrunk capability probing fails", () => {
272
+ it("falls back to native only when Worktrunk capability probing fails", async () => {
179
273
  const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-fake-wt-"));
180
274
  const fakeWt = path.join(fakeBin, process.platform === "win32" ? "wt.cmd" : "wt");
181
275
  fs.writeFileSync(fakeWt, process.platform === "win32" ? "@echo not-worktrunk\r\n" : "#!/bin/sh\nprintf 'not-worktrunk\\n'\n", "utf-8");
@@ -193,8 +287,8 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
193
287
  assert.equal(shouldDeferWorktreeCwd(undefined), true);
194
288
  assert.equal(resolveWorktreeProvider(undefined, " "), "native");
195
289
  assert.equal(shouldDeferWorktreeCwd(undefined, " "), false);
196
- assert.throws(() => createWorktrees(repoDir, "provider-fallback", 1, { baseDir: " " }), /cannot be empty/i);
197
- setup = createWorktrees(repoDir, "provider-fallback", 1);
290
+ await assert.rejects(() => createWorktrees(repoDir, "provider-fallback", 1, { baseDir: " " }), /cannot be empty/i);
291
+ setup = await createWorktrees(repoDir, "provider-fallback", 1);
198
292
  assert.equal(setup.worktrees[0]?.provider, "native");
199
293
  assert.throws(() => resolveWorktreeProvider("worktrunk"), /Worktrunk provider is unavailable/i);
200
294
  } finally {
@@ -227,7 +321,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
227
321
  assert.throws(() => normalizeWorktreeBranchPrefix("../unsafe"), /invalid/i);
228
322
  });
229
323
 
230
- it("createWorktrees maps subdirectory cwd to each agentCwd", () => {
324
+ it("createWorktrees maps subdirectory cwd to each agentCwd", async () => {
231
325
  const repoDir = createRepo("pi-worktree-subdir-");
232
326
  const nestedDir = path.join(repoDir, "packages", "app");
233
327
  fs.mkdirSync(nestedDir, { recursive: true });
@@ -237,7 +331,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
237
331
 
238
332
  let setup: WorktreeSetup | undefined;
239
333
  try {
240
- setup = createWorktrees(nestedDir, "subdir", 1);
334
+ setup = await createWorktrees(nestedDir, "subdir", 1);
241
335
  assert.equal(setup.worktrees[0]!.agentCwd, path.join(setup.worktrees[0]!.path, "packages", "app"));
242
336
  } finally {
243
337
  if (setup) cleanupWorktrees(setup);
@@ -254,31 +348,41 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
254
348
  git(repoDir, ["commit", "-m", "add nested dir"]);
255
349
 
256
350
  try {
351
+ const repoRoot = git(nestedDir, ["rev-parse", "--show-toplevel"]);
257
352
  assert.equal(
258
353
  resolveExpectedWorktreeAgentCwd(nestedDir, "preview", 2),
259
- path.join(os.tmpdir(), "pi-worktree-preview-2", "packages", "app"),
354
+ path.join(
355
+ path.dirname(repoRoot),
356
+ "worktrees",
357
+ path.basename(repoRoot),
358
+ "pi-worktree-preview-2",
359
+ "packages",
360
+ "app",
361
+ ),
260
362
  );
261
363
  } finally {
262
364
  cleanupRepo(repoDir);
263
365
  }
264
366
  });
265
367
 
266
- it("creates worktrees under a configured base directory", () => {
368
+ it("creates worktrees under a configured base directory", async () => {
267
369
  const repoDir = createRepo("pi-worktree-base-dir-");
268
370
  const baseDir = path.join(os.tmpdir(), `pi-worktree-base-${Date.now().toString(36)}`, "nested");
269
371
  let setup: WorktreeSetup | undefined;
270
372
  try {
271
- setup = createWorktrees(repoDir, "base-dir", 1, { baseDir });
272
- assert.equal(setup.worktrees[0]!.path, path.join(baseDir, "pi-worktree-base-dir-0"));
373
+ setup = await createWorktrees(repoDir, "base-dir", 1, { baseDir });
374
+ assert.equal(
375
+ setup.worktrees[0]!.path,
376
+ path.join(baseDir, path.basename(repoDir), "pi-worktree-base-dir-0"),
377
+ );
273
378
  assert.ok(fs.existsSync(baseDir), "configured base directory should be created");
274
379
  } finally {
275
380
  if (setup) cleanupWorktrees(setup);
276
- cleanupRepo(repoDir);
277
- fs.rmSync(path.dirname(baseDir), { recursive: true, force: true });
381
+ cleanupRepo(repoDir, baseDir);
278
382
  }
279
383
  });
280
384
 
281
- it("rejects worktree base directories inside Pi extensions", () => {
385
+ it("rejects worktree base directories inside Pi extensions", async () => {
282
386
  const repoDir = createRepo("pi-worktree-extension-dir-");
283
387
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-home-"));
284
388
  const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
@@ -289,11 +393,11 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
289
393
  process.env.HOME = tempHome;
290
394
  process.env.USERPROFILE = tempHome;
291
395
  delete process.env.SELESAI_CODING_AGENT_DIR;
292
- assert.throws(
396
+ await assert.rejects(
293
397
  () => createWorktrees(repoDir, "extension-dir", 1, { baseDir: extensionsDir }),
294
398
  /worktree base directory cannot be inside Pi extensions directory/i,
295
399
  );
296
- assert.throws(
400
+ await assert.rejects(
297
401
  () => createWorktrees(repoDir, "extension-subdir", 1, { baseDir: path.join(extensionsDir, "checkout") }),
298
402
  /worktree base directory cannot be inside Pi extensions directory/i,
299
403
  );
@@ -309,7 +413,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
309
413
  }
310
414
  });
311
415
 
312
- it("rejects symlinked worktree base directories inside Pi extensions", () => {
416
+ it("rejects symlinked worktree base directories inside Pi extensions", async () => {
313
417
  const repoDir = createRepo("pi-worktree-extension-symlink-");
314
418
  const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-home-"));
315
419
  const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
@@ -323,7 +427,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
323
427
  delete process.env.SELESAI_CODING_AGENT_DIR;
324
428
  fs.mkdirSync(extensionsDir, { recursive: true });
325
429
  fs.symlinkSync(extensionsDir, aliasDir, process.platform === "win32" ? "junction" : "dir");
326
- assert.throws(
430
+ await assert.rejects(
327
431
  () => createWorktrees(repoDir, "extension-symlink", 1, { baseDir: path.join(aliasDir, "checkout") }),
328
432
  /worktree base directory cannot be inside Pi extensions directory/i,
329
433
  );
@@ -339,15 +443,96 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
339
443
  }
340
444
  });
341
445
 
342
- it("uses SELESAI_SUBAGENTS_WORKTREE_DIR when no base directory is configured", () => {
446
+ it("rejects a final project directory inside Pi extensions", async () => {
447
+ const sourceRepo = createRepo("pi-worktree-extension-project-");
448
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-home-"));
449
+ const repoDir = path.join(tempHome, "extensions");
450
+ const agentDir = path.join(tempHome, ".selesai", "agent");
451
+ const extensionsDir = path.join(agentDir, "extensions");
452
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
453
+ const previousHome = process.env.HOME;
454
+ const previousUserProfile = process.env.USERPROFILE;
455
+ try {
456
+ fs.renameSync(sourceRepo, repoDir);
457
+ fs.mkdirSync(extensionsDir, { recursive: true });
458
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
459
+ process.env.HOME = tempHome;
460
+ process.env.USERPROFILE = tempHome;
461
+
462
+ await assert.rejects(
463
+ () => createWorktrees(repoDir, "extension-project", 1, { provider: "native", baseDir: agentDir }),
464
+ /worktree path cannot be inside Pi extensions directory/i,
465
+ );
466
+ assert.throws(
467
+ () => resolveExpectedWorktreeAgentCwd(repoDir, "extension-project", 0, agentDir),
468
+ /worktree path cannot be inside Pi extensions directory/i,
469
+ );
470
+ assert.deepEqual(git(repoDir, ["worktree", "list", "--porcelain"]).split("\n").filter((line) => line.startsWith("worktree ")), [`worktree ${git(repoDir, ["rev-parse", "--show-toplevel"])}`]);
471
+ assert.deepEqual(fs.readdirSync(extensionsDir), []);
472
+ } finally {
473
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
474
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
475
+ if (previousHome === undefined) delete process.env.HOME;
476
+ else process.env.HOME = previousHome;
477
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
478
+ else process.env.USERPROFILE = previousUserProfile;
479
+ cleanupRepo(repoDir);
480
+ cleanupRepo(sourceRepo);
481
+ fs.rmSync(tempHome, { recursive: true, force: true });
482
+ }
483
+ });
484
+
485
+ it("rejects a project directory symlink into Pi extensions", async () => {
486
+ const repoDir = createRepo("pi-worktree-extension-project-symlink-");
487
+ const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-home-"));
488
+ const agentDir = path.join(tempHome, ".selesai", "agent");
489
+ const extensionsDir = path.join(agentDir, "extensions");
490
+ const baseDir = path.join(tempHome, "worktree-root");
491
+ const previousAgentDir = process.env.SELESAI_CODING_AGENT_DIR;
492
+ const previousHome = process.env.HOME;
493
+ const previousUserProfile = process.env.USERPROFILE;
494
+ try {
495
+ fs.mkdirSync(extensionsDir, { recursive: true });
496
+ fs.mkdirSync(baseDir, { recursive: true });
497
+ fs.symlinkSync(extensionsDir, path.join(baseDir, path.basename(repoDir)), process.platform === "win32" ? "junction" : "dir");
498
+ process.env.SELESAI_CODING_AGENT_DIR = agentDir;
499
+ process.env.HOME = tempHome;
500
+ process.env.USERPROFILE = tempHome;
501
+
502
+ await assert.rejects(
503
+ () => createWorktrees(repoDir, "extension-project-symlink", 1, { provider: "native", baseDir }),
504
+ /worktree path cannot be inside Pi extensions directory/i,
505
+ );
506
+ assert.throws(
507
+ () => resolveExpectedWorktreeAgentCwd(repoDir, "extension-project-symlink", 0, baseDir),
508
+ /worktree path cannot be inside Pi extensions directory/i,
509
+ );
510
+ assert.deepEqual(git(repoDir, ["worktree", "list", "--porcelain"]).split("\n").filter((line) => line.startsWith("worktree ")), [`worktree ${git(repoDir, ["rev-parse", "--show-toplevel"])}`]);
511
+ assert.deepEqual(fs.readdirSync(extensionsDir), []);
512
+ } finally {
513
+ if (previousAgentDir === undefined) delete process.env.SELESAI_CODING_AGENT_DIR;
514
+ else process.env.SELESAI_CODING_AGENT_DIR = previousAgentDir;
515
+ if (previousHome === undefined) delete process.env.HOME;
516
+ else process.env.HOME = previousHome;
517
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
518
+ else process.env.USERPROFILE = previousUserProfile;
519
+ cleanupRepo(repoDir, baseDir);
520
+ fs.rmSync(tempHome, { recursive: true, force: true });
521
+ }
522
+ });
523
+
524
+ it("uses SELESAI_SUBAGENTS_WORKTREE_DIR when no base directory is configured", async () => {
343
525
  const repoDir = createRepo("pi-worktree-env-base-dir-");
344
526
  const previous = process.env.SELESAI_SUBAGENTS_WORKTREE_DIR;
345
527
  const baseDir = path.join(os.tmpdir(), `pi-worktree-env-base-${Date.now().toString(36)}`);
346
528
  let setup: WorktreeSetup | undefined;
347
529
  try {
348
530
  process.env.SELESAI_SUBAGENTS_WORKTREE_DIR = baseDir;
349
- setup = createWorktrees(repoDir, "env-base-dir", 1);
350
- assert.equal(setup.worktrees[0]!.path, path.join(baseDir, "pi-worktree-env-base-dir-0"));
531
+ setup = await createWorktrees(repoDir, "env-base-dir", 1);
532
+ assert.equal(
533
+ setup.worktrees[0]!.path,
534
+ path.join(baseDir, path.basename(repoDir), "pi-worktree-env-base-dir-0"),
535
+ );
351
536
  } finally {
352
537
  if (setup) cleanupWorktrees(setup);
353
538
  if (previous === undefined) {
@@ -355,17 +540,104 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
355
540
  } else {
356
541
  process.env.SELESAI_SUBAGENTS_WORKTREE_DIR = previous;
357
542
  }
543
+ cleanupRepo(repoDir, baseDir);
544
+ }
545
+ });
546
+
547
+ it("rejects empty worktree base directory", async () => {
548
+ const repoDir = createRepo("pi-worktree-empty-base-");
549
+ try {
550
+ await assert.rejects(
551
+ () => createWorktrees(repoDir, "empty-base", 1, { baseDir: " " }),
552
+ /worktree base directory cannot be empty/,
553
+ );
554
+ } finally {
555
+ cleanupRepo(repoDir);
556
+ }
557
+ });
558
+
559
+ it("rejects worktree paths that would land inside the repository checkout", async () => {
560
+ const repoDir = createRepo("pi-worktree-inside-");
561
+ const repoParent = path.dirname(repoDir);
562
+ const leakedLeaf = "pi-worktree-inside-0";
563
+ try {
564
+ await assert.rejects(
565
+ () => createWorktrees(repoDir, "inside", 1, { baseDir: repoParent }),
566
+ /inside the repository/i,
567
+ );
568
+ assert.throws(
569
+ () => resolveExpectedWorktreeAgentCwd(repoDir, "inside", 0, repoParent),
570
+ /inside the repository/i,
571
+ );
572
+
573
+ const porcelain = git(repoDir, ["worktree", "list", "--porcelain"]);
574
+ const worktreeLines = porcelain.split("\n").filter((line) => line.startsWith("worktree "));
575
+ assert.deepEqual(worktreeLines, [`worktree ${git(repoDir, ["rev-parse", "--show-toplevel"])}`]);
576
+ assert.equal(fs.existsSync(path.join(repoDir, leakedLeaf)), false);
577
+ assert.equal(fs.existsSync(path.join(repoParent, leakedLeaf)), false);
578
+ } finally {
579
+ cleanupRepo(repoDir);
580
+ }
581
+ });
582
+
583
+ it("rejects worktree paths that are direct children of the repository parent", async () => {
584
+ const repoDir = createRepo("pi-worktree-parent-child-");
585
+ const repoParent = path.dirname(repoDir);
586
+ const dedicatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-worktree-parent-child-root-"));
587
+ const projectDir = path.join(dedicatedRoot, path.basename(repoDir));
588
+ const leakedLeaf = path.join(repoParent, "pi-worktree-parent-child-0");
589
+ try {
590
+ fs.symlinkSync(repoParent, projectDir, process.platform === "win32" ? "junction" : "dir");
591
+ await assert.rejects(
592
+ () => createWorktrees(repoDir, "parent-child", 1, { baseDir: dedicatedRoot }),
593
+ /direct child of the repository parent/i,
594
+ );
595
+ assert.throws(
596
+ () => resolveExpectedWorktreeAgentCwd(repoDir, "parent-child", 0, dedicatedRoot),
597
+ /direct child of the repository parent/i,
598
+ );
599
+
600
+ const porcelain = git(repoDir, ["worktree", "list", "--porcelain"]);
601
+ const worktreeLines = porcelain.split("\n").filter((line) => line.startsWith("worktree "));
602
+ assert.deepEqual(worktreeLines, [`worktree ${git(repoDir, ["rev-parse", "--show-toplevel"])}`]);
603
+ assert.equal(fs.existsSync(leakedLeaf), false);
604
+ } finally {
605
+ try { fs.rmSync(leakedLeaf, { recursive: true, force: true }); } catch {}
606
+ fs.rmSync(dedicatedRoot, { recursive: true, force: true });
358
607
  cleanupRepo(repoDir);
359
- fs.rmSync(baseDir, { recursive: true, force: true });
360
608
  }
361
609
  });
362
610
 
363
- it("createWorktrees rejects dirty repositories", () => {
611
+ it("does not mkdir inside the checkout when rejecting unsafe locations", async () => {
612
+ const repoDir = createRepo("pi-worktree-no-mkdir-inside-");
613
+ try {
614
+ await assert.rejects(
615
+ () => createWorktrees(repoDir, "inside-self", 1, { baseDir: repoDir }),
616
+ /inside the repository/i,
617
+ );
618
+ assert.throws(
619
+ () => resolveExpectedWorktreeAgentCwd(repoDir, "inside-self", 0, repoDir),
620
+ /inside the repository/i,
621
+ );
622
+ assert.equal(fs.existsSync(path.join(repoDir, path.basename(repoDir))), false);
623
+
624
+ await assert.rejects(
625
+ () => createWorktrees(repoDir, "rel-worktrees", 1, { baseDir: "worktrees" }),
626
+ /inside the repository/i,
627
+ );
628
+ assert.equal(fs.existsSync(path.join(repoDir, "worktrees")), false);
629
+ assert.equal(fs.existsSync(path.join(repoDir, "worktrees", path.basename(repoDir))), false);
630
+ } finally {
631
+ cleanupRepo(repoDir);
632
+ }
633
+ });
634
+
635
+ it("createWorktrees rejects dirty repositories before resolving the requested base ref", async () => {
364
636
  const repoDir = createRepo("pi-worktree-dirty-");
365
637
  try {
366
638
  fs.writeFileSync(path.join(repoDir, "tracked.txt"), "dirty\n", "utf-8");
367
- assert.throws(
368
- () => createWorktrees(repoDir, "dirty", 1),
639
+ await assert.rejects(
640
+ () => createWorktrees(repoDir, "dirty", 1, { baseRef: "unsafe..ref" }),
369
641
  /worktree isolation requires a clean git working tree/i,
370
642
  );
371
643
  } finally {
@@ -373,13 +645,64 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
373
645
  }
374
646
  });
375
647
 
376
- it("createWorktrees ignores pi-subagents runtime artifacts in the source checkout", () => {
648
+ it("allocates from baseRef while preserving source HEAD and propagating baseCommit to hooks and diffs", { skip: hookScriptSkip }, async () => {
649
+ const repoDir = createRepo("pi-worktree-base-ref-");
650
+ const firstCommit = git(repoDir, ["rev-parse", "HEAD"]);
651
+ git(repoDir, ["branch", "release"]);
652
+ fs.writeFileSync(path.join(repoDir, "tracked.txt"), "second\n", "utf-8");
653
+ git(repoDir, ["add", "tracked.txt"]);
654
+ git(repoDir, ["commit", "-m", "second commit"]);
655
+ const sourceHead = git(repoDir, ["rev-parse", "HEAD"]);
656
+ const hookPath = createHookScript(repoDir, "base-ref-hook.mjs", `
657
+ import * as fs from "node:fs";
658
+ const payload = JSON.parse(fs.readFileSync(0, "utf-8"));
659
+ fs.writeFileSync(payload.worktreePath + "/.base-commit", payload.baseCommit + "\\n", "utf-8");
660
+ process.stdout.write(JSON.stringify({ syntheticPaths: [".base-commit"] }));
661
+ `);
662
+ let setup: WorktreeSetup | undefined;
663
+ try {
664
+ setup = await createWorktrees(repoDir, "base-ref", 1, {
665
+ provider: "native",
666
+ baseRef: "release",
667
+ setupHook: { hookPath: path.relative(repoDir, hookPath) },
668
+ });
669
+ const worktree = setup.worktrees[0]!;
670
+ assert.equal(setup.baseCommit, firstCommit);
671
+ assert.equal(git(worktree.path, ["rev-parse", "HEAD"]), firstCommit);
672
+ assert.equal(git(repoDir, ["rev-parse", "HEAD"]), sourceHead);
673
+ assert.equal(fs.readFileSync(path.join(worktree.path, ".base-commit"), "utf-8").trim(), firstCommit);
674
+ fs.writeFileSync(path.join(worktree.path, "tracked.txt"), "agent change\n", "utf-8");
675
+ const diffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "base-ref"));
676
+ assert.equal(diffs[0]?.error, undefined);
677
+ assert.match(fs.readFileSync(diffs[0]!.patchPath, "utf-8"), /tracked\.txt/);
678
+ } finally {
679
+ if (setup) cleanupWorktrees(setup, { kind: "setup-rollback" });
680
+ cleanupRepo(repoDir);
681
+ }
682
+ });
683
+
684
+ it("rejects unsafe and unresolved base refs before allocation", async () => {
685
+ const repoDir = createRepo("pi-worktree-invalid-base-ref-");
686
+ try {
687
+ for (const baseRef of ["unsafe..ref", "branch name", "HEAD^{tree}", "@", "a".repeat(40), "a".repeat(64)] as const) {
688
+ await assert.rejects(() => createWorktrees(repoDir, `invalid-${baseRef.length}`, 1, { provider: "native", baseRef }), /baseRef.*HEAD.*named ref.*40\/64-character commit IDs.*revision expressions.*unsupported/);
689
+ }
690
+ git(repoDir, ["tag", "tree-object", "HEAD^{tree}"]);
691
+ await assert.rejects(() => createWorktrees(repoDir, "invalid-tree", 1, { provider: "native", baseRef: "tree-object" }), /could not be resolved to a commit/i);
692
+ await assert.rejects(() => createWorktrees(repoDir, "invalid-missing", 1, { provider: "native", baseRef: "refs/heads/missing" }), /could not be resolved to a commit/i);
693
+ assert.equal(git(repoDir, ["worktree", "list", "--porcelain"]).split("\n").filter((line) => line.startsWith("worktree ")).length, 1);
694
+ } finally {
695
+ cleanupRepo(repoDir);
696
+ }
697
+ });
698
+
699
+ it("createWorktrees ignores pi-subagents runtime artifacts in the source checkout", async () => {
377
700
  const repoDir = createRepo("pi-worktree-runtime-artifacts-");
378
701
  let setup: WorktreeSetup | undefined;
379
702
  try {
380
703
  fs.mkdirSync(path.join(repoDir, ".pi-subagents", "missions"), { recursive: true });
381
704
  fs.writeFileSync(path.join(repoDir, ".pi-subagents", "missions", "mission.json"), "{}\n", "utf-8");
382
- setup = createWorktrees(repoDir, "runtime-artifacts", 1);
705
+ setup = await createWorktrees(repoDir, "runtime-artifacts", 1);
383
706
  assert.equal(setup.worktrees.length, 1);
384
707
  } finally {
385
708
  if (setup) cleanupWorktrees(setup);
@@ -428,7 +751,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
428
751
  });
429
752
  });
430
753
 
431
- it("diffWorktrees captures committed, modified, and new files without staging the node_modules symlink", () => {
754
+ it("diffWorktrees captures committed, modified, and new files without staging the node_modules symlink", async () => {
432
755
  const repoDir = createRepo("pi-worktree-diff-");
433
756
  const nodeModulesDir = path.join(repoDir, "node_modules");
434
757
  fs.mkdirSync(nodeModulesDir, { recursive: true });
@@ -436,7 +759,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
436
759
 
437
760
  let setup: WorktreeSetup | undefined;
438
761
  try {
439
- setup = createWorktrees(repoDir, `diff-${process.pid}`, 1);
762
+ setup = await createWorktrees(repoDir, `diff-${process.pid}`, 1);
440
763
  const worktree = setup.worktrees[0]!;
441
764
  fs.writeFileSync(path.join(worktree.path, "committed.ts"), "export const committed = true;\n", "utf-8");
442
765
  git(worktree.path, ["add", "committed.ts"]);
@@ -467,11 +790,58 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
467
790
  }
468
791
  });
469
792
 
470
- it("cleanupWorktrees removes worktrees and branches", () => {
793
+ it("captures applyable patches despite external diffs and display configuration", { skip: hookScriptSkip }, async () => {
794
+ const repoDir = createRepo("pi-worktree-diff-external-");
795
+ const externalDiffPath = createHookScript(repoDir, "external-diff.mjs", `
796
+ process.stdout.write("corrupt external diff output\\n");
797
+ `);
798
+ let setup: WorktreeSetup | undefined;
799
+ try {
800
+ git(repoDir, ["config", "diff.external", externalDiffPath]);
801
+ git(repoDir, ["config", "color.ui", "always"]);
802
+ git(repoDir, ["config", "diff.noprefix", "true"]);
803
+ git(repoDir, ["config", "diff.linePrefix", "corrupt display prefix "]);
804
+ git(repoDir, ["config", "diff.relative", "true"]);
805
+ setup = await createWorktrees(repoDir, "diff-external", 1);
806
+ const worktree = setup.worktrees[0]!;
807
+ fs.writeFileSync(path.join(worktree.path, "tracked.txt"), "external-safe\n", "utf-8");
808
+
809
+ const diffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "external"));
810
+ assert.equal(diffs[0]?.error, undefined);
811
+ const patch = fs.readFileSync(diffs[0]!.patchPath, "utf-8");
812
+ assert.match(patch, /^diff --git a\/tracked\.txt b\/tracked\.txt/m);
813
+ assert.doesNotMatch(patch, /corrupt external diff output|corrupt display prefix|\u001b/);
814
+ assert.equal(git(worktree.path, ["apply", "--check", "--cached", "--reverse", diffs[0]!.patchPath]), "");
815
+ } finally {
816
+ if (setup) cleanupWorktrees(setup, { kind: "setup-rollback" });
817
+ cleanupRepo(repoDir);
818
+ }
819
+ });
820
+
821
+ it("includes binary data in captured patches", async () => {
822
+ const repoDir = createRepo("pi-worktree-diff-binary-");
823
+ let setup: WorktreeSetup | undefined;
824
+ try {
825
+ setup = await createWorktrees(repoDir, "diff-binary", 1);
826
+ const worktree = setup.worktrees[0]!;
827
+ fs.writeFileSync(path.join(worktree.path, "binary.dat"), Buffer.from([0, 1, 2, 255, 0, 3]));
828
+
829
+ const diffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "binary"));
830
+ assert.equal(diffs[0]?.error, undefined);
831
+ const patch = fs.readFileSync(diffs[0]!.patchPath, "utf-8");
832
+ assert.match(patch, /GIT binary patch/);
833
+ assert.equal(git(worktree.path, ["apply", "--check", "--cached", "--reverse", diffs[0]!.patchPath]), "");
834
+ } finally {
835
+ if (setup) cleanupWorktrees(setup, { kind: "setup-rollback" });
836
+ cleanupRepo(repoDir);
837
+ }
838
+ });
839
+
840
+ it("cleanupWorktrees removes worktrees and branches", async () => {
471
841
  const repoDir = createRepo("pi-worktree-cleanup-");
472
842
  let setup: WorktreeSetup | undefined;
473
843
  try {
474
- setup = createWorktrees(repoDir, "cleanup", 2);
844
+ setup = await createWorktrees(repoDir, "cleanup", 2);
475
845
  const worktreePaths = setup.worktrees.map((worktree) => worktree.path);
476
846
  const branches = setup.worktrees.map((worktree) => worktree.branch);
477
847
  const cleanup = cleanupWorktrees(setup);
@@ -494,11 +864,11 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
494
864
  }
495
865
  });
496
866
 
497
- it("preserves dirty worktrees when no patch was captured", () => {
867
+ it("preserves dirty worktrees when no patch was captured", async () => {
498
868
  const repoDir = createRepo("pi-worktree-uncaptured-");
499
869
  let setup: WorktreeSetup | undefined;
500
870
  try {
501
- setup = createWorktrees(repoDir, "uncaptured", 1);
871
+ setup = await createWorktrees(repoDir, "uncaptured", 1);
502
872
  const worktree = setup.worktrees[0]!;
503
873
  fs.writeFileSync(path.join(worktree.path, "tracked.txt"), "uncaptured\n", "utf-8");
504
874
 
@@ -521,11 +891,11 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
521
891
  }
522
892
  });
523
893
 
524
- it("preserves committed branch work when no patch was captured", () => {
894
+ it("preserves committed branch work when no patch was captured", async () => {
525
895
  const repoDir = createRepo("pi-worktree-committed-uncaptured-");
526
896
  let setup: WorktreeSetup | undefined;
527
897
  try {
528
- setup = createWorktrees(repoDir, "committed-uncaptured", 1);
898
+ setup = await createWorktrees(repoDir, "committed-uncaptured", 1);
529
899
  const worktree = setup.worktrees[0]!;
530
900
  fs.writeFileSync(path.join(worktree.path, "committed.txt"), "unlanded commit\n", "utf-8");
531
901
  git(worktree.path, ["add", "committed.txt"]);
@@ -545,11 +915,36 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
545
915
  }
546
916
  });
547
917
 
548
- it("removes dirty worktrees only after their captured patch is recorded in the handoff manifest", () => {
918
+ it("does not let trusted external diff hide uncaptured committed work during cleanup", { skip: hookScriptSkip }, async () => {
919
+ const repoDir = createRepo("pi-worktree-cleanup-external-diff-");
920
+ const externalDiffPath = createHookScript(repoDir, "external-diff-clean.mjs", "process.exit(0);");
921
+ let setup: WorktreeSetup | undefined;
922
+ try {
923
+ git(repoDir, ["config", "diff.external", externalDiffPath]);
924
+ git(repoDir, ["config", "diff.trustExitCode", "true"]);
925
+ setup = await createWorktrees(repoDir, "cleanup-external-diff", 1);
926
+ const worktree = setup.worktrees[0]!;
927
+ fs.writeFileSync(path.join(worktree.path, "committed.txt"), "unlanded work\n", "utf-8");
928
+ git(worktree.path, ["add", "committed.txt"]);
929
+ git(worktree.path, ["commit", "-m", "unlanded work"]);
930
+
931
+ const cleanup = cleanupWorktrees(setup);
932
+ assert.equal(cleanup.state, "partial");
933
+ assert.equal(cleanup.tasks[0]?.preserved, true);
934
+ assert.match(cleanup.tasks[0]?.reason ?? "", /not represented by a captured handoff patch/);
935
+ assert.equal(fs.existsSync(worktree.path), true);
936
+ assert.notEqual(git(repoDir, ["branch", "--list", worktree.branch]), "");
937
+ } finally {
938
+ if (setup) cleanupWorktrees(setup, { kind: "setup-rollback" });
939
+ cleanupRepo(repoDir);
940
+ }
941
+ });
942
+
943
+ it("removes dirty worktrees only after their captured patch is recorded in the handoff manifest", async () => {
549
944
  const repoDir = createRepo("pi-worktree-captured-");
550
945
  let setup: WorktreeSetup | undefined;
551
946
  try {
552
- setup = createWorktrees(repoDir, "captured", 1);
947
+ setup = await createWorktrees(repoDir, "captured", 1);
553
948
  const worktreePath = setup.worktrees[0]!.path;
554
949
  fs.writeFileSync(path.join(worktreePath, "tracked.txt"), "captured\n", "utf-8");
555
950
  const diffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "captured"));
@@ -565,7 +960,21 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
565
960
  version: 1,
566
961
  groups: [{ children: [{ patch: { path: diffs[0]!.patchPath } }] }],
567
962
  }), "utf-8");
568
- const cleanup = cleanupWorktrees(setup, { kind: "preserve", capturedDiffs: diffs, handoffManifestPath });
963
+ fs.writeFileSync(diffs[0]!.patchPath, "corrupt patch\n", "utf-8");
964
+ const invalidPatch = cleanupWorktrees(setup, { kind: "preserve", capturedDiffs: diffs, handoffManifestPath });
965
+ assert.equal(invalidPatch.state, "partial");
966
+ assert.match(invalidPatch.tasks[0]?.reason ?? "", /failed validation/);
967
+ assert.equal(fs.existsSync(worktreePath), true);
968
+
969
+ const validatedDiffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "captured"));
970
+ fs.writeFileSync(path.join(worktreePath, "late.txt"), "late\n", "utf-8");
971
+ const stalePatch = cleanupWorktrees(setup, { kind: "preserve", capturedDiffs: validatedDiffs, handoffManifestPath });
972
+ assert.equal(stalePatch.state, "partial");
973
+ assert.match(stalePatch.tasks[0]?.reason ?? "", /does not match current worktree changes/);
974
+ assert.equal(fs.existsSync(worktreePath), true);
975
+
976
+ const currentDiffs = diffWorktrees(setup, ["worker"], path.join(repoDir, "artifacts", "captured"));
977
+ const cleanup = cleanupWorktrees(setup, { kind: "preserve", capturedDiffs: currentDiffs, handoffManifestPath });
569
978
  assert.equal(cleanup.state, "complete");
570
979
  assert.equal(fs.existsSync(worktreePath), false);
571
980
  setup = undefined;
@@ -577,7 +986,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
577
986
 
578
987
  it("createWorktrees creates node_modules symlink when node_modules exists", {
579
988
  skip: process.platform === "win32" ? "Symlink behavior differs on Windows CI environments." : undefined,
580
- }, () => {
989
+ }, async () => {
581
990
  const repoDir = createRepo("pi-worktree-node-modules-");
582
991
  const nodeModulesDir = path.join(repoDir, "node_modules");
583
992
  fs.mkdirSync(nodeModulesDir, { recursive: true });
@@ -585,7 +994,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
585
994
 
586
995
  let setup: WorktreeSetup | undefined;
587
996
  try {
588
- setup = createWorktrees(repoDir, "node-modules", 1);
997
+ setup = await createWorktrees(repoDir, "node-modules", 1);
589
998
  const symlinkPath = path.join(setup.worktrees[0]!.path, "node_modules");
590
999
  assert.equal(setup.worktrees[0]!.nodeModulesLinked, true);
591
1000
  assert.deepEqual(setup.worktrees[0]!.syntheticPaths, ["node_modules"]);
@@ -600,7 +1009,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
600
1009
 
601
1010
  it("diffWorktrees preserves a tracked node_modules symlink", {
602
1011
  skip: process.platform === "win32" ? "Symlink behavior differs on Windows CI environments." : undefined,
603
- }, () => {
1012
+ }, async () => {
604
1013
  const repoDir = createRepo("pi-worktree-tracked-node-modules-");
605
1014
  const vendorDir = path.join(repoDir, "vendor-modules");
606
1015
  fs.mkdirSync(vendorDir, { recursive: true });
@@ -611,7 +1020,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
611
1020
 
612
1021
  let setup: WorktreeSetup | undefined;
613
1022
  try {
614
- setup = createWorktrees(repoDir, `tracked-node-modules-${process.pid}`, 1);
1023
+ setup = await createWorktrees(repoDir, `tracked-node-modules-${process.pid}`, 1);
615
1024
  assert.equal(setup.worktrees[0]!.nodeModulesLinked, false);
616
1025
  assert.deepEqual(setup.worktrees[0]!.syntheticPaths, []);
617
1026
  fs.writeFileSync(path.join(setup.worktrees[0]!.path, "tracked.txt"), "modified\n", "utf-8");
@@ -627,7 +1036,7 @@ console.log(JSON.stringify({ action: "created", branch, path: repo, created_bran
627
1036
  }
628
1037
  });
629
1038
 
630
- it("runs a repo-relative worktree setup hook and records synthetic paths", { skip: hookScriptSkip }, () => {
1039
+ it("runs a repo-relative worktree setup hook and records synthetic paths", { skip: hookScriptSkip }, async () => {
631
1040
  const repoDir = createRepo("pi-worktree-hook-relative-");
632
1041
  const hookPath = createHookScript(repoDir, "setup-hook.mjs", `
633
1042
  import * as fs from "node:fs";
@@ -640,7 +1049,7 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [".venv"] }));
640
1049
 
641
1050
  let setup: WorktreeSetup | undefined;
642
1051
  try {
643
- setup = createWorktrees(repoDir, "hook-relative", 1, {
1052
+ setup = await createWorktrees(repoDir, "hook-relative", 1, {
644
1053
  setupHook: { hookPath: path.relative(repoDir, hookPath) },
645
1054
  });
646
1055
  assert.ok(setup.worktrees[0]!.syntheticPaths.includes(".venv"));
@@ -650,7 +1059,7 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [".venv"] }));
650
1059
  }
651
1060
  });
652
1061
 
653
- it("runs an absolute worktree setup hook path", { skip: hookScriptSkip }, () => {
1062
+ it("runs an absolute worktree setup hook path", { skip: hookScriptSkip }, async () => {
654
1063
  const repoDir = createRepo("pi-worktree-hook-absolute-");
655
1064
  const hookPath = createHookScript(repoDir, "setup-hook.mjs", `
656
1065
  import * as fs from "node:fs";
@@ -660,7 +1069,7 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [] }));
660
1069
 
661
1070
  let setup: WorktreeSetup | undefined;
662
1071
  try {
663
- setup = createWorktrees(repoDir, "hook-absolute", 1, {
1072
+ setup = await createWorktrees(repoDir, "hook-absolute", 1, {
664
1073
  setupHook: { hookPath },
665
1074
  });
666
1075
  assert.equal(setup.worktrees.length, 1);
@@ -670,10 +1079,10 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [] }));
670
1079
  }
671
1080
  });
672
1081
 
673
- it("rejects bare command names for worktree setup hooks", () => {
1082
+ it("rejects bare command names for worktree setup hooks", async () => {
674
1083
  const repoDir = createRepo("pi-worktree-hook-bare-");
675
1084
  try {
676
- assert.throws(
1085
+ await assert.rejects(
677
1086
  () => createWorktrees(repoDir, "hook-bare", 1, { setupHook: { hookPath: "node" } }),
678
1087
  /worktree setup hook must be an absolute path or a repo-relative path/i,
679
1088
  );
@@ -682,7 +1091,8 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [] }));
682
1091
  }
683
1092
  });
684
1093
 
685
- it("rejects tracked synthetic paths from hook output", { skip: hookScriptSkip }, () => {
1094
+ it("rejects tracked synthetic paths from hook output", { skip: hookScriptSkip }, async () => {
1095
+ if (await runPoisonCaseInChild("rejects tracked synthetic paths from hook output")) return;
686
1096
  const repoDir = createRepo("pi-worktree-hook-tracked-");
687
1097
  const hookPath = createHookScript(repoDir, "tracked-hook.mjs", `
688
1098
  import * as fs from "node:fs";
@@ -690,17 +1100,24 @@ JSON.parse(fs.readFileSync(0, "utf-8"));
690
1100
  process.stdout.write(JSON.stringify({ syntheticPaths: ["tracked.txt"] }));
691
1101
  `);
692
1102
  const runId = `hook-tracked-${Date.now().toString(36)}`;
1103
+ let retained: WorktreeSetup | undefined;
693
1104
  try {
694
- assert.throws(
1105
+ await assert.rejects(
695
1106
  () => createWorktrees(repoDir, runId, 1, { setupHook: { hookPath: path.relative(repoDir, hookPath) } }),
696
- /cannot mark tracked paths as synthetic/i,
1107
+ (error: unknown) => {
1108
+ assert.ok(error instanceof WorktreeSetupError);
1109
+ retained = error.snapshot.setup;
1110
+ return assertRetainedHookFailure(error, /cannot mark tracked paths as synthetic/i);
1111
+ },
697
1112
  );
698
1113
  } finally {
1114
+ for (const worktree of retained?.worktrees ?? []) fs.rmSync(worktree.path, { recursive: true, force: true });
699
1115
  cleanupRepo(repoDir);
700
1116
  }
701
1117
  });
702
1118
 
703
- it("rejects absolute synthetic paths from hook output", { skip: hookScriptSkip }, () => {
1119
+ it("rejects absolute synthetic paths from hook output", { skip: hookScriptSkip }, async () => {
1120
+ if (await runPoisonCaseInChild("rejects absolute synthetic paths from hook output")) return;
704
1121
  const repoDir = createRepo("pi-worktree-hook-absolute-synthetic-");
705
1122
  const hookPath = createHookScript(repoDir, "absolute-path-hook.mjs", `
706
1123
  import * as fs from "node:fs";
@@ -708,17 +1125,23 @@ const payload = JSON.parse(fs.readFileSync(0, "utf-8"));
708
1125
  process.stdout.write(JSON.stringify({ syntheticPaths: [payload.worktreePath + "/.venv"] }));
709
1126
  `);
710
1127
  const runId = `hook-absolute-synthetic-${Date.now().toString(36)}`;
1128
+ let retained: WorktreeSetup | undefined;
711
1129
  try {
712
- assert.throws(
1130
+ await assert.rejects(
713
1131
  () => createWorktrees(repoDir, runId, 1, { setupHook: { hookPath: path.relative(repoDir, hookPath) } }),
714
- /synthetic path must be relative/i,
1132
+ (error: unknown) => {
1133
+ assert.ok(error instanceof WorktreeSetupError);
1134
+ retained = error.snapshot.setup;
1135
+ return assertRetainedHookFailure(error, /synthetic path must be relative/i);
1136
+ },
715
1137
  );
716
1138
  } finally {
1139
+ for (const worktree of retained?.worktrees ?? []) fs.rmSync(worktree.path, { recursive: true, force: true });
717
1140
  cleanupRepo(repoDir);
718
1141
  }
719
1142
  });
720
1143
 
721
- it("excludes hook-created synthetic files from captured patch output", { skip: hookScriptSkip }, () => {
1144
+ it("excludes hook-created synthetic files from captured patch output", { skip: hookScriptSkip }, async () => {
722
1145
  const repoDir = createRepo("pi-worktree-hook-diff-");
723
1146
  const hookPath = createHookScript(repoDir, "setup-copy-hook.mjs", `
724
1147
  import * as fs from "node:fs";
@@ -730,7 +1153,7 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [".env.local"] }));
730
1153
 
731
1154
  let setup: WorktreeSetup | undefined;
732
1155
  try {
733
- setup = createWorktrees(repoDir, `hook-diff-${process.pid}`, 1, {
1156
+ setup = await createWorktrees(repoDir, `hook-diff-${process.pid}`, 1, {
734
1157
  setupHook: { hookPath: path.relative(repoDir, hookPath) },
735
1158
  });
736
1159
  fs.writeFileSync(path.join(setup.worktrees[0]!.path, "tracked.txt"), "modified-by-agent\n", "utf-8");
@@ -744,9 +1167,11 @@ process.stdout.write(JSON.stringify({ syntheticPaths: [".env.local"] }));
744
1167
  }
745
1168
  });
746
1169
 
747
- it("cleans up created worktrees when a later hook setup fails", { skip: hookScriptSkip }, () => {
1170
+ it("retains created worktrees when a later hook failure has unknown settlement", { skip: hookScriptSkip }, async () => {
1171
+ if (await runPoisonCaseInChild("retains created worktrees when a later hook failure has unknown settlement")) return;
748
1172
  const repoDir = createRepo("pi-worktree-hook-cleanup-");
749
1173
  const runId = `hook-cleanup-${Date.now().toString(36)}`;
1174
+ let retained: WorktreeSetup | undefined;
750
1175
  const hookPath = createHookScript(repoDir, "flaky-hook.mjs", `
751
1176
  import * as fs from "node:fs";
752
1177
  const payload = JSON.parse(fs.readFileSync(0, "utf-8"));
@@ -757,18 +1182,29 @@ if (payload.index === 1) {
757
1182
  process.stdout.write(JSON.stringify({ syntheticPaths: [] }));
758
1183
  `);
759
1184
  try {
760
- assert.throws(
1185
+ await assert.rejects(
761
1186
  () => createWorktrees(repoDir, runId, 2, { setupHook: { hookPath: path.relative(repoDir, hookPath) } }),
762
- /worktree setup hook failed with exit code 1/i,
1187
+ (error: unknown) => {
1188
+ assert.ok(error instanceof WorktreeSetupError);
1189
+ retained = error.snapshot.setup;
1190
+ assertRetainedHookFailure(error, /worktree setup hook failed/i);
1191
+ assert.equal(error.snapshot.attempts[1]?.hookCommand?.result?.status, 1);
1192
+ assert.equal(error.snapshot.setup.worktrees.length, 2);
1193
+ for (const worktree of error.snapshot.setup.worktrees) {
1194
+ assert.equal(fs.existsSync(worktree.path), true);
1195
+ assert.equal(git(worktree.path, ["branch", "--show-current"]), worktree.branch);
1196
+ }
1197
+ return true;
1198
+ },
763
1199
  );
764
- const branchList = git(repoDir, ["branch", "--list", "pi-subagents/task-hook-cleanup-s0-t*"]);
765
- assert.equal(branchList.trim(), "", "temporary branches should be cleaned up after setup failure");
1200
+ await assert.rejects(() => createWorktrees(repoDir, "after-hook-failure", 1, { provider: "native" }), /Worktree mutation blocked/i);
766
1201
  } finally {
1202
+ for (const worktree of retained?.worktrees ?? []) fs.rmSync(worktree.path, { recursive: true, force: true });
767
1203
  cleanupRepo(repoDir);
768
1204
  }
769
1205
  });
770
1206
 
771
- it("fails when the hook exceeds the configured timeout", { skip: hookScriptSkip }, () => {
1207
+ it("fails when the hook exceeds the configured timeout", { skip: hookScriptSkip }, async () => {
772
1208
  const repoDir = createRepo("pi-worktree-hook-timeout-");
773
1209
  const hookPath = createHookScript(repoDir, "slow-hook.mjs", `
774
1210
  import * as fs from "node:fs";
@@ -779,11 +1215,16 @@ setTimeout(() => {
779
1215
  `);
780
1216
  const runId = `hook-timeout-${Date.now().toString(36)}`;
781
1217
  try {
782
- assert.throws(
1218
+ await assert.rejects(
783
1219
  () => createWorktrees(repoDir, runId, 1, {
784
1220
  setupHook: { hookPath: path.relative(repoDir, hookPath), timeoutMs: 50 },
785
1221
  }),
786
- /timed out/i,
1222
+ (error: unknown) => {
1223
+ assert.ok(error instanceof WorktreeSetupError);
1224
+ assert.ok(error.cause instanceof Error && error.cause.cause instanceof Error);
1225
+ assert.match(error.cause.cause.message, /timed out/i);
1226
+ return true;
1227
+ },
787
1228
  );
788
1229
  } finally {
789
1230
  cleanupRepo(repoDir);
@@ -824,4 +1265,4 @@ describe("manifest-backed worktree discard", () => {
824
1265
  fs.rmSync(root, { recursive: true, force: true });
825
1266
  }
826
1267
  });
827
- });
1268
+ });