@fitlab-ai/agent-infra 0.9.5 → 0.9.6

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