@fitlab-ai/agent-infra 0.9.14 → 0.10.0

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 (354) hide show
  1. package/README.md +10 -0
  2. package/README.zh-CN.md +10 -0
  3. package/bin/internal-cli.sh +45 -0
  4. package/dist/bin/internal-cli.js +93 -30
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +165 -27
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +9 -4
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +43 -3
  8. package/dist/lib/defaults.json +103 -103
  9. package/dist/lib/fs/durable-write.js +39 -0
  10. package/dist/lib/fs/file-lock.js +111 -0
  11. package/dist/lib/host-control/audit.js +19 -0
  12. package/dist/lib/host-control/client.js +142 -0
  13. package/dist/lib/host-control/command.js +49 -0
  14. package/dist/lib/host-control/path.js +160 -0
  15. package/dist/lib/host-control/secure-fs.js +140 -0
  16. package/dist/lib/host-control/server.js +159 -0
  17. package/dist/lib/host-control/service.js +112 -0
  18. package/dist/lib/init.js +11 -2
  19. package/dist/lib/internal/cli-route-inventory.js +4 -2
  20. package/dist/lib/internal/codex-lifecycle.js +6 -6
  21. package/dist/lib/internal/demo-promotion.js +322 -0
  22. package/dist/lib/internal/demo-transcript.js +454 -0
  23. package/dist/lib/internal/host-control.js +28 -0
  24. package/dist/lib/internal/manual-validation.js +331 -0
  25. package/dist/lib/internal/platform-pr.js +18 -4
  26. package/dist/lib/internal/release-workflow.js +63 -84
  27. package/dist/lib/internal/sandbox-control.js +149 -2
  28. package/dist/lib/internal/task-artifact.js +16 -263
  29. package/dist/lib/internal/task-create.js +6 -5
  30. package/dist/lib/internal/task-event.js +37 -37
  31. package/dist/lib/internal/task-lifecycle.js +2 -2
  32. package/dist/lib/internal/task-operation-registry.js +101 -4
  33. package/dist/lib/internal/task-review.js +11 -56
  34. package/dist/lib/internal/task-validate.js +30 -10
  35. package/dist/lib/owned-path.js +33 -0
  36. package/dist/lib/platform/check-status.js +11 -0
  37. package/dist/lib/platform/comment-safety.js +4 -1
  38. package/dist/lib/platform/completion-backfill.js +1 -1
  39. package/dist/lib/platform/github-data.js +241 -0
  40. package/dist/lib/platform/github-provider.js +15 -20
  41. package/dist/lib/platform/issue-comments.js +18 -12
  42. package/dist/lib/platform/issues.js +2 -180
  43. package/dist/lib/platform/pr-change-report.js +5 -4
  44. package/dist/lib/platform/pr-checks.js +12 -65
  45. package/dist/lib/platform/pr-summary.js +117 -33
  46. package/dist/lib/platform/pull-requests.js +5 -108
  47. package/dist/lib/platform/snapshots.js +2 -0
  48. package/dist/lib/platform/task-identities.js +2 -9
  49. package/dist/lib/platform/verification-sync.js +132 -143
  50. package/dist/lib/pr-review/evidence-grading.js +4 -8
  51. package/dist/lib/release/workflow.js +1 -1
  52. package/dist/lib/sandbox/commands/create.js +7 -2
  53. package/dist/lib/sandbox/commands/rm.js +19 -1511
  54. package/dist/lib/sandbox/control/audit.js +203 -0
  55. package/dist/lib/sandbox/control/client.js +93 -54
  56. package/dist/lib/sandbox/control/executor.js +129 -65
  57. package/dist/lib/sandbox/control/finalization-response.js +27 -0
  58. package/dist/lib/sandbox/control/identity-sentinel.js +107 -0
  59. package/dist/lib/sandbox/control/lifecycle.js +36 -2
  60. package/dist/lib/sandbox/control/native-file-lock.js +6 -98
  61. package/dist/lib/sandbox/control/protocol.js +55 -4
  62. package/dist/lib/sandbox/control/server.js +518 -127
  63. package/dist/lib/sandbox/control/state.js +64 -0
  64. package/dist/lib/sandbox/control/task-workflow.js +74 -0
  65. package/dist/lib/sandbox/control/workflow-executor.js +89 -0
  66. package/dist/lib/sandbox/index.js +1 -2
  67. package/dist/lib/sandbox/recovery.js +28 -4
  68. package/dist/lib/sandbox/removal.js +822 -0
  69. package/dist/lib/sandbox/task-cleanup.js +217 -0
  70. package/dist/lib/sandbox/workspace-identity.js +17 -1
  71. package/dist/lib/sandbox/workspace-view.js +13 -14
  72. package/dist/lib/task/activity-intent.js +2 -1
  73. package/dist/lib/task/activity-log.js +30 -24
  74. package/dist/lib/task/artifact-command.js +79 -0
  75. package/dist/lib/task/artifact-lifecycle.js +30 -28
  76. package/dist/lib/task/artifact-name.js +36 -0
  77. package/dist/lib/task/artifact-operations.js +29 -289
  78. package/dist/lib/task/artifact-receipts.js +3 -15
  79. package/dist/lib/task/artifact-recovery.js +430 -0
  80. package/dist/lib/task/artifact-repair-intent.js +134 -29
  81. package/dist/lib/task/artifact-schema.js +6 -14
  82. package/dist/lib/task/capabilities.js +12 -19
  83. package/dist/lib/task/codex-orchestration.js +71 -25
  84. package/dist/lib/task/commit-identity.js +28 -0
  85. package/dist/lib/task/commit-operation.js +33 -20
  86. package/dist/lib/task/control-authority.js +403 -6
  87. package/dist/lib/task/control-recovery.js +140 -0
  88. package/dist/lib/task/create-service.js +1 -8
  89. package/dist/lib/task/create.js +62 -9
  90. package/dist/lib/task/decision-details.js +2 -46
  91. package/dist/lib/task/delegation-receipts.js +64 -3
  92. package/dist/lib/task/events.js +275 -22
  93. package/dist/lib/task/finalization-state.js +9 -0
  94. package/dist/lib/task/human-override.js +8 -14
  95. package/dist/lib/task/implementation-inputs.js +3 -3
  96. package/dist/lib/task/intermediate-cleanup.js +441 -0
  97. package/dist/lib/task/invalidation.js +18 -65
  98. package/dist/lib/task/ledger-intents.js +7 -22
  99. package/dist/lib/task/ledger.js +1 -3
  100. package/dist/lib/task/lifecycle-recovery.js +549 -0
  101. package/dist/lib/task/lifecycle.js +31 -4
  102. package/dist/lib/task/local-artifact-finalization.js +197 -197
  103. package/dist/lib/task/manual-validation-completion.js +26 -0
  104. package/dist/lib/task/manual-validation-evidence.js +130 -0
  105. package/dist/lib/task/manual-validation-receipt.js +105 -0
  106. package/dist/lib/task/manual-validation-shared.js +40 -0
  107. package/dist/lib/task/manual-validation-transaction.js +243 -0
  108. package/dist/lib/task/markdown.js +47 -0
  109. package/dist/lib/task/orchestration.js +66 -19
  110. package/dist/lib/task/pr-delivery-fact.js +12 -56
  111. package/dist/lib/task/qualification-audit.js +20 -34
  112. package/dist/lib/task/recovery-warning.js +19 -0
  113. package/dist/lib/task/review-artifacts.js +2 -18
  114. package/dist/lib/task/review-command.js +26 -0
  115. package/dist/lib/task/review-finalization.js +175 -216
  116. package/dist/lib/task/review-fingerprint.js +2 -2
  117. package/dist/lib/task/sections.js +34 -56
  118. package/dist/lib/task/short-id.js +15 -7
  119. package/dist/lib/task/task-execution-lock.js +1 -1
  120. package/dist/lib/task/verification-engine.js +119 -337
  121. package/dist/lib/task/verification.js +1 -1
  122. package/dist/lib/task/workflow-command.js +16 -0
  123. package/dist/lib/task/workflow-warning-intents.js +0 -16
  124. package/dist/lib/task/workflow-warnings.js +28 -2
  125. package/dist/lib/update.js +2 -0
  126. package/dist/lib/version.js +1 -11
  127. package/dist/lifecycle-build-manifest.json +3 -3
  128. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +223 -33
  129. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +10 -4
  130. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +55 -3
  131. package/lib/fs/durable-write.ts +30 -0
  132. package/lib/fs/file-lock.ts +113 -0
  133. package/lib/host-control/audit.ts +22 -0
  134. package/lib/host-control/client.ts +172 -0
  135. package/lib/host-control/command.ts +60 -0
  136. package/lib/host-control/path.ts +167 -0
  137. package/lib/host-control/secure-fs.ts +135 -0
  138. package/lib/host-control/server.ts +180 -0
  139. package/lib/host-control/service.ts +133 -0
  140. package/lib/init.ts +15 -2
  141. package/lib/internal/cli-route-inventory.ts +4 -2
  142. package/lib/internal/codex-lifecycle.ts +6 -6
  143. package/lib/internal/demo-promotion.ts +354 -0
  144. package/lib/internal/demo-transcript.ts +419 -0
  145. package/lib/internal/host-control.ts +28 -0
  146. package/lib/internal/manual-validation.ts +315 -0
  147. package/lib/internal/platform-pr.ts +15 -4
  148. package/lib/internal/release-workflow.ts +74 -82
  149. package/lib/internal/sandbox-control.ts +162 -1
  150. package/lib/internal/task-artifact.ts +14 -217
  151. package/lib/internal/task-create.ts +5 -5
  152. package/lib/internal/task-event.ts +20 -11
  153. package/lib/internal/task-lifecycle.ts +4 -3
  154. package/lib/internal/task-operation-registry.ts +111 -4
  155. package/lib/internal/task-review.ts +9 -54
  156. package/lib/internal/task-validate.ts +26 -10
  157. package/lib/owned-path.ts +26 -0
  158. package/lib/platform/check-status.ts +9 -0
  159. package/lib/platform/comment-safety.ts +5 -0
  160. package/lib/platform/completion-backfill.ts +1 -1
  161. package/lib/platform/github-data.ts +322 -0
  162. package/lib/platform/github-provider.ts +15 -20
  163. package/lib/platform/issue-comments.ts +21 -14
  164. package/lib/platform/issues.ts +5 -246
  165. package/lib/platform/pr-change-report.ts +7 -5
  166. package/lib/platform/pr-checks.ts +13 -77
  167. package/lib/platform/pr-summary.ts +123 -35
  168. package/lib/platform/pull-requests.ts +6 -173
  169. package/lib/platform/snapshots.ts +53 -0
  170. package/lib/platform/task-identities.ts +3 -14
  171. package/lib/platform/verification-sync.ts +150 -156
  172. package/lib/pr-review/evidence-grading.ts +4 -9
  173. package/lib/release/workflow.ts +3 -3
  174. package/lib/sandbox/commands/create.ts +7 -6
  175. package/lib/sandbox/commands/rm.ts +21 -1895
  176. package/lib/sandbox/control/audit.ts +253 -0
  177. package/lib/sandbox/control/client.ts +109 -66
  178. package/lib/sandbox/control/executor.ts +134 -74
  179. package/lib/sandbox/control/finalization-response.ts +29 -0
  180. package/lib/sandbox/control/identity-sentinel.ts +137 -0
  181. package/lib/sandbox/control/lifecycle.ts +45 -3
  182. package/lib/sandbox/control/native-file-lock.ts +6 -107
  183. package/lib/sandbox/control/protocol.ts +80 -6
  184. package/lib/sandbox/control/server.ts +567 -132
  185. package/lib/sandbox/control/state.ts +86 -0
  186. package/lib/sandbox/control/task-workflow.ts +95 -0
  187. package/lib/sandbox/control/workflow-executor.ts +92 -0
  188. package/lib/sandbox/index.ts +1 -2
  189. package/lib/sandbox/recovery.ts +28 -5
  190. package/lib/sandbox/removal.ts +1142 -0
  191. package/lib/sandbox/task-cleanup.ts +209 -0
  192. package/lib/sandbox/workspace-identity.ts +19 -3
  193. package/lib/sandbox/workspace-view.ts +17 -15
  194. package/lib/task/activity-intent.ts +2 -2
  195. package/lib/task/activity-log.ts +27 -19
  196. package/lib/task/artifact-command.ts +85 -0
  197. package/lib/task/artifact-lifecycle.ts +31 -26
  198. package/lib/task/artifact-name.ts +55 -0
  199. package/lib/task/artifact-operations.ts +34 -310
  200. package/lib/task/artifact-receipts.ts +3 -14
  201. package/lib/task/artifact-recovery.ts +519 -0
  202. package/lib/task/artifact-repair-intent.ts +162 -33
  203. package/lib/task/artifact-schema.ts +5 -34
  204. package/lib/task/capabilities.ts +13 -22
  205. package/lib/task/codex-orchestration.ts +85 -24
  206. package/lib/task/commit-identity.ts +31 -0
  207. package/lib/task/commit-operation.ts +37 -20
  208. package/lib/task/control-authority.ts +540 -10
  209. package/lib/task/control-recovery.ts +186 -0
  210. package/lib/task/create-service.ts +1 -10
  211. package/lib/task/create.ts +63 -8
  212. package/lib/task/decision-details.ts +3 -71
  213. package/lib/task/delegation-receipts.ts +73 -2
  214. package/lib/task/events.ts +309 -24
  215. package/lib/task/finalization-state.ts +11 -0
  216. package/lib/task/human-override.ts +8 -16
  217. package/lib/task/implementation-inputs.ts +3 -3
  218. package/lib/task/intermediate-cleanup.ts +493 -0
  219. package/lib/task/invalidation.ts +18 -62
  220. package/lib/task/ledger-intents.ts +7 -24
  221. package/lib/task/ledger.ts +1 -3
  222. package/lib/task/lifecycle-recovery.ts +691 -0
  223. package/lib/task/lifecycle.ts +30 -5
  224. package/lib/task/local-artifact-finalization.ts +244 -234
  225. package/lib/task/manual-validation-completion.ts +40 -0
  226. package/lib/task/manual-validation-evidence.ts +209 -0
  227. package/lib/task/manual-validation-receipt.ts +156 -0
  228. package/lib/task/manual-validation-shared.ts +50 -0
  229. package/lib/task/manual-validation-transaction.ts +304 -0
  230. package/lib/task/markdown.ts +70 -0
  231. package/lib/task/orchestration.ts +84 -19
  232. package/lib/task/pr-delivery-fact.ts +15 -62
  233. package/lib/task/qualification-audit.ts +22 -33
  234. package/lib/task/recovery-warning.ts +21 -0
  235. package/lib/task/review-artifacts.ts +1 -20
  236. package/lib/task/review-command.ts +24 -0
  237. package/lib/task/review-finalization.ts +183 -292
  238. package/lib/task/review-fingerprint.ts +2 -2
  239. package/lib/task/sections.ts +33 -54
  240. package/lib/task/short-id.ts +27 -5
  241. package/lib/task/task-execution-lock.ts +1 -1
  242. package/lib/task/verification-engine.ts +113 -383
  243. package/lib/task/verification-types.ts +15 -0
  244. package/lib/task/verification.ts +1 -1
  245. package/lib/task/workflow-command.ts +17 -0
  246. package/lib/task/workflow-warning-intents.ts +0 -13
  247. package/lib/task/workflow-warnings.ts +21 -2
  248. package/lib/update.ts +6 -0
  249. package/lib/version.ts +1 -15
  250. package/package.json +6 -6
  251. package/templates/.agents/hooks/lifecycle-delegation.js +6 -6
  252. package/templates/.agents/rules/create-issue.en.md +1 -1
  253. package/templates/.agents/rules/create-issue.zh-CN.md +1 -1
  254. package/templates/.agents/rules/issue-sync.en.md +4 -2
  255. package/templates/.agents/rules/issue-sync.zh-CN.md +4 -3
  256. package/templates/.agents/rules/local-artifact-repair.en.md +46 -39
  257. package/templates/.agents/rules/local-artifact-repair.zh-CN.md +43 -36
  258. package/templates/.agents/rules/testing-discipline.en.md +2 -0
  259. package/templates/.agents/rules/testing-discipline.zh-CN.md +2 -0
  260. package/templates/.agents/scripts/package.json +3 -0
  261. package/templates/.agents/skills/analyze-task/SKILL.en.md +6 -6
  262. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +6 -6
  263. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  264. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  265. package/templates/.agents/skills/block-task/SKILL.en.md +2 -2
  266. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  267. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  268. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  269. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  270. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  271. package/templates/.agents/skills/code-task/SKILL.en.md +7 -7
  272. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +7 -7
  273. package/templates/.agents/skills/code-task/config/verify.en.json +1 -1
  274. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +1 -1
  275. package/templates/.agents/skills/commit/reference/issue-metadata-sync.en.md +1 -1
  276. package/templates/.agents/skills/commit/reference/issue-metadata-sync.zh-CN.md +1 -1
  277. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +33 -21
  278. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +33 -21
  279. package/templates/.agents/skills/complete-manual-validation/config/verify.en.json +1 -1
  280. package/templates/.agents/skills/complete-manual-validation/config/verify.zh-CN.json +1 -1
  281. package/templates/.agents/skills/complete-manual-validation/reference/report-template.en.md +2 -0
  282. package/templates/.agents/skills/complete-manual-validation/reference/report-template.zh-CN.md +2 -0
  283. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.en.md +16 -7
  284. package/templates/.agents/skills/complete-manual-validation/reference/summary-update.zh-CN.md +16 -7
  285. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
  286. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
  287. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -2
  288. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -2
  289. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  290. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  291. package/templates/.agents/skills/create-pr/SKILL.en.md +2 -2
  292. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +2 -2
  293. package/templates/.agents/skills/create-pr/config/verify.json +1 -1
  294. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +2 -2
  295. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +2 -2
  296. package/templates/.agents/skills/create-task/SKILL.en.md +5 -5
  297. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  298. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  299. package/templates/.agents/skills/import-issue/SKILL.en.md +8 -8
  300. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +8 -8
  301. package/templates/.agents/skills/import-issue/config/verify.json +2 -2
  302. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -4
  303. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -4
  304. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  305. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  306. package/templates/.agents/skills/release/SKILL.en.md +7 -3
  307. package/templates/.agents/skills/release/SKILL.zh-CN.md +7 -3
  308. package/templates/.agents/skills/restore-task/SKILL.en.md +2 -2
  309. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +2 -2
  310. package/templates/.agents/skills/restore-task/config/verify.json +1 -1
  311. package/templates/.agents/skills/review-analysis/SKILL.en.md +2 -2
  312. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +2 -2
  313. package/templates/.agents/skills/review-analysis/config/verify.en.json +1 -1
  314. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +1 -1
  315. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -7
  316. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +6 -6
  317. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  318. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  319. package/templates/.agents/skills/review-code/config/verify.en.json +1 -1
  320. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +1 -1
  321. package/templates/.agents/skills/review-code/reference/report-template.en.md +12 -12
  322. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +12 -12
  323. package/templates/.agents/skills/review-plan/SKILL.en.md +2 -2
  324. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +2 -2
  325. package/templates/.agents/skills/review-plan/config/verify.en.json +1 -1
  326. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +1 -1
  327. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -7
  328. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -7
  329. package/templates/.agents/skills/review-pr/config/verify.en.json +1 -1
  330. package/templates/.agents/skills/review-pr/config/verify.zh-CN.json +1 -1
  331. package/templates/.agents/skills/review-pr/reference/evidence-grading.en.md +3 -3
  332. package/templates/.agents/skills/review-pr/reference/evidence-grading.zh-CN.md +3 -3
  333. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +20 -3
  334. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +20 -3
  335. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +1 -1
  336. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +1 -1
  337. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +4 -1
  338. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +4 -1
  339. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +2 -0
  340. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +2 -0
  341. package/templates/.agents/skills/run-task/SKILL.en.md +1 -1
  342. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +1 -1
  343. package/templates/.agents/skills/test/SKILL.en.md +17 -6
  344. package/templates/.agents/skills/test/SKILL.zh-CN.md +17 -6
  345. package/templates/.agents/templates/task.en.md +1 -1
  346. package/templates/.agents/templates/task.zh-CN.md +1 -1
  347. package/templates/.claude/commands/import-issue.en.md +1 -1
  348. package/templates/.claude/commands/import-issue.zh-CN.md +1 -1
  349. package/templates/.claude/commands/restore-task.en.md +1 -1
  350. package/templates/.claude/commands/restore-task.zh-CN.md +1 -1
  351. package/templates/.claude/commands/run-manual-validation.en.md +1 -1
  352. package/templates/.claude/commands/run-manual-validation.zh-CN.md +1 -1
  353. package/dist/lib/platform/adapters.js +0 -56
  354. package/lib/platform/adapters.ts +0 -197
@@ -0,0 +1,1142 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import * as p from '@clack/prompts';
5
+ import pc from 'picocolors';
6
+ import type { SandboxConfig } from './config.ts';
7
+ import {
8
+ assertValidBranchName,
9
+ containerNameCandidates,
10
+ sandboxBranchLabel,
11
+ sandboxLabel,
12
+ sandboxTaskIdLabel,
13
+ sandboxWorkspaceModeLabel,
14
+ shareBranchDir,
15
+ shellConfigDirCandidates,
16
+ worktreeDirCandidates
17
+ } from './constants.ts';
18
+ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from './engine.ts';
19
+ import { pruneSandboxDanglingImages } from './image-prune.ts';
20
+ import { assertManagedPath, removeManagedDir, removeWorktreeDir } from './managed-fs.ts';
21
+ import { run, runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from './shell.ts';
22
+ import {
23
+ parseSandboxWorkspaceIdentity,
24
+ resolveSandboxCleanupTarget,
25
+ sameSandboxWorkspaceIdentity,
26
+ type SandboxCleanupTarget,
27
+ type SandboxWorkspaceKey
28
+ } from './workspace-identity.ts';
29
+ import { sandboxControlPaths, sandboxWorkspaceViewPaths } from './workspace-view.ts';
30
+ import {
31
+ advanceSandboxRemovalJournalToPhase,
32
+ advanceSandboxRemovalJournalPhase,
33
+ claimSandboxRemovalJournal,
34
+ clearSandboxRemovalJournalRecord,
35
+ isDefaultSandboxRemovalJournal,
36
+ listSandboxRemovalJournals,
37
+ removeSandboxControlRoot,
38
+ readSandboxControlManifest,
39
+ sandboxRemovalPhaseIndex,
40
+ type SandboxRemovalJournal,
41
+ type SandboxRemovalTargetCommit
42
+ } from './control/lifecycle.ts';
43
+ import { inspectSandboxControlContainer } from './control/container-identity.ts';
44
+ import { commandForSandboxAuthority } from './engines/authority.ts';
45
+ import { acquireSandboxResourceLock, type SandboxResourceLock } from './control/native-file-lock.ts';
46
+ import { toolConfigDirCandidates, toolProjectDirCandidates } from './tools.ts';
47
+ import type { SandboxTool } from './tools.ts';
48
+ import { getProcessStartTime } from '../server/process-state.ts';
49
+ import { releaseStaleShortIdRegistry } from '../task/short-id.ts';
50
+ import { fetchSandboxRows, type SandboxRow } from './commands/list-running.ts';
51
+ import {
52
+ formatIntermediateCleanupReport,
53
+ cleanupIntermediateUnderRemovalCoordinator,
54
+ protectIntermediateCleanupReport,
55
+ mergeIntermediateCleanupReports,
56
+ type IntermediateCleanupReport,
57
+ scanIntermediateCleanup
58
+ } from '../task/intermediate-cleanup.ts';
59
+ import { createSandboxControlBindingEvidence } from './task-cleanup.ts';
60
+ import {
61
+ createCleanPermit,
62
+ createDiscardPermit,
63
+ formatWorktreeSnapshot,
64
+ inspectRecoveredWorktree,
65
+ inspectWorktrees,
66
+ verifyWorktreePermit
67
+ } from './worktree-safety.ts';
68
+ import type {
69
+ WorktreeInspection,
70
+ WorktreeRecoveryContext,
71
+ WorktreeRemovalPermit
72
+ } from './worktree-safety.ts';
73
+
74
+ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[] {
75
+ return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
76
+ }
77
+
78
+ function isMissingPathError(error: unknown): boolean {
79
+ return Boolean(error && typeof error === 'object' && 'code' in error
80
+ && (error as { code?: unknown }).code === 'ENOENT');
81
+ }
82
+
83
+ function canonicalPath(input: string): string {
84
+ try { return fs.realpathSync.native(input); }
85
+ catch { return path.resolve(input); }
86
+ }
87
+
88
+ function isMissingTaskRecordError(error: unknown): boolean {
89
+ return error instanceof Error && /^Task not found: TASK-\d{8}-\d{6}$/.test(error.message);
90
+ }
91
+
92
+ function digestCleanupValue(value: unknown): string {
93
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
94
+ }
95
+
96
+ function removalTargetDigest(config: SandboxConfig, target: RmTarget): string {
97
+ return digestCleanupValue({
98
+ project: config.project,
99
+ branch: target.effectiveBranch,
100
+ workspace: target.workspace,
101
+ controlRoots: target.controlRoots.map((candidate) => path.resolve(candidate)),
102
+ workspaceViewRoots: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)),
103
+ managedPathCandidates: (target.managedPathCandidates ?? []).map((candidate) => path.resolve(candidate)).sort()
104
+ });
105
+ }
106
+
107
+ function removalPermitDigest(permits: ReadonlyMap<string, WorktreeRemovalPermit>): string {
108
+ return digestCleanupValue([...permits.entries()]
109
+ .map(([worktree, permit]) => [path.resolve(worktree), permit.mode, permit.snapshot.identity])
110
+ .sort(([left], [right]) => String(left).localeCompare(String(right))));
111
+ }
112
+
113
+ function removalPermitCommits(permits: ReadonlyMap<string, WorktreeRemovalPermit>): SandboxRemovalTargetCommit['permits'] {
114
+ return [...permits.entries()]
115
+ .map(([worktree, permit]) => ({
116
+ path: path.resolve(worktree),
117
+ mode: permit.mode,
118
+ snapshot: permit.snapshot
119
+ }))
120
+ .sort((left, right) => left.path.localeCompare(right.path));
121
+ }
122
+
123
+ function removalTargetCommit(
124
+ config: SandboxConfig,
125
+ target: RmTarget,
126
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>,
127
+ removeWorktree: boolean,
128
+ removeBranch: boolean,
129
+ removeShare: boolean
130
+ ): SandboxRemovalTargetCommit {
131
+ return {
132
+ branch: target.effectiveBranch,
133
+ project: config.project,
134
+ controlRoot: path.resolve(target.controlRoots[0] ?? path.join(config.controlBase, config.project)),
135
+ targetDigest: removalTargetDigest(config, target),
136
+ permitDigest: removalPermitDigest(permits),
137
+ removeWorktree,
138
+ removeBranch,
139
+ removeShare,
140
+ worktreePaths: target.existingWorktrees.map((candidate) => path.resolve(candidate)).sort(),
141
+ workspaceViewPaths: target.workspaceViewRoots.map((candidate) => path.resolve(candidate)).sort(),
142
+ toolPaths: target.toolCandidates.flatMap(({ candidates }) => candidates.map((candidate) => path.resolve(candidate))).sort(),
143
+ shellPaths: shellConfigDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate)).sort(),
144
+ sharePath: path.resolve(shareBranchDir(config, target.effectiveBranch)),
145
+ permits: removalPermitCommits(permits)
146
+ };
147
+ }
148
+
149
+ function assertRemovalSelectionMatches(
150
+ expected: SandboxRemovalTargetCommit,
151
+ actual: SandboxRemovalTargetCommit
152
+ ): void {
153
+ const normalize = (value: SandboxRemovalTargetCommit): unknown => ({
154
+ ...value,
155
+ controlRoot: '',
156
+ permits: value.permits
157
+ });
158
+ if (JSON.stringify(normalize(expected)) !== JSON.stringify(normalize(actual))) {
159
+ throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
160
+ }
161
+ }
162
+
163
+ function advanceRemovalJournals(
164
+ project: string,
165
+ target: RmTarget,
166
+ targetDigest: string,
167
+ phase: SandboxRemovalJournal['phase'],
168
+ resourceLocks: ReadonlyMap<string, SandboxResourceLock>
169
+ ): SandboxRemovalJournal[] {
170
+ return listSandboxRemovalJournals({ branch: target.effectiveBranch, project, targetDigest }).map((journal) => {
171
+ if (sandboxRemovalPhaseIndex(journal.phase) >= sandboxRemovalPhaseIndex(phase)) return journal;
172
+ const lock = resourceLocks.get(journal.target.controlRoot);
173
+ if (!lock) throw new Error('SANDBOX_CONTROL_REMOVAL_LOCK_MISMATCH');
174
+ return advanceSandboxRemovalJournalToPhase(journal, phase, lock);
175
+ });
176
+ }
177
+
178
+ function prepareRemovalAction(
179
+ project: string,
180
+ target: RmTarget,
181
+ targetDigest: string,
182
+ startPhase: SandboxRemovalJournal['phase'],
183
+ completedPhase: SandboxRemovalJournal['phase'],
184
+ resourceLocks: ReadonlyMap<string, SandboxResourceLock>
185
+ ): RemovalActionPreparation {
186
+ const journals = listSandboxRemovalJournals({
187
+ branch: target.effectiveBranch,
188
+ project,
189
+ targetDigest
190
+ });
191
+ const startIndex = sandboxRemovalPhaseIndex(startPhase);
192
+ const completedIndex = sandboxRemovalPhaseIndex(completedPhase);
193
+ const shouldRun = journals.length === 0
194
+ || journals.some((journal) => sandboxRemovalPhaseIndex(journal.phase) < completedIndex);
195
+ const recovering = journals.some((journal) => {
196
+ const index = sandboxRemovalPhaseIndex(journal.phase);
197
+ return index >= startIndex && index < completedIndex;
198
+ });
199
+ advanceRemovalJournals(project, target, targetDigest, startPhase, resourceLocks);
200
+ return { run: shouldRun, recovering };
201
+ }
202
+
203
+ function assertRemovalPathsAbsent(paths: readonly string[]): void {
204
+ const remaining = paths.filter((candidate) => fs.existsSync(candidate));
205
+ if (remaining.length > 0) {
206
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${remaining[0]}`);
207
+ }
208
+ }
209
+
210
+ function assertBranchRemovalIdentity(
211
+ config: SandboxConfig,
212
+ branch: string,
213
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
214
+ ): void {
215
+ if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${branch}`])) return;
216
+ const expectedHeads = new Set(
217
+ [...permits.values()]
218
+ .filter((permit) => permit.snapshot.branch === branch)
219
+ .map((permit) => permit.snapshot.head)
220
+ );
221
+ if (expectedHeads.size === 0) {
222
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
223
+ }
224
+ const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', `refs/heads/${branch}`]);
225
+ if (!expectedHeads.has(actualHead)) {
226
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
227
+ }
228
+ }
229
+
230
+ function removalTombstonePath(targetDigest: string, kind: RemovalActionKind, source: string): string {
231
+ const sourceDigest = createHash('sha256')
232
+ .update(`${kind}\0${path.resolve(source)}`)
233
+ .digest('hex')
234
+ .slice(0, 24);
235
+ return path.join(
236
+ path.dirname(path.resolve(source)),
237
+ `.agent-infra-removal-${targetDigest.slice(0, 16)}-${kind}-${sourceDigest}`
238
+ );
239
+ }
240
+
241
+ type RemovalTombstoneOwnership = Readonly<{
242
+ version: 1;
243
+ targetDigest: string;
244
+ permitDigest: string;
245
+ kind: RemovalActionKind;
246
+ source: string;
247
+ }>;
248
+
249
+ type RemovalSourceIdentity = Readonly<{
250
+ dev: string;
251
+ ino: string;
252
+ }>;
253
+
254
+ type RemovalTombstoneRecord = RemovalTombstoneOwnership & Readonly<{
255
+ sourceIdentity: RemovalSourceIdentity;
256
+ }>;
257
+
258
+ const REMOVAL_TOMBSTONE_MARKER = '.agent-infra-removal-ownership.json';
259
+ const REMOVAL_TOMBSTONE_PAYLOAD = 'payload';
260
+
261
+ function removalSourceClaimPath(source: string, ownership: RemovalTombstoneOwnership): string {
262
+ const claimDigest = createHash('sha256')
263
+ .update(JSON.stringify(ownership))
264
+ .digest('hex')
265
+ .slice(0, 24);
266
+ return path.join(source, `.agent-infra-removal-source-${claimDigest}.json`);
267
+ }
268
+
269
+ function removalSourceIdentity(source: string): RemovalSourceIdentity {
270
+ const stat = fs.lstatSync(source, { bigint: true });
271
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev === 0n || stat.ino === 0n) {
272
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
273
+ }
274
+ return { dev: String(stat.dev), ino: String(stat.ino) };
275
+ }
276
+
277
+ function sameRemovalSourceIdentity(left: RemovalSourceIdentity, right: RemovalSourceIdentity): boolean {
278
+ return left.dev === right.dev && left.ino === right.ino;
279
+ }
280
+
281
+ function removalSourceClaimMatches(
282
+ source: string,
283
+ ownership: RemovalTombstoneOwnership,
284
+ identity: RemovalSourceIdentity
285
+ ): boolean {
286
+ try {
287
+ const claimPath = removalSourceClaimPath(source, ownership);
288
+ const claimStat = fs.lstatSync(claimPath);
289
+ if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
290
+ const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
291
+ return record.version === ownership.version
292
+ && record.targetDigest === ownership.targetDigest
293
+ && record.permitDigest === ownership.permitDigest
294
+ && record.kind === ownership.kind
295
+ && typeof record.source === 'string'
296
+ && path.resolve(record.source) === path.resolve(ownership.source)
297
+ && record.sourceIdentity?.dev === identity.dev
298
+ && record.sourceIdentity?.ino === identity.ino;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ function isOwnedRemovalSourceClaim(source: string, ownership: RemovalTombstoneOwnership): boolean {
305
+ try {
306
+ const claimPath = removalSourceClaimPath(source, ownership);
307
+ const claimStat = fs.lstatSync(claimPath);
308
+ if (!claimStat.isFile() || claimStat.isSymbolicLink()) return false;
309
+ const record = JSON.parse(fs.readFileSync(claimPath, 'utf8')) as Partial<RemovalTombstoneRecord>;
310
+ return record.version === ownership.version
311
+ && record.targetDigest === ownership.targetDigest
312
+ && record.permitDigest === ownership.permitDigest
313
+ && record.kind === ownership.kind
314
+ && typeof record.source === 'string'
315
+ && path.resolve(record.source) === path.resolve(ownership.source);
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+
321
+ function removeOwnedSourceClaim(source: string, ownership: RemovalTombstoneOwnership): void {
322
+ try {
323
+ const claimPath = removalSourceClaimPath(source, ownership);
324
+ if (isOwnedRemovalSourceClaim(source, ownership)) fs.unlinkSync(claimPath);
325
+ } catch {
326
+ // Preserve the source when the claim cannot be revalidated.
327
+ }
328
+ }
329
+
330
+ function readRemovalTombstoneRecord(root: string, tombstone: string): RemovalTombstoneRecord | null {
331
+ assertManagedPath(root, tombstone);
332
+ try {
333
+ const tombstoneStat = fs.lstatSync(tombstone);
334
+ const markerStat = fs.lstatSync(removalTombstoneMarker(tombstone));
335
+ if (!tombstoneStat.isDirectory() || tombstoneStat.isSymbolicLink()
336
+ || !markerStat.isFile() || markerStat.isSymbolicLink()) return null;
337
+ const record = JSON.parse(fs.readFileSync(removalTombstoneMarker(tombstone), 'utf8')) as Partial<RemovalTombstoneRecord>;
338
+ if (record.version !== 1
339
+ || typeof record.targetDigest !== 'string'
340
+ || typeof record.permitDigest !== 'string'
341
+ || typeof record.kind !== 'string'
342
+ || typeof record.source !== 'string'
343
+ || record.sourceIdentity?.dev === undefined
344
+ || record.sourceIdentity.ino === undefined) return null;
345
+ return record as RemovalTombstoneRecord;
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
351
+ function removalTombstonePayload(tombstone: string): string {
352
+ return path.join(tombstone, REMOVAL_TOMBSTONE_PAYLOAD);
353
+ }
354
+
355
+ function removalTombstoneMarker(tombstone: string): string {
356
+ return path.join(tombstone, REMOVAL_TOMBSTONE_MARKER);
357
+ }
358
+
359
+ function isOwnedRemovalTombstone(
360
+ root: string,
361
+ tombstone: string,
362
+ ownership: RemovalTombstoneOwnership
363
+ ): boolean {
364
+ const record = readRemovalTombstoneRecord(root, tombstone);
365
+ return record !== null
366
+ && record.version === 1
367
+ && record.targetDigest === ownership.targetDigest
368
+ && record.permitDigest === ownership.permitDigest
369
+ && record.kind === ownership.kind
370
+ && typeof record.source === 'string'
371
+ && path.resolve(record.source) === path.resolve(ownership.source);
372
+ }
373
+
374
+ function isOwnedRemovalPayload(
375
+ root: string,
376
+ tombstone: string,
377
+ ownership: RemovalTombstoneOwnership
378
+ ): boolean {
379
+ const record = readRemovalTombstoneRecord(root, tombstone);
380
+ if (!record || record.targetDigest !== ownership.targetDigest
381
+ || record.permitDigest !== ownership.permitDigest
382
+ || record.kind !== ownership.kind
383
+ || path.resolve(record.source) !== path.resolve(ownership.source)) return false;
384
+ try {
385
+ const payload = removalTombstonePayload(tombstone);
386
+ const payloadIdentity = removalSourceIdentity(payload);
387
+ return sameRemovalSourceIdentity(payloadIdentity, record.sourceIdentity)
388
+ && removalSourceClaimMatches(payload, ownership, record.sourceIdentity);
389
+ } catch {
390
+ return false;
391
+ }
392
+ }
393
+
394
+ function assertOwnedRemovalTombstone(
395
+ root: string,
396
+ tombstone: string,
397
+ ownership: RemovalTombstoneOwnership
398
+ ): void {
399
+ if (!isOwnedRemovalTombstone(root, tombstone, ownership)) {
400
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${ownership.source}`);
401
+ }
402
+ }
403
+
404
+ function shouldStageManagedRemoval(source: string, tombstone: string, recovering: boolean): boolean {
405
+ return fs.existsSync(source) || (recovering && fs.existsSync(tombstone));
406
+ }
407
+
408
+ function stageManagedRemoval(
409
+ root: string,
410
+ source: string,
411
+ tombstone: string,
412
+ recovering: boolean,
413
+ ownership: RemovalTombstoneOwnership
414
+ ): void {
415
+ assertManagedPath(root, source);
416
+ assertManagedPath(root, tombstone);
417
+ const sourceExists = fs.existsSync(source);
418
+ const tombstoneExists = fs.existsSync(tombstone);
419
+ if (tombstoneExists) {
420
+ assertOwnedRemovalTombstone(root, tombstone, ownership);
421
+ if (sourceExists) {
422
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
423
+ }
424
+ if (recovering) {
425
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
426
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
427
+ }
428
+ return;
429
+ }
430
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
431
+ }
432
+ if (recovering) {
433
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${source}`);
434
+ }
435
+ if (!sourceExists) return;
436
+ // The operator authorizes cleanup of this managed target after execution stops.
437
+ // Path checks detect conflicts; they do not provide an atomic source-instance move.
438
+ const expectedIdentity = removalSourceIdentity(source);
439
+ const claimPath = removalSourceClaimPath(source, ownership);
440
+ try {
441
+ fs.writeFileSync(claimPath, `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
442
+ encoding: 'utf8',
443
+ mode: 0o600,
444
+ flag: 'wx'
445
+ });
446
+ } catch {
447
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
448
+ }
449
+ if (!sameRemovalSourceIdentity(expectedIdentity, removalSourceIdentity(source))) {
450
+ removeOwnedSourceClaim(source, ownership);
451
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
452
+ }
453
+ let moved = false;
454
+ try {
455
+ fs.mkdirSync(tombstone, { mode: 0o700 });
456
+ fs.writeFileSync(removalTombstoneMarker(tombstone), `${JSON.stringify({ ...ownership, sourceIdentity: expectedIdentity })}\n`, {
457
+ encoding: 'utf8',
458
+ mode: 0o600,
459
+ flag: 'wx'
460
+ });
461
+ fs.renameSync(source, removalTombstonePayload(tombstone));
462
+ moved = true;
463
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
464
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
465
+ }
466
+ } catch (error) {
467
+ if (!moved) removeOwnedSourceClaim(source, ownership);
468
+ throw error;
469
+ }
470
+ }
471
+
472
+ function cleanupManagedTombstones(
473
+ root: string,
474
+ targetDigest: string,
475
+ permitDigest: string,
476
+ kind: RemovalActionKind,
477
+ sources: readonly string[]
478
+ ): void {
479
+ for (const source of sources) {
480
+ const tombstone = removalTombstonePath(targetDigest, kind, source);
481
+ if (!fs.existsSync(tombstone)) continue;
482
+ const ownership = { version: 1, targetDigest, permitDigest, kind, source } as const;
483
+ if (!isOwnedRemovalTombstone(root, tombstone, ownership)) continue;
484
+ if (!isOwnedRemovalPayload(root, tombstone, ownership)) {
485
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${source}`);
486
+ }
487
+ removeManagedDir(root, tombstone);
488
+ }
489
+ }
490
+
491
+ function branchRemovalTombstoneRef(targetDigest: string): string {
492
+ return `refs/agent-infra/sandbox-removal/${targetDigest}/branch`;
493
+ }
494
+
495
+ function branchPermitHead(
496
+ branch: string,
497
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
498
+ ): string | null {
499
+ const heads = new Set(
500
+ [...permits.values()]
501
+ .filter((permit) => permit.snapshot.branch === branch)
502
+ .map((permit) => permit.snapshot.head)
503
+ );
504
+ return heads.size === 1 ? [...heads][0]! : null;
505
+ }
506
+
507
+ function stageBranchRemoval(
508
+ config: SandboxConfig,
509
+ branch: string,
510
+ targetDigest: string,
511
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>,
512
+ recovering: boolean
513
+ ): void {
514
+ const expectedHead = branchPermitHead(branch, permits);
515
+ if (!expectedHead) throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
516
+ const branchName = `refs/heads/${branch}`;
517
+ const tombstone = branchRemovalTombstoneRef(targetDigest);
518
+ const branchExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', branchName]);
519
+ const tombstoneExists = runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone]);
520
+ if (branchExists && tombstoneExists) {
521
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
522
+ }
523
+ if (recovering) {
524
+ if (branchExists && !tombstoneExists) {
525
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${branch}`);
526
+ }
527
+ if (tombstoneExists && runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
528
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
529
+ }
530
+ return;
531
+ }
532
+ if (!branchExists) return;
533
+ assertBranchRemovalIdentity(config, branch, permits);
534
+ const actualHead = runSafe('git', ['-C', config.repoRoot, 'rev-parse', branchName]);
535
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', tombstone, actualHead, '0'.repeat(40)])) {
536
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
537
+ }
538
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', branchName, actualHead])) {
539
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
540
+ }
541
+ }
542
+
543
+ function cleanupBranchTombstone(
544
+ config: SandboxConfig,
545
+ branch: string,
546
+ targetDigest: string,
547
+ permits: ReadonlyMap<string, WorktreeRemovalPermit>
548
+ ): void {
549
+ const tombstone = branchRemovalTombstoneRef(targetDigest);
550
+ if (!runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', tombstone])) return;
551
+ const expectedHead = branchPermitHead(branch, permits);
552
+ if (!expectedHead || runSafe('git', ['-C', config.repoRoot, 'rev-parse', tombstone]) !== expectedHead) {
553
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
554
+ }
555
+ if (!runOk('git', ['-C', config.repoRoot, 'update-ref', '-d', tombstone, expectedHead])) {
556
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${branch}`);
557
+ }
558
+ }
559
+
560
+ function worktreeRegistration(
561
+ config: SandboxConfig,
562
+ worktree: string
563
+ ): { head: string; branch: string | null } | null {
564
+ const lines = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain']).split('\n');
565
+ let current: { path: string; head: string; branch: string | null } | null = null;
566
+ const flush = (): { head: string; branch: string | null } | null => {
567
+ if (!current || path.resolve(current.path) !== path.resolve(worktree)) return null;
568
+ return { head: current.head, branch: current.branch };
569
+ };
570
+ for (const line of lines) {
571
+ if (line.startsWith('worktree ')) {
572
+ const previous = flush();
573
+ if (previous) return previous;
574
+ current = { path: line.slice('worktree '.length), head: '', branch: null };
575
+ } else if (current && line.startsWith('HEAD ')) {
576
+ current.head = line.slice('HEAD '.length);
577
+ } else if (current && line.startsWith('branch ')) {
578
+ current.branch = line.slice('branch '.length);
579
+ }
580
+ }
581
+ return flush();
582
+ }
583
+
584
+ function assertWorktreeRegistrationIdentity(
585
+ config: SandboxConfig,
586
+ worktree: string,
587
+ permit: WorktreeRemovalPermit
588
+ ): void {
589
+ const registration = worktreeRegistration(config, worktree);
590
+ if (!registration) return;
591
+ if (registration.head !== permit.snapshot.head
592
+ || registration.branch !== `refs/heads/${permit.snapshot.branch}`) {
593
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
594
+ }
595
+ }
596
+
597
+ function pruneWorktreeRegistration(config: SandboxConfig, worktree: string): void {
598
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
599
+ if (worktreeRegistration(config, worktree)) {
600
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
601
+ }
602
+ }
603
+
604
+ function stageWorktreeRemoval(
605
+ config: SandboxConfig,
606
+ worktree: string,
607
+ permit: WorktreeRemovalPermit,
608
+ tombstone: string,
609
+ recovering: boolean,
610
+ ownership: RemovalTombstoneOwnership
611
+ ): void {
612
+ assertManagedPath(config.worktreeBase, worktree);
613
+ assertManagedPath(config.worktreeBase, tombstone);
614
+ const worktreeExists = fs.existsSync(worktree);
615
+ const tombstoneExists = fs.existsSync(tombstone);
616
+ if (worktreeExists || !recovering) verifyWorktreePermit(permit);
617
+ if (recovering) {
618
+ if (worktreeExists || !tombstoneExists) {
619
+ if (worktreeExists && tombstoneExists) {
620
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH: ${worktree}`);
621
+ }
622
+ throw new Error(`SANDBOX_CONTROL_REMOVAL_ACTION_OUTCOME_UNKNOWN: ${worktree}`);
623
+ }
624
+ assertOwnedRemovalTombstone(config.worktreeBase, tombstone, ownership);
625
+ assertWorktreeRegistrationIdentity(config, worktree, permit);
626
+ pruneWorktreeRegistration(config, worktree);
627
+ return;
628
+ }
629
+ if (!worktreeExists) return;
630
+ stageManagedRemoval(config.worktreeBase, worktree, tombstone, false, ownership);
631
+ pruneWorktreeRegistration(config, worktree);
632
+ }
633
+
634
+ function cleanupCompletedRemovalArtifacts(
635
+ config: SandboxConfig,
636
+ target: RmTarget,
637
+ committedTarget: SandboxRemovalTargetCommit,
638
+ targetDigest: string
639
+ ): void {
640
+ const journals = listSandboxRemovalJournals({
641
+ branch: target.effectiveBranch,
642
+ project: config.project,
643
+ targetDigest
644
+ });
645
+ if (journals.length === 0) return;
646
+ const completed = (phase: SandboxRemovalJournal['phase']): boolean => journals.every((journal) => (
647
+ sandboxRemovalPhaseIndex(journal.phase) >= sandboxRemovalPhaseIndex(phase)
648
+ ));
649
+ if (completed('workspace-removed')) {
650
+ cleanupManagedTombstones(
651
+ config.workspaceViewBase, targetDigest, committedTarget.permitDigest, 'workspace', target.workspaceViewRoots
652
+ );
653
+ cleanupManagedTombstones(
654
+ path.join(config.controlBase, config.project), targetDigest, committedTarget.permitDigest, 'workspace', target.controlRoots
655
+ );
656
+ cleanupManagedTombstones(
657
+ config.worktreeBase, targetDigest, committedTarget.permitDigest, 'worktree', committedTarget.worktreePaths
658
+ );
659
+ }
660
+ if (completed('branch-removed')) {
661
+ cleanupBranchTombstone(config, target.effectiveBranch, targetDigest, new Map(
662
+ committedTarget.permits.map((permit) => [path.resolve(permit.path), {
663
+ mode: permit.mode,
664
+ snapshot: permit.snapshot
665
+ }])
666
+ ));
667
+ }
668
+ if (completed('tool-removed')) {
669
+ cleanupManagedTombstones(config.home, targetDigest, committedTarget.permitDigest, 'tool', committedTarget.toolPaths);
670
+ }
671
+ if (completed('shell-removed')) {
672
+ cleanupManagedTombstones(config.shellConfigBase, targetDigest, committedTarget.permitDigest, 'shell', committedTarget.shellPaths);
673
+ }
674
+ if (completed('share-removed')) {
675
+ cleanupManagedTombstones(config.shareBase, targetDigest, committedTarget.permitDigest, 'share', [committedTarget.sharePath]);
676
+ }
677
+ }
678
+
679
+ function removalActionPaths(target: RmTarget, config: SandboxConfig, phase: SandboxRemovalJournal['phase']): string[] {
680
+ if (phase === 'workspace-removed') {
681
+ return [...target.workspaceViewRoots, ...target.controlRoots];
682
+ }
683
+ if (phase === 'tool-removed') {
684
+ return target.toolCandidates.flatMap(({ candidates }) => candidates);
685
+ }
686
+ if (phase === 'shell-removed') {
687
+ return shellConfigDirCandidates(config, target.effectiveBranch);
688
+ }
689
+ if (phase === 'share-removed') {
690
+ return [shareBranchDir(config, target.effectiveBranch)];
691
+ }
692
+ return [];
693
+ }
694
+
695
+ function permitsFromJournal(
696
+ journals: readonly SandboxRemovalJournal[],
697
+ target: RmTarget,
698
+ expectedTargetDigest: string
699
+ ): Map<string, WorktreeRemovalPermit> {
700
+ const permits = new Map<string, WorktreeRemovalPermit>();
701
+ for (const journal of journals) {
702
+ if (journal.target.targetDigest !== expectedTargetDigest) continue;
703
+ if (journal.target.branch !== target.effectiveBranch) {
704
+ throw new Error('SANDBOX_CONTROL_REMOVAL_TARGET_MISMATCH');
705
+ }
706
+ for (const entry of journal.target.permits) {
707
+ if (path.resolve(entry.path) !== path.resolve(entry.snapshot.worktree)) {
708
+ throw new Error('SANDBOX_CONTROL_REMOVAL_PERMIT_MISMATCH');
709
+ }
710
+ permits.set(path.resolve(entry.path), {
711
+ mode: entry.mode,
712
+ snapshot: entry.snapshot
713
+ });
714
+ }
715
+ }
716
+ return permits;
717
+ }
718
+
719
+ export function sandboxManagedPathKey(
720
+ candidate: string,
721
+ platform: NodeJS.Platform = process.platform,
722
+ resolveExistingPath: (resolved: string) => string = (resolved) => fs.realpathSync.native(resolved)
723
+ ): string {
724
+ const resolved = path.resolve(candidate);
725
+ let identity = resolved;
726
+ try {
727
+ identity = resolveExistingPath(resolved);
728
+ } catch (error) {
729
+ if (!isMissingPathError(error)) {
730
+ throw new Error(
731
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: unable to determine managed path identity for '${resolved}'`
732
+ );
733
+ }
734
+ }
735
+ return platform === 'win32' ? identity.toLowerCase() : identity;
736
+ }
737
+
738
+ type RmTarget = {
739
+ branch: string;
740
+ effectiveBranch: string;
741
+ engine: string;
742
+ matchedContainers: string[];
743
+ existingWorktrees: string[];
744
+ toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
745
+ managedPathCandidates?: string[];
746
+ workspace: SandboxWorkspaceKey;
747
+ controlRoots: string[];
748
+ workspaceViewRoots: string[];
749
+ };
750
+
751
+ type RemovalResourceKind = 'control-root' | 'workspace-view' | 'worktree' | 'branch' | 'tool' | 'shell' | 'share' | 'artifact' | 'intent';
752
+
753
+ export type SandboxRemovalResource = Readonly<{
754
+ kind: RemovalResourceKind;
755
+ path: string;
756
+ }>;
757
+
758
+ export type SandboxRemovalResourceDisclosure = Readonly<{
759
+ remove: readonly SandboxRemovalResource[];
760
+ preserve: readonly SandboxRemovalResource[];
761
+ }>;
762
+
763
+ type RemovalDisclosureTarget = Readonly<Pick<
764
+ RmTarget,
765
+ 'branch' | 'effectiveBranch' | 'existingWorktrees' | 'toolCandidates' | 'controlRoots' | 'workspaceViewRoots'
766
+ >>;
767
+
768
+ type RemovalSelection = Pick<SandboxRemovalTargetCommit, 'removeWorktree' | 'removeBranch' | 'removeShare'>;
769
+
770
+ type RemovalActionKind = 'workspace' | 'worktree' | 'tool' | 'shell' | 'share';
771
+
772
+ type RemovalActionPreparation = Readonly<{
773
+ run: boolean;
774
+ recovering: boolean;
775
+ }>;
776
+
777
+ type CleanupCandidate = Readonly<{
778
+ row: SandboxRow;
779
+ cleanupTarget: SandboxCleanupTarget;
780
+ }>;
781
+
782
+ type ProtectedCleanupCandidate = Readonly<{
783
+ row: SandboxRow;
784
+ branch: string;
785
+ identity: string;
786
+ reason: string;
787
+ }>;
788
+
789
+ type CleanupGroup = Readonly<{
790
+ candidates: readonly CleanupCandidate[];
791
+ cleanupTarget: SandboxCleanupTarget;
792
+ target: RmTarget;
793
+ }>;
794
+
795
+ type RmOneOptions = {
796
+ assumeYes?: boolean;
797
+ interactive?: boolean;
798
+ quiet?: boolean;
799
+ target?: RmTarget;
800
+ cleanupTarget?: SandboxCleanupTarget;
801
+ permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
802
+ allowDirtyDiscard?: boolean;
803
+ prompt?: PromptDependencies;
804
+ };
805
+
806
+ type PromptDependencies = {
807
+ confirm?: typeof p.confirm;
808
+ isCancel?: typeof p.isCancel;
809
+ };
810
+
811
+ function resolveRmTarget(
812
+ config: SandboxConfig,
813
+ tools: SandboxTool[],
814
+ cleanupTarget: SandboxCleanupTarget,
815
+ options: Readonly<{ discoveredContainers?: readonly string[] }> = {}
816
+ ): RmTarget {
817
+ assertValidBranchName(cleanupTarget.branch);
818
+ const engine = detectEngine(config);
819
+ const branch = cleanupTarget.branch;
820
+ const effectiveBranch = branch;
821
+ const worktreeCandidates = worktreeDirCandidates(config, branch);
822
+ const toolCandidates = tools.map((tool) => ({
823
+ tool,
824
+ candidates: toolConfigDirCandidates(tool, config.project, branch)
825
+ }));
826
+ const shellCandidates = shellConfigDirCandidates(config, branch);
827
+ const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
828
+ const matchedContainers = options.discoveredContainers
829
+ ? [...options.discoveredContainers]
830
+ : containerNameCandidates(config, branch).filter((name) => existing.includes(name));
831
+
832
+ const workspace = cleanupTarget.workspace;
833
+ const identities: SandboxWorkspaceKey[] = [workspace];
834
+ const containers = options.discoveredContainers
835
+ ? matchedContainers
836
+ : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
837
+ const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
838
+ base: config.controlBase, project: config.project, container, identity
839
+ }).root));
840
+ const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
841
+ base: config.workspaceViewBase, project: config.project, container, identity
842
+ }).root));
843
+ const managedPathCandidates = [...new Set([
844
+ ...worktreeCandidates,
845
+ ...toolCandidates.flatMap(({ candidates }) => candidates),
846
+ ...shellCandidates,
847
+ shareBranchDir(config, branch),
848
+ ...controlRoots,
849
+ ...workspaceViewRoots
850
+ ].map((candidate) => path.resolve(candidate)))];
851
+
852
+ return {
853
+ branch,
854
+ effectiveBranch,
855
+ engine,
856
+ matchedContainers,
857
+ existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
858
+ toolCandidates,
859
+ managedPathCandidates,
860
+ workspace,
861
+ controlRoots: [...new Set(controlRoots)],
862
+ workspaceViewRoots: [...new Set(workspaceViewRoots)]
863
+ };
864
+ }
865
+
866
+ function assertRemoved(target: string, label: string): void {
867
+ if (fs.existsSync(target)) throw new Error(`${label} still exists after removal: ${target}`);
868
+ }
869
+
870
+ function sandboxContainersRemoved(engine: string, containers: readonly string[]): boolean {
871
+ if (containers.length === 0) return true;
872
+ const remaining = new Set(runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}'])
873
+ .split('\n')
874
+ .filter(Boolean));
875
+ return containers.every((container) => !remaining.has(container));
876
+ }
877
+
878
+ function removeEmptyManagedParent(base: string, directory: string): void {
879
+ const parent = path.dirname(directory);
880
+ if (path.resolve(parent) === path.resolve(base)) return;
881
+ assertManagedPath(base, parent);
882
+ try {
883
+ fs.rmdirSync(parent);
884
+ } catch (error) {
885
+ if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
886
+ throw error;
887
+ }
888
+ assertRemoved(parent, 'Empty sandbox container directory');
889
+ }
890
+
891
+ function blockerMessage(blockers: readonly WorktreeInspection[]): string {
892
+ return blockers.map((blocker) => {
893
+ if (blocker.status === 'failed') return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
894
+ return `${formatWorktreeSnapshot(blocker.snapshot)}\n Action: commit, stash, clean, or remove this sandbox interactively.`;
895
+ }).join('\n');
896
+ }
897
+
898
+ function cleanPermits(inspections: readonly WorktreeInspection[]): Map<string, WorktreeRemovalPermit> {
899
+ const permits = new Map<string, WorktreeRemovalPermit>();
900
+ for (const inspection of inspections) {
901
+ if (inspection.status === 'clean') permits.set(inspection.snapshot.worktree, createCleanPermit(inspection.snapshot));
902
+ }
903
+ return permits;
904
+ }
905
+
906
+ async function authorizeWorktrees(
907
+ worktrees: readonly string[],
908
+ { allowDirtyDiscard, assumeYes }: { allowDirtyDiscard: boolean; assumeYes: boolean },
909
+ {
910
+ interactive = Boolean(process.stdin.isTTY),
911
+ confirm = p.confirm,
912
+ isCancel = p.isCancel,
913
+ recovery = new Map<string, WorktreeRecoveryContext>()
914
+ }: PromptDependencies & { interactive?: boolean; recovery?: ReadonlyMap<string, WorktreeRecoveryContext> } = {}
915
+ ): Promise<Map<string, WorktreeRemovalPermit>> {
916
+ const inspections = worktrees.map((worktree) => {
917
+ const inspection = inspectWorktrees([worktree])[0]!;
918
+ const context = recovery.get(path.resolve(worktree));
919
+ return inspection.status === 'failed' && context
920
+ ? inspectRecoveredWorktree(worktree, context)
921
+ : inspection;
922
+ });
923
+ const failures = inspections.filter((inspection) => inspection.status === 'failed');
924
+ if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
925
+ const permits = cleanPermits(inspections);
926
+ for (const inspection of inspections) {
927
+ if (inspection.status !== 'dirty') continue;
928
+ if (!allowDirtyDiscard || assumeYes || !interactive) {
929
+ throw new Error(`Refusing to remove dirty worktree(s):\n${blockerMessage([inspection])}`);
930
+ }
931
+ p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
932
+ const confirmed = await confirm({
933
+ message: 'Discard this worktree and all its uncommitted changes, including later changes?',
934
+ initialValue: false
935
+ });
936
+ if (isCancel(confirmed) || !confirmed) throw new Error('Dirty worktree removal cancelled; nothing was deleted');
937
+ permits.set(inspection.snapshot.worktree, createDiscardPermit(inspection.snapshot));
938
+ }
939
+ return permits;
940
+ }
941
+
942
+ async function runRmOneUnderRepositoryLock(
943
+ config: SandboxConfig,
944
+ tools: SandboxTool[],
945
+ branch: string,
946
+ options: RmOneOptions = {}
947
+ ): Promise<IntermediateCleanupReport | null> {
948
+ const target = options.target ?? resolveRmTarget(
949
+ config,
950
+ tools,
951
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
952
+ );
953
+ return rmOneCore(config, tools, branch, { ...options, target });
954
+ }
955
+
956
+ async function rmOne(
957
+ config: SandboxConfig,
958
+ tools: SandboxTool[],
959
+ branch: string,
960
+ options: RmOneOptions = {}
961
+ ): Promise<void> {
962
+ await runRmOneUnderRepositoryLock(config, tools, branch, options);
963
+ }
964
+
965
+ async function removeUncheckedSandbox(
966
+ config: SandboxConfig,
967
+ target: RmTarget,
968
+ options: RmOneOptions
969
+ ): Promise<null> {
970
+ const confirm = options.prompt?.confirm ?? p.confirm;
971
+ const isCancel = options.prompt?.isCancel ?? p.isCancel;
972
+ const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates, controlRoots, workspaceViewRoots } = target;
973
+
974
+ if (!options.quiet) p.intro(pc.cyan(`Removing sandbox for ${target.branch}`));
975
+
976
+ const shouldRemoveWorktree = existingWorktrees.length > 0 && !options.assumeYes
977
+ ? await confirm({ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`, initialValue: true })
978
+ : existingWorktrees.length > 0;
979
+ if (isCancel(shouldRemoveWorktree)) {
980
+ if (!options.quiet) p.outro('Cancelled');
981
+ return null;
982
+ }
983
+
984
+ const shouldDeleteBranch = Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0 && !options.assumeYes
985
+ ? await confirm({ message: `Also delete local branch '${effectiveBranch}'?`, initialValue: true })
986
+ : Boolean(shouldRemoveWorktree) && existingWorktrees.length > 0;
987
+ if (isCancel(shouldDeleteBranch)) {
988
+ if (!options.quiet) p.outro('Cancelled');
989
+ return null;
990
+ }
991
+
992
+ const sharePath = path.resolve(shareBranchDir(config, effectiveBranch));
993
+ const shouldRemoveShare = fs.existsSync(sharePath) && !options.assumeYes
994
+ ? await confirm({ message: `Remove share dir for branch '${effectiveBranch}' (${sharePath})?`, initialValue: true })
995
+ : fs.existsSync(sharePath);
996
+ if (isCancel(shouldRemoveShare)) {
997
+ if (!options.quiet) p.outro('Cancelled');
998
+ return null;
999
+ }
1000
+
1001
+ for (const container of matchedContainers) runSafeEngine(engine, 'docker', ['rm', '-f', container]);
1002
+ for (const root of controlRoots) {
1003
+ fs.rmSync(root, { recursive: true, force: true });
1004
+ removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
1005
+ }
1006
+ for (const root of workspaceViewRoots) {
1007
+ fs.rmSync(root, { recursive: true, force: true });
1008
+ removeEmptyManagedParent(path.join(config.workspaceViewBase, config.project), root);
1009
+ }
1010
+ for (const candidate of toolCandidates.flatMap(({ candidates }) => candidates)) fs.rmSync(candidate, { recursive: true, force: true });
1011
+ for (const shell of shellConfigDirCandidates(config, effectiveBranch)) fs.rmSync(shell, { recursive: true, force: true });
1012
+ if (shouldRemoveShare) fs.rmSync(sharePath, { recursive: true, force: true });
1013
+ if (shouldRemoveWorktree) {
1014
+ for (const worktree of existingWorktrees) fs.rmSync(worktree, { recursive: true, force: true });
1015
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1016
+ }
1017
+ if (shouldDeleteBranch) runSafe('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch]);
1018
+ if (!sandboxContainersRemoved(engine, matchedContainers)) {
1019
+ throw new Error(`SANDBOX_REMOVAL_CONTAINER_STILL_PRESENT: ${matchedContainers.join(', ')}`);
1020
+ }
1021
+ if (target.workspace.mode === 'task-bound') {
1022
+ releaseStaleShortIdRegistry(config.repoRoot, target.workspace.taskId);
1023
+ }
1024
+
1025
+ if (!options.quiet) p.outro('Sandbox removed');
1026
+ return null;
1027
+ }
1028
+
1029
+ async function rmOneCore(
1030
+ config: SandboxConfig,
1031
+ tools: SandboxTool[],
1032
+ branch: string,
1033
+ options: RmOneOptions = {}
1034
+ ): Promise<IntermediateCleanupReport | null> {
1035
+ const target = options.target ?? resolveRmTarget(
1036
+ config,
1037
+ tools,
1038
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
1039
+ );
1040
+ return removeUncheckedSandbox(config, target, options);
1041
+ }
1042
+
1043
+ async function rmPurge(
1044
+ config: SandboxConfig,
1045
+ tools: SandboxTool[],
1046
+ prompt: PromptDependencies = {}
1047
+ ): Promise<void> {
1048
+ return rmPurgeCore(config, tools, prompt);
1049
+ }
1050
+
1051
+ async function rmPurgeCore(
1052
+ config: SandboxConfig,
1053
+ tools: SandboxTool[],
1054
+ prompt: PromptDependencies = {}
1055
+ ): Promise<void> {
1056
+ const engine = detectEngine(config);
1057
+ const confirm = prompt.confirm ?? p.confirm;
1058
+ const isCancel = prompt.isCancel ?? p.isCancel;
1059
+ p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
1060
+
1061
+ const containers = runEngine(engine, 'docker', [
1062
+ 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
1063
+ ]).split('\n').filter(Boolean);
1064
+ for (const name of containers) runSafeEngine(engine, 'docker', ['rm', '-f', name]);
1065
+
1066
+ const worktrees = fs.existsSync(config.worktreeBase)
1067
+ ? fs.readdirSync(config.worktreeBase).map((entry) => path.join(config.worktreeBase, entry))
1068
+ : [];
1069
+ if (worktrees.length > 0) {
1070
+ const selected = await confirm({ message: `Remove all worktrees in ${config.worktreeBase}?`, initialValue: true });
1071
+ if (!isCancel(selected) && selected) {
1072
+ for (const worktree of worktrees) fs.rmSync(worktree, { recursive: true, force: true });
1073
+ runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
1074
+ }
1075
+ }
1076
+
1077
+ for (const dir of projectToolDirs(config, tools)) fs.rmSync(dir, { recursive: true, force: true });
1078
+ if (fs.existsSync(config.shellConfigBase)) {
1079
+ const selected = await confirm({ message: `Remove all shell config dirs in ${config.shellConfigBase}?`, initialValue: true });
1080
+ if (!isCancel(selected) && selected) fs.rmSync(config.shellConfigBase, { recursive: true, force: true });
1081
+ }
1082
+ if (fs.existsSync(config.shareBase)) {
1083
+ const selected = await confirm({ message: `Remove all share dirs for project (${config.shareBase})?`, initialValue: true });
1084
+ if (!isCancel(selected) && selected) fs.rmSync(config.shareBase, { recursive: true, force: true });
1085
+ }
1086
+ for (const base of [config.workspaceViewBase, config.controlBase]) {
1087
+ fs.rmSync(path.join(base, config.project), { recursive: true, force: true });
1088
+ }
1089
+ const removeImage = await confirm({ message: `Remove image ${config.imageName}?`, initialValue: false });
1090
+ if (!isCancel(removeImage) && removeImage) runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
1091
+ p.outro(pc.green('All project sandboxes removed'));
1092
+ }
1093
+
1094
+ async function rmUnbound(
1095
+ config: SandboxConfig,
1096
+ tools: SandboxTool[],
1097
+ options: { dryRun: boolean; assumeYes: boolean }
1098
+ ): Promise<void> {
1099
+ return rmUnboundCore(config, tools, options);
1100
+ }
1101
+
1102
+ async function rmUnboundCore(
1103
+ config: SandboxConfig,
1104
+ tools: SandboxTool[],
1105
+ options: { dryRun: boolean; assumeYes: boolean }
1106
+ ): Promise<void> {
1107
+ const engine = detectEngine(config);
1108
+ const listed = fetchSandboxRows(
1109
+ engine,
1110
+ sandboxLabel(config),
1111
+ sandboxBranchLabel(config),
1112
+ { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
1113
+ );
1114
+ const rows = [...listed.running, ...listed.nonRunning];
1115
+ p.intro(pc.cyan(`Removing sandboxes for ${config.project}`));
1116
+ if (options.dryRun) {
1117
+ p.outro(`Dry run: ${rows.length} sandbox(es) found, nothing deleted`);
1118
+ return;
1119
+ }
1120
+ for (const row of rows) {
1121
+ if (!row.branch) {
1122
+ runSafeEngine(engine, 'docker', ['rm', '-f', row.name]);
1123
+ continue;
1124
+ }
1125
+ const cleanupTarget: SandboxCleanupTarget = {
1126
+ requestedRef: row.branch,
1127
+ branch: row.branch,
1128
+ workspace: row.workspaceMode === 'task-bound' && row.taskId
1129
+ ? { mode: 'task-bound', taskId: row.taskId }
1130
+ : { mode: 'branch-only' },
1131
+ taskState: 'branch-only'
1132
+ };
1133
+ const target = resolveRmTarget(config, tools, cleanupTarget, { discoveredContainers: [row.name] });
1134
+ await rmOne(config, tools, cleanupTarget.branch, {
1135
+ assumeYes: options.assumeYes,
1136
+ target,
1137
+ cleanupTarget
1138
+ });
1139
+ }
1140
+ p.outro(pc.green(`Removed ${rows.length} sandbox(es)`));
1141
+ }
1142
+ export { authorizeWorktrees, rmOne, rmPurge, rmUnbound };