@fitlab-ai/agent-infra 0.9.5 → 0.9.7

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 (172) hide show
  1. package/dist/bin/internal-cli.js +8 -3
  2. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +171 -20
  3. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +162 -0
  4. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +244 -0
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -2
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +370 -0
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +68 -8
  8. package/dist/lib/agent-clients/adapters/codex.js +1 -1
  9. package/dist/lib/defaults.json +10 -2
  10. package/dist/lib/git/workflow.js +30 -4
  11. package/dist/lib/internal/codex-lifecycle.js +169 -3
  12. package/dist/lib/internal/codex-sandbox-controller.js +106 -0
  13. package/dist/lib/internal/release-workflow.js +108 -11
  14. package/dist/lib/internal/sandbox-control.js +43 -6
  15. package/dist/lib/internal/task-create.js +12 -4
  16. package/dist/lib/internal/task-orchestration.js +40 -7
  17. package/dist/lib/platform/context.js +6 -4
  18. package/dist/lib/platform/github-client.js +55 -2
  19. package/dist/lib/platform/issue-comments.js +2 -1
  20. package/dist/lib/process-data/checkpoint.js +319 -0
  21. package/dist/lib/process-data/index.js +128 -60
  22. package/dist/lib/process-data/materialize.js +84 -0
  23. package/dist/lib/process-data/normalize.js +34 -1
  24. package/dist/lib/process-data/reconcile.js +55 -1
  25. package/dist/lib/process-data/sources.js +422 -1
  26. package/dist/lib/process-data/store.js +341 -11
  27. package/dist/lib/release/workflow.js +29 -21
  28. package/dist/lib/sandbox/commands/create.js +111 -60
  29. package/dist/lib/sandbox/commands/prune.js +40 -16
  30. package/dist/lib/sandbox/commands/rm.js +372 -94
  31. package/dist/lib/sandbox/commands/start.js +4 -3
  32. package/dist/lib/sandbox/control/client.js +138 -22
  33. package/dist/lib/sandbox/control/container-identity.js +64 -0
  34. package/dist/lib/sandbox/control/executor.js +142 -0
  35. package/dist/lib/sandbox/control/lifecycle.js +557 -0
  36. package/dist/lib/sandbox/control/protocol.js +69 -26
  37. package/dist/lib/sandbox/control/server.js +395 -99
  38. package/dist/lib/sandbox/control/state.js +191 -0
  39. package/dist/lib/sandbox/control/timing.js +8 -0
  40. package/dist/lib/sandbox/index.js +2 -2
  41. package/dist/lib/sandbox/managed-fs.js +14 -2
  42. package/dist/lib/sandbox/mounts.js +22 -19
  43. package/dist/lib/sandbox/recovery.js +284 -50
  44. package/dist/lib/sandbox/workspace-view.js +90 -10
  45. package/dist/lib/sandbox/worktree-safety.js +196 -0
  46. package/dist/lib/server/daemon.js +1 -1
  47. package/dist/lib/server/process-control.js +17 -1
  48. package/dist/lib/server/process-state.js +57 -20
  49. package/dist/lib/task/artifact-lifecycle.js +87 -15
  50. package/dist/lib/task/artifact-receipts.js +128 -0
  51. package/dist/lib/task/codex-orchestration.js +236 -0
  52. package/dist/lib/task/commands/status.js +3 -0
  53. package/dist/lib/task/commands/validate.js +289 -0
  54. package/dist/lib/task/commit-finalization.js +36 -9
  55. package/dist/lib/task/create.js +10 -2
  56. package/dist/lib/task/delegation-receipts.js +109 -2
  57. package/dist/lib/task/events.js +149 -6
  58. package/dist/lib/task/finalization-artifacts.js +2 -1
  59. package/dist/lib/task/implementation-inputs.js +1 -7
  60. package/dist/lib/task/index.js +9 -0
  61. package/dist/lib/task/ledger.js +13 -1
  62. package/dist/lib/task/orchestration.js +365 -15
  63. package/dist/lib/task/sections.js +5 -1
  64. package/dist/lib/task/task-execution-lock.js +5 -4
  65. package/dist/lib/task/verification-engine.js +78 -14
  66. package/dist/lib/task/verification.js +1 -0
  67. package/dist/lifecycle-build-manifest.json +6 -0
  68. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +202 -18
  69. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +227 -0
  70. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +321 -0
  71. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -3
  72. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +26 -0
  73. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +453 -0
  74. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +83 -6
  75. package/lib/agent-clients/adapters/codex.ts +1 -1
  76. package/lib/defaults.json +10 -2
  77. package/lib/git/workflow.ts +25 -3
  78. package/lib/internal/codex-lifecycle.ts +175 -4
  79. package/lib/internal/codex-sandbox-controller.ts +109 -0
  80. package/lib/internal/release-workflow.ts +107 -11
  81. package/lib/internal/sandbox-control.ts +38 -6
  82. package/lib/internal/task-create.ts +16 -4
  83. package/lib/internal/task-orchestration.ts +34 -7
  84. package/lib/platform/context.ts +8 -5
  85. package/lib/platform/github-client.ts +61 -3
  86. package/lib/platform/issue-comments.ts +2 -1
  87. package/lib/process-data/checkpoint.ts +335 -0
  88. package/lib/process-data/index.ts +112 -60
  89. package/lib/process-data/materialize.ts +83 -0
  90. package/lib/process-data/normalize.ts +29 -1
  91. package/lib/process-data/reconcile.ts +64 -2
  92. package/lib/process-data/sources.ts +449 -2
  93. package/lib/process-data/store.ts +304 -11
  94. package/lib/process-data/types.ts +124 -2
  95. package/lib/release/workflow.ts +69 -20
  96. package/lib/sandbox/commands/create.ts +70 -25
  97. package/lib/sandbox/commands/prune.ts +53 -15
  98. package/lib/sandbox/commands/rm.ts +423 -108
  99. package/lib/sandbox/commands/start.ts +4 -3
  100. package/lib/sandbox/control/client.ts +152 -28
  101. package/lib/sandbox/control/container-identity.ts +93 -0
  102. package/lib/sandbox/control/executor.ts +167 -0
  103. package/lib/sandbox/control/lifecycle.ts +573 -0
  104. package/lib/sandbox/control/protocol.ts +121 -68
  105. package/lib/sandbox/control/server.ts +398 -109
  106. package/lib/sandbox/control/state.ts +213 -0
  107. package/lib/sandbox/control/timing.ts +12 -0
  108. package/lib/sandbox/index.ts +2 -2
  109. package/lib/sandbox/managed-fs.ts +17 -2
  110. package/lib/sandbox/mounts.ts +16 -8
  111. package/lib/sandbox/recovery.ts +335 -65
  112. package/lib/sandbox/workspace-view.ts +102 -11
  113. package/lib/sandbox/worktree-safety.ts +234 -0
  114. package/lib/server/daemon.ts +1 -1
  115. package/lib/server/process-control.ts +24 -3
  116. package/lib/server/process-state.ts +82 -27
  117. package/lib/task/artifact-lifecycle.ts +78 -14
  118. package/lib/task/artifact-receipts.ts +161 -0
  119. package/lib/task/codex-orchestration.ts +319 -0
  120. package/lib/task/commands/status.ts +2 -0
  121. package/lib/task/commands/validate.ts +282 -0
  122. package/lib/task/commit-finalization.ts +52 -9
  123. package/lib/task/create.ts +11 -2
  124. package/lib/task/delegation-receipts.ts +208 -7
  125. package/lib/task/events.ts +131 -5
  126. package/lib/task/finalization-artifacts.ts +2 -1
  127. package/lib/task/implementation-inputs.ts +1 -5
  128. package/lib/task/index.ts +8 -0
  129. package/lib/task/ledger.ts +16 -1
  130. package/lib/task/orchestration.ts +552 -18
  131. package/lib/task/sections.ts +6 -2
  132. package/lib/task/task-execution-lock.ts +11 -9
  133. package/lib/task/verification-engine.ts +93 -24
  134. package/lib/task/verification.ts +2 -1
  135. package/package.json +1 -1
  136. package/templates/.agents/README.en.md +7 -0
  137. package/templates/.agents/README.zh-CN.md +5 -0
  138. package/templates/.agents/hooks/lifecycle-delegation.js +72 -12
  139. package/templates/.agents/rules/lifecycle-orchestration.en.md +8 -2
  140. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +8 -2
  141. package/templates/.agents/rules/no-mid-flow-questions.en.md +1 -0
  142. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +1 -0
  143. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  144. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +1 -1
  145. package/templates/.agents/rules/review-handshake.en.md +3 -1
  146. package/templates/.agents/rules/review-handshake.zh-CN.md +3 -1
  147. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +3 -1
  148. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +3 -1
  149. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -0
  150. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -0
  151. package/templates/.agents/skills/post-release/SKILL.en.md +32 -6
  152. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +32 -6
  153. package/templates/.agents/skills/run-manual-validation/SKILL.en.md +42 -0
  154. package/templates/.agents/skills/run-manual-validation/SKILL.zh-CN.md +42 -0
  155. package/templates/.agents/skills/run-manual-validation/config/verify.en.json +25 -0
  156. package/templates/.agents/skills/run-manual-validation/config/verify.zh-CN.json +25 -0
  157. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.en.md +67 -0
  158. package/templates/.agents/skills/run-manual-validation/reference/discovery-and-execution.zh-CN.md +67 -0
  159. package/templates/.agents/skills/run-manual-validation/reference/report-template.en.md +69 -0
  160. package/templates/.agents/skills/run-manual-validation/reference/report-template.zh-CN.md +69 -0
  161. package/templates/.agents/skills/run-task/SKILL.en.md +9 -7
  162. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +9 -7
  163. package/templates/.agents/skills/run-task/reference/host-validation.en.md +5 -3
  164. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +5 -3
  165. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +75 -3
  166. package/templates/.claude/commands/run-manual-validation.en.md +9 -0
  167. package/templates/.claude/commands/run-manual-validation.zh-CN.md +9 -0
  168. package/templates/.codex/hooks.json +8 -8
  169. package/templates/.opencode/commands/run-manual-validation.en.md +11 -0
  170. package/templates/.opencode/commands/run-manual-validation.zh-CN.md +11 -0
  171. package/templates/.agents/workspace/README.en.md +0 -26
  172. package/templates/.agents/workspace/README.zh-CN.md +0 -26
@@ -16,18 +16,29 @@ import {
16
16
  } from '../constants.ts';
17
17
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
18
18
  import { pruneSandboxDanglingImages } from '../image-prune.ts';
19
- import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
20
- import { runOk, runSafe, runSafeEngine } from '../shell.ts';
21
- import { resolveSandboxTarget } from '../workspace-identity.ts';
19
+ import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
20
+ import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
21
+ import { resolveSandboxTarget, type SandboxWorkspaceIdentity } from '../workspace-identity.ts';
22
+ import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
23
+ import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
24
+ import { inspectSandboxControlContainer } from '../control/container-identity.ts';
22
25
  import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
23
26
  import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
24
27
  import type { SandboxTool } from '../tools.ts';
25
28
  import { fetchSandboxRows } from './list-running.ts';
26
29
  import { lookupShortIdByBranch } from '../../task/short-id.ts';
30
+ import {
31
+ createCleanPermit,
32
+ createDiscardPermit,
33
+ formatWorktreeSnapshot,
34
+ inspectWorktrees,
35
+ verifyWorktreePermit
36
+ } from '../worktree-safety.ts';
37
+ import type { WorktreeInspection, WorktreeRemovalPermit } from '../worktree-safety.ts';
27
38
 
28
39
  const USAGE = `Usage:
29
40
  ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
30
- ai sandbox rm --all [--dry-run] [--yes] Remove every sandbox not bound to an active task
41
+ ai sandbox rm --unbound [--dry-run] [--yes] Remove every sandbox not bound to an active task
31
42
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
32
43
  export { assertManagedPath } from '../managed-fs.ts';
33
44
 
@@ -35,14 +46,33 @@ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[]
35
46
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
36
47
  }
37
48
 
38
- type RmOneOptions = { assumeYes?: boolean; quiet?: boolean };
39
-
40
- async function rmOne(
41
- config: SandboxConfig,
42
- tools: SandboxTool[],
43
- branch: string,
44
- options: RmOneOptions = {}
45
- ): Promise<void> {
49
+ type RmTarget = {
50
+ branch: string;
51
+ effectiveBranch: string;
52
+ engine: string;
53
+ matchedContainers: string[];
54
+ existingWorktrees: string[];
55
+ toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
56
+ workspace: SandboxWorkspaceIdentity;
57
+ controlRoots: string[];
58
+ workspaceViewRoots: string[];
59
+ };
60
+
61
+ type RmOneOptions = {
62
+ assumeYes?: boolean;
63
+ quiet?: boolean;
64
+ target?: RmTarget;
65
+ permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
66
+ allowDirtyDiscard?: boolean;
67
+ prompt?: PromptDependencies;
68
+ };
69
+
70
+ type PromptDependencies = {
71
+ confirm?: typeof p.confirm;
72
+ isCancel?: typeof p.isCancel;
73
+ };
74
+
75
+ function resolveRmTarget(config: SandboxConfig, tools: SandboxTool[], branch: string): RmTarget {
46
76
  assertValidBranchName(branch);
47
77
  const engine = detectEngine(config);
48
78
  let effectiveBranch = branch;
@@ -51,17 +81,11 @@ async function rmOne(
51
81
  tool,
52
82
  candidates: toolConfigDirCandidates(tool, config.project, branch)
53
83
  }));
54
-
55
- if (!options.quiet) {
56
- p.intro(pc.cyan(`Removing sandbox for ${branch}`));
57
- }
58
-
59
- const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
60
- const matchedContainers = containerNameCandidates(config, branch)
61
- .filter((name) => existing.includes(name));
84
+ const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
85
+ const matchedContainers = containerNameCandidates(config, branch).filter((name) => existing.includes(name));
62
86
 
63
87
  if (matchedContainers.length > 0) {
64
- const resolvedBranch = runSafeEngine(engine, 'docker', [
88
+ const resolvedBranch = runEngine(engine, 'docker', [
65
89
  'inspect',
66
90
  '-f',
67
91
  `{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
@@ -75,58 +99,293 @@ async function rmOne(
75
99
  candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
76
100
  }));
77
101
  }
102
+ }
78
103
 
79
- const spinner = p.spinner();
80
- spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
81
- for (const name of matchedContainers) {
82
- runSafeEngine(engine, 'docker', ['stop', name]);
83
- runSafeEngine(engine, 'docker', ['rm', name]);
84
- for (const [base, label] of [
85
- [path.join(config.workspaceViewBase, config.project), 'Workspace view'],
86
- [path.join(config.controlBase, config.project), 'Control channel']
87
- ] as const) {
88
- const directory = path.join(base, name);
89
- if (fs.existsSync(directory)) {
90
- removeManagedDir(base, directory);
91
- if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
92
- }
93
- }
104
+ const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
105
+ const identities: SandboxWorkspaceIdentity[] = workspace.mode === 'task-bound'
106
+ ? [workspace, { mode: 'branch-only' }]
107
+ : [workspace];
108
+ const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
109
+ const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
110
+ base: config.controlBase, project: config.project, container, identity
111
+ }).root));
112
+ const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
113
+ base: config.workspaceViewBase, project: config.project, container, identity
114
+ }).root));
115
+
116
+ return {
117
+ branch,
118
+ effectiveBranch,
119
+ engine,
120
+ matchedContainers,
121
+ existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
122
+ toolCandidates,
123
+ workspace,
124
+ controlRoots: [...new Set(controlRoots)],
125
+ workspaceViewRoots: [...new Set(workspaceViewRoots)]
126
+ };
127
+ }
128
+
129
+ function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceIdentity): void {
130
+ const manifestPath = path.join(root, 'manifest.json');
131
+ if (!fs.existsSync(manifestPath)) return;
132
+ const manifest = readSandboxControlManifest(manifestPath);
133
+ const identityMatches = workspace.mode === 'task-bound'
134
+ ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
135
+ : manifest.mode === 'branch-only';
136
+ const branchOnlyFallback = workspace.mode === 'task-bound' && manifest.mode === 'branch-only';
137
+ if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
138
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
139
+ }
140
+ }
141
+
142
+ function assertLegacyCandidateEvidence(
143
+ candidate: string,
144
+ base: string,
145
+ config: SandboxConfig,
146
+ effectiveBranch: string,
147
+ matchedContainers: readonly string[]
148
+ ): void {
149
+ const relative = path.relative(path.join(base, config.project), candidate);
150
+ const [container, identity] = relative.split(path.sep);
151
+ const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
152
+ if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container)) return;
153
+ const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
154
+ if (!fs.existsSync(manifestPath)) {
155
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
156
+ }
157
+ }
158
+
159
+ function assertRemoved(target: string, label: string): void {
160
+ if (fs.existsSync(target)) throw new Error(`${label} still exists after removal: ${target}`);
161
+ }
162
+
163
+ function removeEmptyManagedParent(base: string, directory: string): void {
164
+ const parent = path.dirname(directory);
165
+ if (path.resolve(parent) === path.resolve(base)) return;
166
+ assertManagedPath(base, parent);
167
+ try {
168
+ fs.rmdirSync(parent);
169
+ } catch (error) {
170
+ if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
171
+ throw error;
172
+ }
173
+ assertRemoved(parent, 'Empty sandbox container directory');
174
+ }
175
+
176
+ async function removeExactSandboxContainer(
177
+ engine: string,
178
+ manifest: ReturnType<typeof readSandboxControlManifest>,
179
+ timeoutMs: number
180
+ ): Promise<void> {
181
+ const deadlineAt = Date.now() + timeoutMs;
182
+ const remaining = (): number => Math.max(1, deadlineAt - Date.now());
183
+ const inspect = () => inspectSandboxControlContainer(manifest, { timeoutMs: remaining() });
184
+ const before = await inspect();
185
+ if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
186
+ if (before.state === 'absent') return;
187
+ if (before.running && !runOkEngine(engine, 'docker', ['stop', manifest.containerIdentity.id], { timeout: remaining() })) {
188
+ const afterStop = await inspect();
189
+ if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
190
+ throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
94
191
  }
95
- spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
96
- } else {
97
- p.log.warn(`No sandbox container found for '${branch}'`);
192
+ if (afterStop.state === 'found' && afterStop.running) {
193
+ throw new Error(`Failed to stop sandbox container: ${manifest.containerIdentity.id}`);
194
+ }
195
+ }
196
+ const afterStop = await inspect();
197
+ if (afterStop.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
198
+ if (afterStop.state === 'found' && !runOkEngine(engine, 'docker', ['rm', manifest.containerIdentity.id], { timeout: remaining() })) {
199
+ const afterRemove = await inspect();
200
+ if (afterRemove.state !== 'absent') {
201
+ throw new Error(`Failed to remove sandbox container: ${manifest.containerIdentity.id}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ async function removeProjectControlRoots(config: SandboxConfig, engine: string): Promise<Set<string>> {
207
+ const containers = new Set<string>();
208
+ const projectRoot = path.join(config.controlBase, config.project);
209
+ if (!fs.existsSync(projectRoot)) return containers;
210
+ assertManagedPath(config.controlBase, projectRoot);
211
+ const projectStat = fs.lstatSync(projectRoot);
212
+ if (!projectStat.isDirectory() || projectStat.isSymbolicLink()) throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
213
+ for (const container of fs.readdirSync(projectRoot, { withFileTypes: true })) {
214
+ if (!container.isDirectory() || container.isSymbolicLink()) continue;
215
+ const containerRoot = path.join(projectRoot, container.name);
216
+ for (const identity of fs.readdirSync(containerRoot, { withFileTypes: true })) {
217
+ if (!identity.isDirectory() || identity.isSymbolicLink()) continue;
218
+ const root = path.join(containerRoot, identity.name);
219
+ const manifestPath = path.join(root, 'manifest.json');
220
+ if (!fs.existsSync(manifestPath)) throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
221
+ const manifest = readSandboxControlManifest(manifestPath);
222
+ containers.add(manifest.container);
223
+ await removeSandboxControlRoot(root, {
224
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
225
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
226
+ });
227
+ }
228
+ }
229
+ return containers;
230
+ }
231
+
232
+ function inspectionBlockers(inspections: readonly WorktreeInspection[]): WorktreeInspection[] {
233
+ return inspections.filter((inspection) => inspection.status !== 'clean');
234
+ }
235
+
236
+ function blockerMessage(blockers: readonly WorktreeInspection[]): string {
237
+ return blockers.map((blocker) => {
238
+ if (blocker.status === 'failed') return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
239
+ return `${formatWorktreeSnapshot(blocker.snapshot)}\n Action: commit, stash, clean, or remove this sandbox interactively.`;
240
+ }).join('\n');
241
+ }
242
+
243
+ function cleanPermits(inspections: readonly WorktreeInspection[]): Map<string, WorktreeRemovalPermit> {
244
+ const permits = new Map<string, WorktreeRemovalPermit>();
245
+ for (const inspection of inspections) {
246
+ if (inspection.status === 'clean') permits.set(inspection.snapshot.worktree, createCleanPermit(inspection.snapshot));
247
+ }
248
+ return permits;
249
+ }
250
+
251
+ async function authorizeWorktrees(
252
+ worktrees: readonly string[],
253
+ { allowDirtyDiscard, assumeYes }: { allowDirtyDiscard: boolean; assumeYes: boolean },
254
+ {
255
+ interactive = Boolean(process.stdin.isTTY),
256
+ confirm = p.confirm,
257
+ isCancel = p.isCancel
258
+ }: PromptDependencies & { interactive?: boolean } = {}
259
+ ): Promise<Map<string, WorktreeRemovalPermit>> {
260
+ const inspections = inspectWorktrees(worktrees);
261
+ const failures = inspections.filter((inspection) => inspection.status === 'failed');
262
+ if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
263
+ const permits = cleanPermits(inspections);
264
+ for (const inspection of inspections) {
265
+ if (inspection.status !== 'dirty') continue;
266
+ if (!allowDirtyDiscard || assumeYes || !interactive) {
267
+ throw new Error(`Refusing to remove dirty worktree(s):\n${blockerMessage([inspection])}`);
268
+ }
269
+ p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
270
+ const confirmed = await confirm({
271
+ message: 'Discard these exact uncommitted changes?',
272
+ initialValue: false
273
+ });
274
+ if (isCancel(confirmed) || !confirmed) throw new Error('Dirty worktree removal cancelled; nothing was deleted');
275
+ permits.set(inspection.snapshot.worktree, createDiscardPermit(inspection.snapshot));
276
+ }
277
+ return permits;
278
+ }
279
+
280
+ async function rmOne(
281
+ config: SandboxConfig,
282
+ tools: SandboxTool[],
283
+ branch: string,
284
+ options: RmOneOptions = {}
285
+ ): Promise<void> {
286
+ const target = options.target ?? resolveRmTarget(config, tools, branch);
287
+ const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
288
+ const { workspace, controlRoots, workspaceViewRoots } = target;
289
+ const confirm = options.prompt?.confirm ?? p.confirm;
290
+ const isCancel = options.prompt?.isCancel ?? p.isCancel;
291
+
292
+ if (!options.quiet) {
293
+ p.intro(pc.cyan(`Removing sandbox for ${branch}`));
294
+ }
295
+
296
+ const permits = options.permits
297
+ ? new Map(options.permits)
298
+ : await authorizeWorktrees(existingWorktrees, {
299
+ allowDirtyDiscard: options.allowDirtyDiscard ?? true,
300
+ assumeYes: Boolean(options.assumeYes)
301
+ }, options.prompt);
302
+ for (const worktree of existingWorktrees) {
303
+ const permit = permits.get(path.resolve(worktree));
304
+ if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
305
+ verifyWorktreePermit(permit);
98
306
  }
99
307
 
100
- const existingWorktrees = worktreeCandidates.filter((candidate) => fs.existsSync(candidate));
101
- if (existingWorktrees.length > 0) {
102
- const shouldRemoveWorktree = options.assumeYes
308
+ const shouldRemoveWorktree = existingWorktrees.length === 0
309
+ ? false
310
+ : options.assumeYes
103
311
  ? true
104
- : await p.confirm({
312
+ : await confirm({
105
313
  message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
106
314
  initialValue: true
107
315
  });
316
+ if (isCancel(shouldRemoveWorktree)) {
317
+ p.outro('Cancelled');
318
+ return;
319
+ }
108
320
 
109
- if (p.isCancel(shouldRemoveWorktree)) {
110
- p.outro('Cancelled');
111
- return;
321
+ const coordinatedContainers = new Set<string>();
322
+ for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
323
+ assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
324
+ assertControlRootMatchesTarget(root, effectiveBranch, workspace);
325
+ const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
326
+ coordinatedContainers.add(manifest.container);
327
+ await removeSandboxControlRoot(root, {
328
+ inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
329
+ removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
330
+ });
331
+ }
332
+
333
+ const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
334
+ if (uncoordinatedContainers.length > 0) {
335
+ const spinner = p.spinner();
336
+ spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
337
+ for (const name of uncoordinatedContainers) {
338
+ if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
339
+ if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
340
+ }
341
+ const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
342
+ const leftovers = uncoordinatedContainers.filter((name) => remaining.includes(name));
343
+ if (leftovers.length > 0) throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
344
+ spinner.stop(pc.green(`Removed container(s): ${uncoordinatedContainers.join(', ')}`));
345
+ } else {
346
+ p.log.warn(`No sandbox container found for '${branch}'`);
347
+ }
348
+
349
+ for (const [roots, base, label] of [
350
+ [workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
351
+ [controlRoots, path.join(config.controlBase, config.project), 'Control channel']
352
+ ] as const) {
353
+ for (const directory of roots.filter((candidate) => fs.existsSync(candidate))) {
354
+ assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
355
+ ? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
356
+ removeManagedDir(base, directory);
357
+ assertRemoved(directory, label);
358
+ removeEmptyManagedParent(base, directory);
359
+ if (!options.quiet) p.log.success(`${label} removed: ${directory}`);
112
360
  }
361
+ }
113
362
 
114
- if (shouldRemoveWorktree) {
115
- for (const worktree of existingWorktrees) {
116
- removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
117
- }
363
+ if (shouldRemoveWorktree) {
364
+ for (const worktree of existingWorktrees) {
365
+ const permit = permits.get(path.resolve(worktree));
366
+ if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
367
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree, permit, {
368
+ allowRegisteredPathFallback: engine === ENGINES.WSL2
369
+ });
370
+ assertRemoved(worktree, 'Worktree');
371
+ const registered = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
372
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9)));
373
+ if (registered.includes(path.resolve(worktree))) throw new Error(`Worktree is still registered after removal: ${worktree}`);
374
+ }
375
+
376
+ const shouldDeleteBranch = options.assumeYes
377
+ ? true
378
+ : await confirm({
379
+ message: `Also delete local branch '${effectiveBranch}'?`,
380
+ initialValue: true
381
+ });
118
382
 
119
- const shouldDeleteBranch = options.assumeYes
120
- ? true
121
- : await p.confirm({
122
- message: `Also delete local branch '${effectiveBranch}'?`,
123
- initialValue: true
124
- });
125
-
126
- if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
127
- if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
128
- p.log.warn(`Local branch '${effectiveBranch}' was not deleted`);
129
- }
383
+ if (!isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
384
+ if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
385
+ throw new Error(`Local branch '${effectiveBranch}' was not deleted`);
386
+ }
387
+ if (runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
388
+ throw new Error(`Local branch '${effectiveBranch}' still exists after removal`);
130
389
  }
131
390
  }
132
391
  }
@@ -134,12 +393,14 @@ async function rmOne(
134
393
  for (const { tool, candidates } of toolCandidates) {
135
394
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
136
395
  removeManagedDir(tool.sandboxBase, dir);
396
+ assertRemoved(dir, `${tool.name} state`);
137
397
  p.log.success(`${tool.name} state removed: ${dir}`);
138
398
  }
139
399
  }
140
400
 
141
401
  for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
142
402
  removeManagedDir(config.shellConfigBase, dir);
403
+ assertRemoved(dir, 'Shell config');
143
404
  p.log.success(`Shell config removed: ${dir}`);
144
405
  }
145
406
 
@@ -147,12 +408,13 @@ async function rmOne(
147
408
  if (fs.existsSync(shareBranch)) {
148
409
  const shouldRemoveShare = options.assumeYes
149
410
  ? true
150
- : await p.confirm({
411
+ : await confirm({
151
412
  message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
152
413
  initialValue: true
153
414
  });
154
- if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
415
+ if (!isCancel(shouldRemoveShare) && shouldRemoveShare) {
155
416
  removeManagedDir(config.shareBase, shareBranch);
417
+ assertRemoved(shareBranch, 'Share dir');
156
418
  p.log.success(`Share dir removed: ${shareBranch}`);
157
419
  }
158
420
  }
@@ -162,11 +424,33 @@ async function rmOne(
162
424
  }
163
425
  }
164
426
 
165
- async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<void> {
427
+ async function rmPurge(
428
+ config: SandboxConfig,
429
+ tools: SandboxTool[],
430
+ prompt: PromptDependencies = {}
431
+ ): Promise<void> {
166
432
  const engine = detectEngine(config);
433
+ const confirm = prompt.confirm ?? p.confirm;
434
+ const isCancel = prompt.isCancel ?? p.isCancel;
167
435
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
168
436
 
169
- const containers = runSafeEngine(engine, 'docker', [
437
+ const worktrees = fs.existsSync(config.worktreeBase)
438
+ ? fs.readdirSync(config.worktreeBase)
439
+ .map((entry) => path.join(config.worktreeBase, entry))
440
+ .filter((entry) => {
441
+ try { return fs.statSync(entry).isDirectory(); } catch { return false; }
442
+ })
443
+ : [];
444
+ const inspections = inspectWorktrees(worktrees);
445
+ const blockers = inspectionBlockers(inspections);
446
+ if (blockers.length > 0) {
447
+ throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
448
+ }
449
+ const permits = cleanPermits(inspections);
450
+
451
+ const coordinatedContainers = await removeProjectControlRoots(config, engine);
452
+
453
+ const containers = runEngine(engine, 'docker', [
170
454
  'ps',
171
455
  '-a',
172
456
  '--filter',
@@ -174,62 +458,79 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
174
458
  '--format',
175
459
  '{{.Names}}'
176
460
  ]);
177
- if (containers) {
461
+ const uncoordinatedContainers = containers.split('\n').filter((name) => name && !coordinatedContainers.has(name));
462
+ if (uncoordinatedContainers.length > 0) {
178
463
  const spinner = p.spinner();
179
464
  spinner.start('Stopping project sandbox containers...');
180
- for (const name of containers.split('\n').filter(Boolean)) {
181
- runSafeEngine(engine, 'docker', ['stop', name]);
182
- runSafeEngine(engine, 'docker', ['rm', name]);
465
+ for (const name of uncoordinatedContainers) {
466
+ if (!runOkEngine(engine, 'docker', ['stop', name])) throw new Error(`Failed to stop sandbox container: ${name}`);
467
+ if (!runOkEngine(engine, 'docker', ['rm', name])) throw new Error(`Failed to remove sandbox container: ${name}`);
183
468
  }
469
+ const remaining = runEngine(engine, 'docker', [
470
+ 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
471
+ ]);
472
+ const leftovers = remaining.split('\n').filter((name) => name && !coordinatedContainers.has(name));
473
+ if (leftovers.length > 0) throw new Error(`Project sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
184
474
  spinner.stop(pc.green('Project sandbox containers removed'));
185
475
  } else {
186
476
  p.log.warn('No project sandbox containers found');
187
477
  }
188
478
 
189
- if (fs.existsSync(config.worktreeBase) && fs.readdirSync(config.worktreeBase).length > 0) {
190
- const shouldRemoveWorktrees = await p.confirm({
479
+ if (worktrees.length > 0) {
480
+ const shouldRemoveWorktrees = await confirm({
191
481
  message: `Remove all worktrees in ${config.worktreeBase}?`,
192
482
  initialValue: true
193
483
  });
194
484
 
195
- if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
196
- for (const entry of fs.readdirSync(config.worktreeBase)) {
197
- const dir = path.join(config.worktreeBase, entry);
198
- removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
485
+ if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
486
+ for (const dir of worktrees) {
487
+ const permit = permits.get(path.resolve(dir));
488
+ if (!permit) throw new Error(`Missing worktree removal permit: ${dir}`);
489
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
490
+ allowRegisteredPathFallback: engine === ENGINES.WSL2
491
+ });
492
+ assertRemoved(dir, 'Worktree');
199
493
  }
200
494
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
495
+ const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
496
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
497
+ const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
498
+ if (leftovers.length > 0) throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
201
499
  }
202
500
  }
203
501
 
204
502
  for (const dir of projectToolDirs(config, tools)) {
205
503
  if (fs.existsSync(dir)) {
206
504
  removeManagedDir(path.dirname(dir), dir);
505
+ assertRemoved(dir, 'Tool state');
207
506
  p.log.success(`Removed tool state: ${dir}`);
208
507
  }
209
508
  }
210
509
 
211
510
  if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
212
- const shouldRemoveShellConfigs = await p.confirm({
511
+ const shouldRemoveShellConfigs = await confirm({
213
512
  message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
214
513
  initialValue: true
215
514
  });
216
515
 
217
- if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
516
+ if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
218
517
  for (const entry of fs.readdirSync(config.shellConfigBase)) {
219
518
  const dir = path.join(config.shellConfigBase, entry);
220
519
  removeManagedDir(config.shellConfigBase, dir);
520
+ assertRemoved(dir, 'Shell config');
221
521
  }
222
522
  p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
223
523
  }
224
524
  }
225
525
 
226
526
  if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
227
- const shouldRemoveAllShares = await p.confirm({
527
+ const shouldRemoveAllShares = await confirm({
228
528
  message: `Remove all share dirs for project (${config.shareBase})?`,
229
529
  initialValue: true
230
530
  });
231
- if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
531
+ if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
232
532
  removeManagedDir(path.dirname(config.shareBase), config.shareBase);
533
+ assertRemoved(config.shareBase, 'Project share dir');
233
534
  p.log.success(`Project share dirs removed: ${config.shareBase}`);
234
535
  }
235
536
  }
@@ -241,15 +542,16 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
241
542
  const projectDir = path.join(base, config.project);
242
543
  if (fs.existsSync(projectDir)) {
243
544
  removeManagedDir(base, projectDir);
545
+ assertRemoved(projectDir, `Project ${label}`);
244
546
  p.log.success(`Removed project ${label}: ${projectDir}`);
245
547
  }
246
548
  }
247
549
 
248
- const shouldRemoveImage = await p.confirm({
550
+ const shouldRemoveImage = await confirm({
249
551
  message: `Remove image ${config.imageName}?`,
250
552
  initialValue: false
251
553
  });
252
- if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
554
+ if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
253
555
  runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
254
556
  }
255
557
 
@@ -263,11 +565,11 @@ async function rmPurge(config: SandboxConfig, tools: SandboxTool[]): Promise<voi
263
565
  }
264
566
 
265
567
  const name = engineDisplayName(engine);
266
- const shouldStopVm = await p.confirm({
568
+ const shouldStopVm = await confirm({
267
569
  message: `Stop ${name} VM?`,
268
570
  initialValue: false
269
571
  });
270
- if (!p.isCancel(shouldStopVm) && shouldStopVm) {
572
+ if (!isCancel(shouldStopVm) && shouldStopVm) {
271
573
  stopManagedVm(config);
272
574
  }
273
575
  }
@@ -293,35 +595,41 @@ async function rmUnbound(
293
595
  return;
294
596
  }
295
597
 
598
+ const targets = removable.map((row) => resolveRmTarget(config, tools, row.branch));
599
+ const inspections = inspectWorktrees(targets.flatMap((target) => target.existingWorktrees));
600
+ const blockers = inspectionBlockers(inspections);
601
+ const permits = cleanPermits(inspections);
602
+
296
603
  for (const row of removable) {
297
604
  p.log.message(`${row.name} ${row.branch}`);
298
605
  }
606
+ if (blockers.length > 0) p.log.error(blockerMessage(blockers));
299
607
 
300
608
  if (options.dryRun) {
301
- p.outro(`Dry run: ${removable.length} sandbox(es) would be removed, nothing deleted`);
609
+ p.outro(`Dry run: ${removable.length} sandbox(es) inspected, nothing deleted`);
302
610
  return;
303
611
  }
304
612
 
305
- if (!options.assumeYes) {
306
- if (!process.stdin.isTTY) {
307
- throw new Error(
308
- 'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
309
- );
310
- }
311
- const confirmed = await p.confirm({
312
- message: `Remove these ${removable.length} sandbox(es)?`,
313
- initialValue: false
314
- });
315
- if (p.isCancel(confirmed) || !confirmed) {
316
- p.outro('Cancelled');
317
- return;
318
- }
613
+ if (blockers.length > 0) {
614
+ throw new Error(`Refusing batch removal because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
615
+ }
616
+
617
+ if (!options.assumeYes && !process.stdin.isTTY) {
618
+ throw new Error(
619
+ 'Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.'
620
+ );
319
621
  }
320
622
 
321
623
  const failures: { branch: string; message: string }[] = [];
322
- for (const row of removable) {
624
+ for (const [index, row] of removable.entries()) {
323
625
  try {
324
- await rmOne(config, tools, row.branch, { assumeYes: true, quiet: true });
626
+ await rmOne(config, tools, row.branch, {
627
+ assumeYes: options.assumeYes,
628
+ quiet: true,
629
+ target: targets[index],
630
+ permits,
631
+ allowDirtyDiscard: false
632
+ });
325
633
  } catch (error) {
326
634
  failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
327
635
  }
@@ -346,6 +654,7 @@ export async function rm(args: string[]): Promise<void> {
346
654
  strict: true,
347
655
  options: {
348
656
  all: { type: 'boolean' },
657
+ unbound: { type: 'boolean' },
349
658
  purge: { type: 'boolean' },
350
659
  'dry-run': { type: 'boolean' },
351
660
  yes: { type: 'boolean', short: 'y' },
@@ -353,24 +662,28 @@ export async function rm(args: string[]): Promise<void> {
353
662
  }
354
663
  });
355
664
 
665
+ if (values.all) {
666
+ throw new Error('CLI_FLAG_REMOVED: --all was removed; use --unbound');
667
+ }
668
+
356
669
  if (values.help) {
357
670
  process.stdout.write(`${USAGE}\n`);
358
671
  return;
359
672
  }
360
673
 
361
- if (values.all && values.purge) {
362
- throw new Error('--all and --purge are mutually exclusive');
674
+ if (values.unbound && values.purge) {
675
+ throw new Error('--unbound and --purge are mutually exclusive');
363
676
  }
364
677
 
365
- if ((values['dry-run'] || values.yes) && !values.all) {
366
- throw new Error('--dry-run and --yes only apply to --all');
678
+ if ((values['dry-run'] || values.yes) && !values.unbound) {
679
+ throw new Error('--dry-run and --yes only apply to --unbound');
367
680
  }
368
681
 
369
- if ((values.all || values.purge) && positionals.length > 0) {
370
- throw new Error(`${values.all ? '--all' : '--purge'} does not take a branch argument`);
682
+ if ((values.unbound || values.purge) && positionals.length > 0) {
683
+ throw new Error(`${values.unbound ? '--unbound' : '--purge'} does not take a branch argument`);
371
684
  }
372
685
 
373
- if (!values.all && !values.purge && positionals.length !== 1) {
686
+ if (!values.unbound && !values.purge && positionals.length !== 1) {
374
687
  throw new Error(USAGE);
375
688
  }
376
689
 
@@ -382,7 +695,7 @@ export async function rm(args: string[]): Promise<void> {
382
695
  return;
383
696
  }
384
697
 
385
- if (values.all) {
698
+ if (values.unbound) {
386
699
  await rmUnbound(config, tools, {
387
700
  dryRun: Boolean(values['dry-run']),
388
701
  assumeYes: Boolean(values.yes)
@@ -393,3 +706,5 @@ export async function rm(args: string[]): Promise<void> {
393
706
  const target = resolveSandboxTarget(positionals[0] ?? '', config.repoRoot);
394
707
  await rmOne(config, tools, target.branch);
395
708
  }
709
+
710
+ export { authorizeWorktrees, rmOne, rmPurge };