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