@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
@@ -7,22 +7,25 @@ import { loadConfig } from '../config.js';
7
7
  import { assertValidBranchName, containerNameCandidates, sandboxBranchLabel, sandboxLabel, shareBranchDir, shellConfigDirCandidates, worktreeDirCandidates } from '../constants.js';
8
8
  import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.js';
9
9
  import { pruneSandboxDanglingImages } from '../image-prune.js';
10
- import { removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
- import { runOk, runSafe, runSafeEngine } from '../shell.js';
10
+ import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.js';
11
+ import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.js';
12
12
  import { resolveSandboxTarget } from '../workspace-identity.js';
13
+ import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.js';
14
+ import { quiesceSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.js';
13
15
  import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.js';
14
16
  import { createSandboxCapabilityPlan } from '../agent-client-reconciler.js';
15
17
  import { fetchSandboxRows } from './list-running.js';
16
18
  import { lookupShortIdByBranch } from '../../task/short-id.js';
19
+ import { createCleanPermit, createDiscardPermit, formatWorktreeSnapshot, inspectWorktrees, verifyWorktreePermit } from '../worktree-safety.js';
17
20
  const USAGE = `Usage:
18
21
  ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
19
- ai sandbox rm --all [--dry-run] [--yes] Remove every sandbox not bound to an active task
22
+ ai sandbox rm --unbound [--dry-run] [--yes] Remove every sandbox not bound to an active task
20
23
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
21
24
  export { assertManagedPath } from '../managed-fs.js';
22
25
  function projectToolDirs(config, tools) {
23
26
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
24
27
  }
25
- async function rmOne(config, tools, branch, options = {}) {
28
+ function resolveRmTarget(config, tools, branch) {
26
29
  assertValidBranchName(branch);
27
30
  const engine = detectEngine(config);
28
31
  let effectiveBranch = branch;
@@ -31,14 +34,10 @@ async function rmOne(config, tools, branch, options = {}) {
31
34
  tool,
32
35
  candidates: toolConfigDirCandidates(tool, config.project, branch)
33
36
  }));
34
- if (!options.quiet) {
35
- p.intro(pc.cyan(`Removing sandbox for ${branch}`));
36
- }
37
- const existing = runSafeEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
38
- const matchedContainers = containerNameCandidates(config, branch)
39
- .filter((name) => existing.includes(name));
37
+ const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
38
+ const matchedContainers = containerNameCandidates(config, branch).filter((name) => existing.includes(name));
40
39
  if (matchedContainers.length > 0) {
41
- const resolvedBranch = runSafeEngine(engine, 'docker', [
40
+ const resolvedBranch = runEngine(engine, 'docker', [
42
41
  'inspect',
43
42
  '-f',
44
43
  `{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
@@ -52,77 +51,255 @@ async function rmOne(config, tools, branch, options = {}) {
52
51
  candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
53
52
  }));
54
53
  }
54
+ }
55
+ const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
56
+ const identities = workspace.mode === 'task-bound'
57
+ ? [workspace, { mode: 'branch-only' }]
58
+ : [workspace];
59
+ const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
60
+ const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
61
+ base: config.controlBase, project: config.project, container, identity
62
+ }).root));
63
+ const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
64
+ base: config.workspaceViewBase, project: config.project, container, identity
65
+ }).root));
66
+ return {
67
+ branch,
68
+ effectiveBranch,
69
+ engine,
70
+ matchedContainers,
71
+ existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
72
+ toolCandidates,
73
+ workspace,
74
+ controlRoots: [...new Set(controlRoots)],
75
+ workspaceViewRoots: [...new Set(workspaceViewRoots)]
76
+ };
77
+ }
78
+ function assertControlRootMatchesTarget(root, effectiveBranch, workspace) {
79
+ const manifestPath = path.join(root, 'manifest.json');
80
+ if (!fs.existsSync(manifestPath))
81
+ return;
82
+ const manifest = readSandboxControlManifest(manifestPath);
83
+ const identityMatches = workspace.mode === 'task-bound'
84
+ ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
85
+ : manifest.mode === 'branch-only';
86
+ const branchOnlyFallback = workspace.mode === 'task-bound' && manifest.mode === 'branch-only';
87
+ if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
88
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
89
+ }
90
+ }
91
+ function assertLegacyCandidateEvidence(candidate, base, config, effectiveBranch, matchedContainers) {
92
+ const relative = path.relative(path.join(base, config.project), candidate);
93
+ const [container, identity] = relative.split(path.sep);
94
+ const [canonical, legacy] = containerNameCandidates(config, effectiveBranch);
95
+ if (!container || !identity || legacy === canonical || container !== legacy || matchedContainers.includes(container))
96
+ return;
97
+ const manifestPath = path.join(config.controlBase, config.project, container, identity, 'manifest.json');
98
+ if (!fs.existsSync(manifestPath)) {
99
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${candidate}`);
100
+ }
101
+ }
102
+ function assertRemoved(target, label) {
103
+ if (fs.existsSync(target))
104
+ throw new Error(`${label} still exists after removal: ${target}`);
105
+ }
106
+ function removeEmptyManagedParent(base, directory) {
107
+ const parent = path.dirname(directory);
108
+ if (path.resolve(parent) === path.resolve(base))
109
+ return;
110
+ assertManagedPath(base, parent);
111
+ try {
112
+ fs.rmdirSync(parent);
113
+ }
114
+ catch (error) {
115
+ if (['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code ?? ''))
116
+ return;
117
+ throw error;
118
+ }
119
+ assertRemoved(parent, 'Empty sandbox container directory');
120
+ }
121
+ async function quiesceProjectControlRoots(config) {
122
+ const projectRoot = path.join(config.controlBase, config.project);
123
+ if (!fs.existsSync(projectRoot))
124
+ return;
125
+ assertManagedPath(config.controlBase, projectRoot);
126
+ const projectStat = fs.lstatSync(projectRoot);
127
+ if (!projectStat.isDirectory() || projectStat.isSymbolicLink())
128
+ throw new Error('SANDBOX_CONTROL_CHANNEL_INVALID');
129
+ for (const container of fs.readdirSync(projectRoot, { withFileTypes: true })) {
130
+ if (!container.isDirectory() || container.isSymbolicLink())
131
+ continue;
132
+ const containerRoot = path.join(projectRoot, container.name);
133
+ for (const identity of fs.readdirSync(containerRoot, { withFileTypes: true })) {
134
+ if (!identity.isDirectory() || identity.isSymbolicLink())
135
+ continue;
136
+ await quiesceSandboxControlRoot(path.join(containerRoot, identity.name));
137
+ }
138
+ }
139
+ }
140
+ function inspectionBlockers(inspections) {
141
+ return inspections.filter((inspection) => inspection.status !== 'clean');
142
+ }
143
+ function blockerMessage(blockers) {
144
+ return blockers.map((blocker) => {
145
+ if (blocker.status === 'failed')
146
+ return `${JSON.stringify(blocker.worktree)}: ${blocker.message}`;
147
+ return `${formatWorktreeSnapshot(blocker.snapshot)}\n Action: commit, stash, clean, or remove this sandbox interactively.`;
148
+ }).join('\n');
149
+ }
150
+ function cleanPermits(inspections) {
151
+ const permits = new Map();
152
+ for (const inspection of inspections) {
153
+ if (inspection.status === 'clean')
154
+ permits.set(inspection.snapshot.worktree, createCleanPermit(inspection.snapshot));
155
+ }
156
+ return permits;
157
+ }
158
+ async function authorizeWorktrees(worktrees, { allowDirtyDiscard, assumeYes }, { interactive = Boolean(process.stdin.isTTY), confirm = p.confirm, isCancel = p.isCancel } = {}) {
159
+ const inspections = inspectWorktrees(worktrees);
160
+ const failures = inspections.filter((inspection) => inspection.status === 'failed');
161
+ if (failures.length > 0)
162
+ throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
163
+ const permits = cleanPermits(inspections);
164
+ for (const inspection of inspections) {
165
+ if (inspection.status !== 'dirty')
166
+ continue;
167
+ if (!allowDirtyDiscard || assumeYes || !interactive) {
168
+ throw new Error(`Refusing to remove dirty worktree(s):\n${blockerMessage([inspection])}`);
169
+ }
170
+ p.log.warn(formatWorktreeSnapshot(inspection.snapshot));
171
+ const confirmed = await confirm({
172
+ message: 'Discard these exact uncommitted changes?',
173
+ initialValue: false
174
+ });
175
+ if (isCancel(confirmed) || !confirmed)
176
+ throw new Error('Dirty worktree removal cancelled; nothing was deleted');
177
+ permits.set(inspection.snapshot.worktree, createDiscardPermit(inspection.snapshot));
178
+ }
179
+ return permits;
180
+ }
181
+ async function rmOne(config, tools, branch, options = {}) {
182
+ const target = options.target ?? resolveRmTarget(config, tools, branch);
183
+ const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
184
+ const { workspace, controlRoots, workspaceViewRoots } = target;
185
+ const confirm = options.prompt?.confirm ?? p.confirm;
186
+ const isCancel = options.prompt?.isCancel ?? p.isCancel;
187
+ if (!options.quiet) {
188
+ p.intro(pc.cyan(`Removing sandbox for ${branch}`));
189
+ }
190
+ const permits = options.permits
191
+ ? new Map(options.permits)
192
+ : await authorizeWorktrees(existingWorktrees, {
193
+ allowDirtyDiscard: options.allowDirtyDiscard ?? true,
194
+ assumeYes: Boolean(options.assumeYes)
195
+ }, options.prompt);
196
+ for (const worktree of existingWorktrees) {
197
+ const permit = permits.get(path.resolve(worktree));
198
+ if (!permit)
199
+ throw new Error(`Missing worktree removal permit: ${worktree}`);
200
+ verifyWorktreePermit(permit);
201
+ }
202
+ const shouldRemoveWorktree = existingWorktrees.length === 0
203
+ ? false
204
+ : options.assumeYes
205
+ ? true
206
+ : await confirm({
207
+ message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
208
+ initialValue: true
209
+ });
210
+ if (isCancel(shouldRemoveWorktree)) {
211
+ p.outro('Cancelled');
212
+ return;
213
+ }
214
+ for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
215
+ assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
216
+ assertControlRootMatchesTarget(root, effectiveBranch, workspace);
217
+ await quiesceSandboxControlRoot(root);
218
+ }
219
+ if (matchedContainers.length > 0) {
55
220
  const spinner = p.spinner();
56
221
  spinner.start(`Stopping container(s): ${matchedContainers.join(', ')}`);
57
222
  for (const name of matchedContainers) {
58
- runSafeEngine(engine, 'docker', ['stop', name]);
59
- runSafeEngine(engine, 'docker', ['rm', name]);
60
- for (const [base, label] of [
61
- [path.join(config.workspaceViewBase, config.project), 'Workspace view'],
62
- [path.join(config.controlBase, config.project), 'Control channel']
63
- ]) {
64
- const directory = path.join(base, name);
65
- if (fs.existsSync(directory)) {
66
- removeManagedDir(base, directory);
67
- if (!options.quiet)
68
- p.log.success(`${label} removed: ${directory}`);
69
- }
70
- }
223
+ if (!runOkEngine(engine, 'docker', ['stop', name]))
224
+ throw new Error(`Failed to stop sandbox container: ${name}`);
225
+ if (!runOkEngine(engine, 'docker', ['rm', name]))
226
+ throw new Error(`Failed to remove sandbox container: ${name}`);
71
227
  }
228
+ const remaining = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
229
+ const leftovers = matchedContainers.filter((name) => remaining.includes(name));
230
+ if (leftovers.length > 0)
231
+ throw new Error(`Sandbox container(s) still exist after removal: ${leftovers.join(', ')}`);
72
232
  spinner.stop(pc.green(`Removed container(s): ${matchedContainers.join(', ')}`));
73
233
  }
74
234
  else {
75
235
  p.log.warn(`No sandbox container found for '${branch}'`);
76
236
  }
77
- const existingWorktrees = worktreeCandidates.filter((candidate) => fs.existsSync(candidate));
78
- if (existingWorktrees.length > 0) {
79
- const shouldRemoveWorktree = options.assumeYes
237
+ for (const [roots, base, label] of [
238
+ [workspaceViewRoots, path.join(config.workspaceViewBase, config.project), 'Workspace view'],
239
+ [controlRoots, path.join(config.controlBase, config.project), 'Control channel']
240
+ ]) {
241
+ for (const directory of roots.filter((candidate) => fs.existsSync(candidate))) {
242
+ assertLegacyCandidateEvidence(directory, base === path.join(config.controlBase, config.project)
243
+ ? config.controlBase : config.workspaceViewBase, config, effectiveBranch, matchedContainers);
244
+ removeManagedDir(base, directory);
245
+ assertRemoved(directory, label);
246
+ removeEmptyManagedParent(base, directory);
247
+ if (!options.quiet)
248
+ p.log.success(`${label} removed: ${directory}`);
249
+ }
250
+ }
251
+ if (shouldRemoveWorktree) {
252
+ for (const worktree of existingWorktrees) {
253
+ const permit = permits.get(path.resolve(worktree));
254
+ if (!permit)
255
+ throw new Error(`Missing worktree removal permit: ${worktree}`);
256
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree, permit, {
257
+ allowRegisteredPathFallback: engine === ENGINES.WSL2
258
+ });
259
+ assertRemoved(worktree, 'Worktree');
260
+ const registered = runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
261
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9)));
262
+ if (registered.includes(path.resolve(worktree)))
263
+ throw new Error(`Worktree is still registered after removal: ${worktree}`);
264
+ }
265
+ const shouldDeleteBranch = options.assumeYes
80
266
  ? true
81
- : await p.confirm({
82
- message: `Remove worktree(s): ${existingWorktrees.join(', ')}?`,
267
+ : await confirm({
268
+ message: `Also delete local branch '${effectiveBranch}'?`,
83
269
  initialValue: true
84
270
  });
85
- if (p.isCancel(shouldRemoveWorktree)) {
86
- p.outro('Cancelled');
87
- return;
88
- }
89
- if (shouldRemoveWorktree) {
90
- for (const worktree of existingWorktrees) {
91
- removeWorktreeDir(config.repoRoot, config.worktreeBase, worktree);
271
+ if (!isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
272
+ if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
273
+ throw new Error(`Local branch '${effectiveBranch}' was not deleted`);
92
274
  }
93
- const shouldDeleteBranch = options.assumeYes
94
- ? true
95
- : await p.confirm({
96
- message: `Also delete local branch '${effectiveBranch}'?`,
97
- initialValue: true
98
- });
99
- if (!p.isCancel(shouldDeleteBranch) && shouldDeleteBranch) {
100
- if (!runOk('git', ['-C', config.repoRoot, 'branch', '-D', effectiveBranch])) {
101
- p.log.warn(`Local branch '${effectiveBranch}' was not deleted`);
102
- }
275
+ if (runOk('git', ['-C', config.repoRoot, 'show-ref', '--verify', `refs/heads/${effectiveBranch}`])) {
276
+ throw new Error(`Local branch '${effectiveBranch}' still exists after removal`);
103
277
  }
104
278
  }
105
279
  }
106
280
  for (const { tool, candidates } of toolCandidates) {
107
281
  for (const dir of candidates.filter((candidate) => fs.existsSync(candidate))) {
108
282
  removeManagedDir(tool.sandboxBase, dir);
283
+ assertRemoved(dir, `${tool.name} state`);
109
284
  p.log.success(`${tool.name} state removed: ${dir}`);
110
285
  }
111
286
  }
112
287
  for (const dir of shellConfigDirCandidates(config, effectiveBranch).filter((candidate) => fs.existsSync(candidate))) {
113
288
  removeManagedDir(config.shellConfigBase, dir);
289
+ assertRemoved(dir, 'Shell config');
114
290
  p.log.success(`Shell config removed: ${dir}`);
115
291
  }
116
292
  const shareBranch = shareBranchDir(config, effectiveBranch);
117
293
  if (fs.existsSync(shareBranch)) {
118
294
  const shouldRemoveShare = options.assumeYes
119
295
  ? true
120
- : await p.confirm({
296
+ : await confirm({
121
297
  message: `Remove share dir for branch '${effectiveBranch}' (${shareBranch})?`,
122
298
  initialValue: true
123
299
  });
124
- if (!p.isCancel(shouldRemoveShare) && shouldRemoveShare) {
300
+ if (!isCancel(shouldRemoveShare) && shouldRemoveShare) {
125
301
  removeManagedDir(config.shareBase, shareBranch);
302
+ assertRemoved(shareBranch, 'Share dir');
126
303
  p.log.success(`Share dir removed: ${shareBranch}`);
127
304
  }
128
305
  }
@@ -130,10 +307,31 @@ async function rmOne(config, tools, branch, options = {}) {
130
307
  p.outro(pc.green('Sandbox removed'));
131
308
  }
132
309
  }
133
- async function rmPurge(config, tools) {
310
+ async function rmPurge(config, tools, prompt = {}) {
134
311
  const engine = detectEngine(config);
312
+ const confirm = prompt.confirm ?? p.confirm;
313
+ const isCancel = prompt.isCancel ?? p.isCancel;
135
314
  p.intro(pc.cyan(`Removing all sandboxes for ${config.project}`));
136
- const containers = runSafeEngine(engine, 'docker', [
315
+ const worktrees = fs.existsSync(config.worktreeBase)
316
+ ? fs.readdirSync(config.worktreeBase)
317
+ .map((entry) => path.join(config.worktreeBase, entry))
318
+ .filter((entry) => {
319
+ try {
320
+ return fs.statSync(entry).isDirectory();
321
+ }
322
+ catch {
323
+ return false;
324
+ }
325
+ })
326
+ : [];
327
+ const inspections = inspectWorktrees(worktrees);
328
+ const blockers = inspectionBlockers(inspections);
329
+ if (blockers.length > 0) {
330
+ throw new Error(`Refusing to purge because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
331
+ }
332
+ const permits = cleanPermits(inspections);
333
+ await quiesceProjectControlRoots(config);
334
+ const containers = runEngine(engine, 'docker', [
137
335
  'ps',
138
336
  '-a',
139
337
  '--filter',
@@ -145,53 +343,73 @@ async function rmPurge(config, tools) {
145
343
  const spinner = p.spinner();
146
344
  spinner.start('Stopping project sandbox containers...');
147
345
  for (const name of containers.split('\n').filter(Boolean)) {
148
- runSafeEngine(engine, 'docker', ['stop', name]);
149
- runSafeEngine(engine, 'docker', ['rm', name]);
346
+ if (!runOkEngine(engine, 'docker', ['stop', name]))
347
+ throw new Error(`Failed to stop sandbox container: ${name}`);
348
+ if (!runOkEngine(engine, 'docker', ['rm', name]))
349
+ throw new Error(`Failed to remove sandbox container: ${name}`);
150
350
  }
351
+ const remaining = runEngine(engine, 'docker', [
352
+ 'ps', '-a', '--filter', `label=${sandboxLabel(config)}`, '--format', '{{.Names}}'
353
+ ]);
354
+ if (remaining)
355
+ throw new Error(`Project sandbox container(s) still exist after removal: ${remaining.replaceAll('\n', ', ')}`);
151
356
  spinner.stop(pc.green('Project sandbox containers removed'));
152
357
  }
153
358
  else {
154
359
  p.log.warn('No project sandbox containers found');
155
360
  }
156
- if (fs.existsSync(config.worktreeBase) && fs.readdirSync(config.worktreeBase).length > 0) {
157
- const shouldRemoveWorktrees = await p.confirm({
361
+ if (worktrees.length > 0) {
362
+ const shouldRemoveWorktrees = await confirm({
158
363
  message: `Remove all worktrees in ${config.worktreeBase}?`,
159
364
  initialValue: true
160
365
  });
161
- if (!p.isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
162
- for (const entry of fs.readdirSync(config.worktreeBase)) {
163
- const dir = path.join(config.worktreeBase, entry);
164
- removeWorktreeDir(config.repoRoot, config.worktreeBase, dir);
366
+ if (!isCancel(shouldRemoveWorktrees) && shouldRemoveWorktrees) {
367
+ for (const dir of worktrees) {
368
+ const permit = permits.get(path.resolve(dir));
369
+ if (!permit)
370
+ throw new Error(`Missing worktree removal permit: ${dir}`);
371
+ removeWorktreeDir(config.repoRoot, config.worktreeBase, dir, permit, {
372
+ allowRegisteredPathFallback: engine === ENGINES.WSL2
373
+ });
374
+ assertRemoved(dir, 'Worktree');
165
375
  }
166
376
  runSafe('git', ['-C', config.repoRoot, 'worktree', 'prune']);
377
+ const registered = new Set(runSafe('git', ['-C', config.repoRoot, 'worktree', 'list', '--porcelain'])
378
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => path.resolve(line.slice(9))));
379
+ const leftovers = worktrees.filter((dir) => registered.has(path.resolve(dir)));
380
+ if (leftovers.length > 0)
381
+ throw new Error(`Worktree(s) still registered after removal: ${leftovers.join(', ')}`);
167
382
  }
168
383
  }
169
384
  for (const dir of projectToolDirs(config, tools)) {
170
385
  if (fs.existsSync(dir)) {
171
386
  removeManagedDir(path.dirname(dir), dir);
387
+ assertRemoved(dir, 'Tool state');
172
388
  p.log.success(`Removed tool state: ${dir}`);
173
389
  }
174
390
  }
175
391
  if (fs.existsSync(config.shellConfigBase) && fs.readdirSync(config.shellConfigBase).length > 0) {
176
- const shouldRemoveShellConfigs = await p.confirm({
392
+ const shouldRemoveShellConfigs = await confirm({
177
393
  message: `Remove all shell config dirs in ${config.shellConfigBase}?`,
178
394
  initialValue: true
179
395
  });
180
- if (!p.isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
396
+ if (!isCancel(shouldRemoveShellConfigs) && shouldRemoveShellConfigs) {
181
397
  for (const entry of fs.readdirSync(config.shellConfigBase)) {
182
398
  const dir = path.join(config.shellConfigBase, entry);
183
399
  removeManagedDir(config.shellConfigBase, dir);
400
+ assertRemoved(dir, 'Shell config');
184
401
  }
185
402
  p.log.success(`Project shell config dirs removed: ${config.shellConfigBase}`);
186
403
  }
187
404
  }
188
405
  if (fs.existsSync(config.shareBase) && fs.readdirSync(config.shareBase).length > 0) {
189
- const shouldRemoveAllShares = await p.confirm({
406
+ const shouldRemoveAllShares = await confirm({
190
407
  message: `Remove all share dirs for project (${config.shareBase})?`,
191
408
  initialValue: true
192
409
  });
193
- if (!p.isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
410
+ if (!isCancel(shouldRemoveAllShares) && shouldRemoveAllShares) {
194
411
  removeManagedDir(path.dirname(config.shareBase), config.shareBase);
412
+ assertRemoved(config.shareBase, 'Project share dir');
195
413
  p.log.success(`Project share dirs removed: ${config.shareBase}`);
196
414
  }
197
415
  }
@@ -202,14 +420,15 @@ async function rmPurge(config, tools) {
202
420
  const projectDir = path.join(base, config.project);
203
421
  if (fs.existsSync(projectDir)) {
204
422
  removeManagedDir(base, projectDir);
423
+ assertRemoved(projectDir, `Project ${label}`);
205
424
  p.log.success(`Removed project ${label}: ${projectDir}`);
206
425
  }
207
426
  }
208
- const shouldRemoveImage = await p.confirm({
427
+ const shouldRemoveImage = await confirm({
209
428
  message: `Remove image ${config.imageName}?`,
210
429
  initialValue: false
211
430
  });
212
- if (!p.isCancel(shouldRemoveImage) && shouldRemoveImage) {
431
+ if (!isCancel(shouldRemoveImage) && shouldRemoveImage) {
213
432
  runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
214
433
  }
215
434
  pruneSandboxDanglingImages(config, engine);
@@ -220,11 +439,11 @@ async function rmPurge(config, tools) {
220
439
  return;
221
440
  }
222
441
  const name = engineDisplayName(engine);
223
- const shouldStopVm = await p.confirm({
442
+ const shouldStopVm = await confirm({
224
443
  message: `Stop ${name} VM?`,
225
444
  initialValue: false
226
445
  });
227
- if (!p.isCancel(shouldStopVm) && shouldStopVm) {
446
+ if (!isCancel(shouldStopVm) && shouldStopVm) {
228
447
  stopManagedVm(config);
229
448
  }
230
449
  }
@@ -239,30 +458,35 @@ async function rmUnbound(config, tools, options) {
239
458
  p.outro('No removable sandboxes: every container is bound to an active task (or none exist)');
240
459
  return;
241
460
  }
461
+ const targets = removable.map((row) => resolveRmTarget(config, tools, row.branch));
462
+ const inspections = inspectWorktrees(targets.flatMap((target) => target.existingWorktrees));
463
+ const blockers = inspectionBlockers(inspections);
464
+ const permits = cleanPermits(inspections);
242
465
  for (const row of removable) {
243
466
  p.log.message(`${row.name} ${row.branch}`);
244
467
  }
468
+ if (blockers.length > 0)
469
+ p.log.error(blockerMessage(blockers));
245
470
  if (options.dryRun) {
246
- p.outro(`Dry run: ${removable.length} sandbox(es) would be removed, nothing deleted`);
471
+ p.outro(`Dry run: ${removable.length} sandbox(es) inspected, nothing deleted`);
247
472
  return;
248
473
  }
249
- if (!options.assumeYes) {
250
- if (!process.stdin.isTTY) {
251
- throw new Error('Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.');
252
- }
253
- const confirmed = await p.confirm({
254
- message: `Remove these ${removable.length} sandbox(es)?`,
255
- initialValue: false
256
- });
257
- if (p.isCancel(confirmed) || !confirmed) {
258
- p.outro('Cancelled');
259
- return;
260
- }
474
+ if (blockers.length > 0) {
475
+ throw new Error(`Refusing batch removal because worktree preflight found blocker(s):\n${blockerMessage(blockers)}`);
476
+ }
477
+ if (!options.assumeYes && !process.stdin.isTTY) {
478
+ throw new Error('Refusing to remove sandboxes without confirmation in a non-interactive shell; pass --yes to proceed.');
261
479
  }
262
480
  const failures = [];
263
- for (const row of removable) {
481
+ for (const [index, row] of removable.entries()) {
264
482
  try {
265
- await rmOne(config, tools, row.branch, { assumeYes: true, quiet: true });
483
+ await rmOne(config, tools, row.branch, {
484
+ assumeYes: options.assumeYes,
485
+ quiet: true,
486
+ target: targets[index],
487
+ permits,
488
+ allowDirtyDiscard: false
489
+ });
266
490
  }
267
491
  catch (error) {
268
492
  failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
@@ -283,26 +507,30 @@ export async function rm(args) {
283
507
  strict: true,
284
508
  options: {
285
509
  all: { type: 'boolean' },
510
+ unbound: { type: 'boolean' },
286
511
  purge: { type: 'boolean' },
287
512
  'dry-run': { type: 'boolean' },
288
513
  yes: { type: 'boolean', short: 'y' },
289
514
  help: { type: 'boolean', short: 'h' }
290
515
  }
291
516
  });
517
+ if (values.all) {
518
+ throw new Error('CLI_FLAG_REMOVED: --all was removed; use --unbound');
519
+ }
292
520
  if (values.help) {
293
521
  process.stdout.write(`${USAGE}\n`);
294
522
  return;
295
523
  }
296
- if (values.all && values.purge) {
297
- throw new Error('--all and --purge are mutually exclusive');
524
+ if (values.unbound && values.purge) {
525
+ throw new Error('--unbound and --purge are mutually exclusive');
298
526
  }
299
- if ((values['dry-run'] || values.yes) && !values.all) {
300
- throw new Error('--dry-run and --yes only apply to --all');
527
+ if ((values['dry-run'] || values.yes) && !values.unbound) {
528
+ throw new Error('--dry-run and --yes only apply to --unbound');
301
529
  }
302
- if ((values.all || values.purge) && positionals.length > 0) {
303
- throw new Error(`${values.all ? '--all' : '--purge'} does not take a branch argument`);
530
+ if ((values.unbound || values.purge) && positionals.length > 0) {
531
+ throw new Error(`${values.unbound ? '--unbound' : '--purge'} does not take a branch argument`);
304
532
  }
305
- if (!values.all && !values.purge && positionals.length !== 1) {
533
+ if (!values.unbound && !values.purge && positionals.length !== 1) {
306
534
  throw new Error(USAGE);
307
535
  }
308
536
  const config = loadConfig();
@@ -311,7 +539,7 @@ export async function rm(args) {
311
539
  await rmPurge(config, tools);
312
540
  return;
313
541
  }
314
- if (values.all) {
542
+ if (values.unbound) {
315
543
  await rmUnbound(config, tools, {
316
544
  dryRun: Boolean(values['dry-run']),
317
545
  assumeYes: Boolean(values.yes)
@@ -321,4 +549,5 @@ export async function rm(args) {
321
549
  const target = resolveSandboxTarget(positionals[0] ?? '', config.repoRoot);
322
550
  await rmOne(config, tools, target.branch);
323
551
  }
552
+ export { authorizeWorktrees, rmOne, rmPurge };
324
553
  //# sourceMappingURL=rm.js.map