@fitlab-ai/agent-infra 0.9.7 → 0.9.9

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 (195) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/bin/internal-cli.js +11 -1
  4. package/dist/lib/agent-clients/adapters/claude-code.js +3 -3
  5. package/dist/lib/agent-clients/adapters/codex-lifecycle/app-server.js +60 -22
  6. package/dist/lib/agent-clients/adapters/codex-lifecycle/build-identity.js +8 -1
  7. package/dist/lib/agent-clients/adapters/codex-lifecycle/capability-store.js +175 -11
  8. package/dist/lib/agent-clients/adapters/codex-lifecycle/controller-context.js +147 -0
  9. package/dist/lib/agent-clients/adapters/codex-lifecycle/evidence.js +14 -0
  10. package/dist/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.js +86 -69
  11. package/dist/lib/agent-clients/adapters/codex-lifecycle/store.js +2 -1
  12. package/dist/lib/decide.js +68 -14
  13. package/dist/lib/git/workflow.js +10 -1
  14. package/dist/lib/init.js +1 -1
  15. package/dist/lib/internal/codex-lifecycle.js +19 -5
  16. package/dist/lib/internal/codex-sandbox-controller.js +6 -13
  17. package/dist/lib/internal/git-workflow.js +5 -7
  18. package/dist/lib/internal/platform-pr.js +20 -7
  19. package/dist/lib/internal/release-workflow.js +3 -4
  20. package/dist/lib/internal/sandbox-control.js +79 -2
  21. package/dist/lib/internal/task-activity.js +57 -7
  22. package/dist/lib/internal/task-event.js +57 -4
  23. package/dist/lib/internal/task-finalization.js +54 -0
  24. package/dist/lib/internal/task-ledger.js +59 -5
  25. package/dist/lib/internal/task-lifecycle.js +59 -8
  26. package/dist/lib/internal/task-orchestration.js +161 -288
  27. package/dist/lib/internal/task-override.js +131 -0
  28. package/dist/lib/internal/task-review.js +64 -6
  29. package/dist/lib/internal/task-verify.js +65 -3
  30. package/dist/lib/internal/task-warning.js +57 -4
  31. package/dist/lib/platform/pr-summary.js +53 -13
  32. package/dist/lib/platform/pull-requests.js +205 -41
  33. package/dist/lib/runtime/agent-runtime.js +56 -0
  34. package/dist/lib/sandbox/commands/create.js +389 -311
  35. package/dist/lib/sandbox/commands/rm.js +316 -55
  36. package/dist/lib/sandbox/control/client.js +190 -6
  37. package/dist/lib/sandbox/control/container-identity.js +1 -1
  38. package/dist/lib/sandbox/control/controller-registration.js +273 -0
  39. package/dist/lib/sandbox/control/executor.js +145 -5
  40. package/dist/lib/sandbox/control/lifecycle.js +395 -73
  41. package/dist/lib/sandbox/control/protocol.js +67 -6
  42. package/dist/lib/sandbox/control/state.js +27 -11
  43. package/dist/lib/sandbox/engine.js +14 -7
  44. package/dist/lib/sandbox/index.js +5 -3
  45. package/dist/lib/sandbox/managed-fs.js +5 -0
  46. package/dist/lib/sandbox/mounts.js +20 -5
  47. package/dist/lib/sandbox/recovery.js +78 -10
  48. package/dist/lib/sandbox/task-resolver.js +50 -6
  49. package/dist/lib/sandbox/workspace-identity.js +27 -0
  50. package/dist/lib/sandbox/workspace-view.js +62 -40
  51. package/dist/lib/sandbox/worktree-safety.js +215 -2
  52. package/dist/lib/server/process-state.js +51 -1
  53. package/dist/lib/task/activity-intent.js +4 -1
  54. package/dist/lib/task/codex-orchestration.js +158 -33
  55. package/dist/lib/task/commands/status.js +59 -1
  56. package/dist/lib/task/commit-operation.js +335 -0
  57. package/dist/lib/task/commit-policy.js +18 -0
  58. package/dist/lib/task/decision-intents.js +4 -2
  59. package/dist/lib/task/delegation-receipts.js +279 -17
  60. package/dist/lib/task/events.js +9 -5
  61. package/dist/lib/task/finalization.js +567 -0
  62. package/dist/lib/task/guard-override.js +31 -0
  63. package/dist/lib/task/human-override.js +1240 -0
  64. package/dist/lib/task/ledger-intents.js +6 -3
  65. package/dist/lib/task/lifecycle.js +69 -14
  66. package/dist/lib/task/operation-outcome.js +33 -0
  67. package/dist/lib/task/orchestration.js +199 -719
  68. package/dist/lib/task/review-finalization.js +3 -2
  69. package/dist/lib/task/short-id.js +59 -1
  70. package/dist/lib/task/task-execution-lock.js +6 -1
  71. package/dist/lib/task/verification-engine.js +138 -59
  72. package/dist/lib/task/verification.js +15 -2
  73. package/dist/lib/task/workflow-warning-intents.js +54 -3
  74. package/dist/lib/task/write.js +2 -1
  75. package/dist/lifecycle-build-manifest.json +3 -3
  76. package/lib/agent-clients/adapters/claude-code.ts +3 -3
  77. package/lib/agent-clients/adapters/codex-lifecycle/app-server.ts +63 -20
  78. package/lib/agent-clients/adapters/codex-lifecycle/build-identity.ts +12 -0
  79. package/lib/agent-clients/adapters/codex-lifecycle/capability-store.ts +278 -13
  80. package/lib/agent-clients/adapters/codex-lifecycle/controller-context.ts +180 -0
  81. package/lib/agent-clients/adapters/codex-lifecycle/evidence.ts +19 -0
  82. package/lib/agent-clients/adapters/codex-lifecycle/manifest-files.json +9 -1
  83. package/lib/agent-clients/adapters/codex-lifecycle/sandbox-controller.ts +112 -93
  84. package/lib/agent-clients/adapters/codex-lifecycle/store.ts +2 -1
  85. package/lib/decide.ts +59 -14
  86. package/lib/git/workflow.ts +11 -2
  87. package/lib/init.ts +1 -1
  88. package/lib/internal/codex-lifecycle.ts +18 -5
  89. package/lib/internal/codex-sandbox-controller.ts +6 -12
  90. package/lib/internal/git-workflow.ts +5 -6
  91. package/lib/internal/platform-pr.ts +14 -7
  92. package/lib/internal/release-workflow.ts +3 -4
  93. package/lib/internal/sandbox-control.ts +92 -2
  94. package/lib/internal/task-activity.ts +46 -6
  95. package/lib/internal/task-event.ts +53 -4
  96. package/lib/internal/task-finalization.ts +68 -0
  97. package/lib/internal/task-ledger.ts +48 -4
  98. package/lib/internal/task-lifecycle.ts +56 -12
  99. package/lib/internal/task-orchestration.ts +32 -107
  100. package/lib/internal/task-override.ts +138 -0
  101. package/lib/internal/task-review.ts +51 -6
  102. package/lib/internal/task-verify.ts +52 -3
  103. package/lib/internal/task-warning.ts +44 -3
  104. package/lib/platform/pr-summary.ts +65 -14
  105. package/lib/platform/pull-requests.ts +211 -41
  106. package/lib/runtime/agent-runtime.ts +76 -0
  107. package/lib/sandbox/commands/create.ts +88 -4
  108. package/lib/sandbox/commands/rm.ts +413 -61
  109. package/lib/sandbox/config.ts +2 -1
  110. package/lib/sandbox/control/client.ts +255 -6
  111. package/lib/sandbox/control/container-identity.ts +3 -3
  112. package/lib/sandbox/control/controller-registration.ts +347 -0
  113. package/lib/sandbox/control/executor.ts +172 -6
  114. package/lib/sandbox/control/lifecycle.ts +465 -77
  115. package/lib/sandbox/control/protocol.ts +85 -9
  116. package/lib/sandbox/control/state.ts +35 -18
  117. package/lib/sandbox/engine.ts +20 -9
  118. package/lib/sandbox/index.ts +5 -3
  119. package/lib/sandbox/managed-fs.ts +5 -0
  120. package/lib/sandbox/mounts.ts +23 -5
  121. package/lib/sandbox/recovery.ts +90 -9
  122. package/lib/sandbox/task-resolver.ts +58 -7
  123. package/lib/sandbox/workspace-identity.ts +50 -2
  124. package/lib/sandbox/workspace-view.ts +73 -43
  125. package/lib/sandbox/worktree-safety.ts +236 -2
  126. package/lib/server/process-state.ts +48 -1
  127. package/lib/task/activity-intent.ts +4 -2
  128. package/lib/task/codex-orchestration.ts +167 -33
  129. package/lib/task/commands/status.ts +78 -1
  130. package/lib/task/commit-operation.ts +430 -0
  131. package/lib/task/commit-policy.ts +30 -0
  132. package/lib/task/decision-intents.ts +4 -2
  133. package/lib/task/delegation-receipts.ts +304 -19
  134. package/lib/task/events.ts +10 -5
  135. package/lib/task/finalization.ts +719 -0
  136. package/lib/task/guard-override.ts +50 -0
  137. package/lib/task/human-override.ts +1503 -0
  138. package/lib/task/ledger-intents.ts +6 -3
  139. package/lib/task/lifecycle.ts +71 -14
  140. package/lib/task/operation-outcome.ts +48 -0
  141. package/lib/task/orchestration.ts +239 -922
  142. package/lib/task/review-finalization.ts +6 -2
  143. package/lib/task/short-id.ts +65 -1
  144. package/lib/task/task-execution-lock.ts +10 -1
  145. package/lib/task/verification-engine.ts +145 -64
  146. package/lib/task/verification.ts +18 -3
  147. package/lib/task/workflow-warning-intents.ts +61 -4
  148. package/lib/task/write.ts +4 -1
  149. package/package.json +2 -2
  150. package/templates/.agents/QUICKSTART.en.md +2 -2
  151. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  152. package/templates/.agents/hooks/lifecycle-delegation.js +9 -24
  153. package/templates/.agents/rules/lifecycle-orchestration.en.md +4 -3
  154. package/templates/.agents/rules/lifecycle-orchestration.zh-CN.md +4 -3
  155. package/templates/.agents/rules/task-management.en.md +2 -2
  156. package/templates/.agents/rules/task-management.zh-CN.md +2 -2
  157. package/templates/.agents/skills/block-task/SKILL.en.md +2 -4
  158. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -4
  159. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -3
  160. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -3
  161. package/templates/.agents/skills/close-codescan/SKILL.en.md +6 -2
  162. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +6 -2
  163. package/templates/.agents/skills/close-dependabot/SKILL.en.md +6 -2
  164. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +6 -2
  165. package/templates/.agents/skills/commit/SKILL.en.md +41 -125
  166. package/templates/.agents/skills/commit/SKILL.zh-CN.md +52 -123
  167. package/templates/.agents/skills/commit/reference/commit-message.en.md +1 -1
  168. package/templates/.agents/skills/commit/reference/commit-message.zh-CN.md +1 -1
  169. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +14 -43
  170. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +14 -49
  171. package/templates/.agents/skills/commit/reference/pr-summary-sync.en.md +1 -1
  172. package/templates/.agents/skills/commit/reference/pr-summary-sync.zh-CN.md +1 -1
  173. package/templates/.agents/skills/commit/reference/task-status-update.en.md +2 -35
  174. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +2 -35
  175. package/templates/.agents/skills/complete-task/SKILL.en.md +31 -38
  176. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +31 -38
  177. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  178. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  179. package/templates/.agents/skills/complete-task/reference/external-delivery.en.md +1 -1
  180. package/templates/.agents/skills/complete-task/reference/external-delivery.zh-CN.md +1 -1
  181. package/templates/.agents/skills/create-pr/SKILL.en.md +6 -4
  182. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +6 -4
  183. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -2
  184. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -2
  185. package/templates/.agents/skills/run-task/SKILL.en.md +5 -5
  186. package/templates/.agents/skills/run-task/SKILL.zh-CN.md +5 -5
  187. package/templates/.agents/skills/run-task/reference/host-validation.en.md +3 -1
  188. package/templates/.agents/skills/run-task/reference/host-validation.zh-CN.md +3 -1
  189. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +1 -1
  190. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +1 -1
  191. package/templates/.codex/hooks.json +4 -4
  192. package/dist/lib/task/commit-finalization.js +0 -305
  193. package/dist/lib/task/commit-intent.js +0 -165
  194. package/lib/task/commit-finalization.ts +0 -419
  195. package/lib/task/commit-intent.ts +0 -230
@@ -10,6 +10,8 @@ import {
10
10
  containerNameCandidates,
11
11
  sandboxBranchLabel,
12
12
  sandboxLabel,
13
+ sandboxTaskIdLabel,
14
+ sandboxWorkspaceModeLabel,
13
15
  shareBranchDir,
14
16
  shellConfigDirCandidates,
15
17
  worktreeDirCandidates
@@ -18,27 +20,37 @@ import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedV
18
20
  import { pruneSandboxDanglingImages } from '../image-prune.ts';
19
21
  import { assertManagedPath, removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
20
22
  import { runEngine, runOk, runOkEngine, runSafe, runSafeEngine } from '../shell.ts';
21
- import { resolveSandboxTarget, type SandboxWorkspaceIdentity } from '../workspace-identity.ts';
23
+ import {
24
+ parseSandboxWorkspaceIdentity,
25
+ resolveSandboxCleanupTarget,
26
+ sameSandboxWorkspaceIdentity,
27
+ type SandboxCleanupTarget,
28
+ type SandboxWorkspaceKey
29
+ } from '../workspace-identity.ts';
22
30
  import { sandboxControlPaths, sandboxWorkspaceViewPaths } from '../workspace-view.ts';
23
31
  import { removeSandboxControlRoot, readSandboxControlManifest } from '../control/lifecycle.ts';
24
32
  import { inspectSandboxControlContainer } from '../control/container-identity.ts';
25
33
  import { toolConfigDirCandidates, toolProjectDirCandidates } from '../tools.ts';
26
34
  import { createSandboxCapabilityPlan } from '../agent-client-reconciler.ts';
27
35
  import type { SandboxTool } from '../tools.ts';
28
- import { fetchSandboxRows } from './list-running.ts';
29
- import { lookupShortIdByBranch } from '../../task/short-id.ts';
36
+ import { fetchSandboxRows, type SandboxRow } from './list-running.ts';
30
37
  import {
31
38
  createCleanPermit,
32
39
  createDiscardPermit,
33
40
  formatWorktreeSnapshot,
41
+ inspectRecoveredWorktree,
34
42
  inspectWorktrees,
35
43
  verifyWorktreePermit
36
44
  } from '../worktree-safety.ts';
37
- import type { WorktreeInspection, WorktreeRemovalPermit } from '../worktree-safety.ts';
45
+ import type {
46
+ WorktreeInspection,
47
+ WorktreeRecoveryContext,
48
+ WorktreeRemovalPermit
49
+ } from '../worktree-safety.ts';
38
50
 
39
51
  const USAGE = `Usage:
40
- ai sandbox rm <branch> Remove one sandbox (branch | TASK-id | short id)
41
- ai sandbox rm --unbound [--dry-run] [--yes] Remove every sandbox not bound to an active task
52
+ ai sandbox rm <branch | TASK-id | short id> Remove one sandbox; use a full TASK-id for a task-bound sandbox and a branch for branch-only sandboxes
53
+ ai sandbox rm --unbound [--dry-run] [--yes] Remove completed task-bound and branch-only sandboxes; active, blocked, and archive tasks are protected
42
54
  ai sandbox rm --purge Tear down ALL sandboxes for the project (containers, worktrees, image, VM)`;
43
55
  export { assertManagedPath } from '../managed-fs.ts';
44
56
 
@@ -46,6 +58,30 @@ function projectToolDirs(config: SandboxConfig, tools: SandboxTool[]): string[]
46
58
  return tools.flatMap((tool) => toolProjectDirCandidates(tool, config.project));
47
59
  }
48
60
 
61
+ function isMissingPathError(error: unknown): boolean {
62
+ return Boolean(error && typeof error === 'object' && 'code' in error
63
+ && (error as { code?: unknown }).code === 'ENOENT');
64
+ }
65
+
66
+ export function sandboxManagedPathKey(
67
+ candidate: string,
68
+ platform: NodeJS.Platform = process.platform,
69
+ resolveExistingPath: (resolved: string) => string = (resolved) => fs.realpathSync.native(resolved)
70
+ ): string {
71
+ const resolved = path.resolve(candidate);
72
+ let identity = resolved;
73
+ try {
74
+ identity = resolveExistingPath(resolved);
75
+ } catch (error) {
76
+ if (!isMissingPathError(error)) {
77
+ throw new Error(
78
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: unable to determine managed path identity for '${resolved}'`
79
+ );
80
+ }
81
+ }
82
+ return platform === 'win32' ? identity.toLowerCase() : identity;
83
+ }
84
+
49
85
  type RmTarget = {
50
86
  branch: string;
51
87
  effectiveBranch: string;
@@ -53,15 +89,29 @@ type RmTarget = {
53
89
  matchedContainers: string[];
54
90
  existingWorktrees: string[];
55
91
  toolCandidates: Array<{ tool: SandboxTool; candidates: string[] }>;
56
- workspace: SandboxWorkspaceIdentity;
92
+ managedPathCandidates?: string[];
93
+ workspace: SandboxWorkspaceKey;
57
94
  controlRoots: string[];
58
95
  workspaceViewRoots: string[];
59
96
  };
60
97
 
98
+ type CleanupCandidate = Readonly<{
99
+ row: SandboxRow;
100
+ cleanupTarget: SandboxCleanupTarget;
101
+ }>;
102
+
103
+ type CleanupGroup = Readonly<{
104
+ candidates: readonly CleanupCandidate[];
105
+ cleanupTarget: SandboxCleanupTarget;
106
+ target: RmTarget;
107
+ }>;
108
+
61
109
  type RmOneOptions = {
62
110
  assumeYes?: boolean;
111
+ interactive?: boolean;
63
112
  quiet?: boolean;
64
113
  target?: RmTarget;
114
+ cleanupTarget?: SandboxCleanupTarget;
65
115
  permits?: ReadonlyMap<string, WorktreeRemovalPermit>;
66
116
  allowDirtyDiscard?: boolean;
67
117
  prompt?: PromptDependencies;
@@ -72,46 +122,81 @@ type PromptDependencies = {
72
122
  isCancel?: typeof p.isCancel;
73
123
  };
74
124
 
75
- function resolveRmTarget(config: SandboxConfig, tools: SandboxTool[], branch: string): RmTarget {
76
- assertValidBranchName(branch);
125
+ function recoveryContexts(
126
+ config: SandboxConfig,
127
+ target: RmTarget,
128
+ worktrees: readonly string[]
129
+ ): Map<string, WorktreeRecoveryContext> {
130
+ const candidates = worktreeDirCandidates(config, target.effectiveBranch).map((candidate) => path.resolve(candidate));
131
+ const existingCandidates = candidates.filter((candidate) => fs.existsSync(candidate));
132
+ if (existingCandidates.length !== 1) return new Map();
133
+
134
+ const contexts = new Map<string, WorktreeRecoveryContext>();
135
+ for (const worktree of worktrees) {
136
+ const resolvedWorktree = path.resolve(worktree);
137
+ if (resolvedWorktree !== existingCandidates[0]) continue;
138
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
139
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
140
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
141
+ }
142
+ contexts.set(resolvedWorktree, {
143
+ repoRoot: config.repoRoot,
144
+ worktreeBase: config.worktreeBase,
145
+ branch: target.effectiveBranch,
146
+ identitySource: target.workspace.mode,
147
+ taskId: target.workspace.mode === 'task-bound' ? target.workspace.taskId : null
148
+ });
149
+ }
150
+ return contexts;
151
+ }
152
+
153
+ function resolveRmTarget(
154
+ config: SandboxConfig,
155
+ tools: SandboxTool[],
156
+ cleanupTarget: SandboxCleanupTarget,
157
+ options: Readonly<{ discoveredContainers?: readonly string[] }> = {}
158
+ ): RmTarget {
159
+ assertValidBranchName(cleanupTarget.branch);
77
160
  const engine = detectEngine(config);
78
- let effectiveBranch = branch;
79
- let worktreeCandidates = worktreeDirCandidates(config, branch);
80
- let toolCandidates = tools.map((tool) => ({
161
+ const branch = cleanupTarget.branch;
162
+ const effectiveBranch = branch;
163
+ const worktreeCandidates = worktreeDirCandidates(config, branch);
164
+ const toolCandidates = tools.map((tool) => ({
81
165
  tool,
82
166
  candidates: toolConfigDirCandidates(tool, config.project, branch)
83
167
  }));
168
+ const shellCandidates = shellConfigDirCandidates(config, branch);
84
169
  const existing = runEngine(engine, 'docker', ['ps', '-a', '--format', '{{.Names}}']).split('\n').filter(Boolean);
85
- const matchedContainers = containerNameCandidates(config, branch).filter((name) => existing.includes(name));
86
-
87
- if (matchedContainers.length > 0) {
88
- const resolvedBranch = runEngine(engine, 'docker', [
89
- 'inspect',
90
- '-f',
91
- `{{ index .Config.Labels "${sandboxBranchLabel(config)}" }}`,
92
- matchedContainers[0] ?? ''
93
- ]);
94
- if (resolvedBranch) {
95
- effectiveBranch = resolvedBranch;
96
- worktreeCandidates = worktreeDirCandidates(config, effectiveBranch);
97
- toolCandidates = tools.map((tool) => ({
98
- tool,
99
- candidates: toolConfigDirCandidates(tool, config.project, effectiveBranch)
100
- }));
101
- }
102
- }
103
-
104
- const workspace = resolveSandboxTarget(effectiveBranch, config.repoRoot).workspace;
105
- const identities: SandboxWorkspaceIdentity[] = workspace.mode === 'task-bound'
106
- ? [workspace, { mode: 'branch-only' }]
107
- : [workspace];
108
- const containers = [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
170
+ const matchedContainers = options.discoveredContainers
171
+ ? options.discoveredContainers.map((container) => {
172
+ if (!existing.includes(container)) {
173
+ throw new Error(
174
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: discovered container '${container}' is no longer present`
175
+ );
176
+ }
177
+ return container;
178
+ })
179
+ : containerNameCandidates(config, branch).filter((name) => existing.includes(name));
180
+
181
+ const workspace = cleanupTarget.workspace;
182
+ const identities: SandboxWorkspaceKey[] = [workspace];
183
+ const containers = options.discoveredContainers
184
+ ? matchedContainers
185
+ : [...new Set([...containerNameCandidates(config, effectiveBranch), ...matchedContainers])];
109
186
  const controlRoots = containers.flatMap((container) => identities.map((identity) => sandboxControlPaths({
110
187
  base: config.controlBase, project: config.project, container, identity
111
188
  }).root));
112
189
  const workspaceViewRoots = containers.flatMap((container) => identities.map((identity) => sandboxWorkspaceViewPaths({
113
190
  base: config.workspaceViewBase, project: config.project, container, identity
114
191
  }).root));
192
+ const managedPathCandidates = [...new Set([
193
+ ...worktreeCandidates,
194
+ ...toolCandidates.flatMap(({ candidates }) => candidates),
195
+ ...shellCandidates,
196
+ shareBranchDir(config, branch),
197
+ ...controlRoots,
198
+ ...workspaceViewRoots
199
+ ].map((candidate) => path.resolve(candidate)))];
115
200
 
116
201
  return {
117
202
  branch,
@@ -120,25 +205,184 @@ function resolveRmTarget(config: SandboxConfig, tools: SandboxTool[], branch: st
120
205
  matchedContainers,
121
206
  existingWorktrees: worktreeCandidates.filter((candidate) => fs.existsSync(candidate)),
122
207
  toolCandidates,
208
+ managedPathCandidates,
123
209
  workspace,
124
210
  controlRoots: [...new Set(controlRoots)],
125
211
  workspaceViewRoots: [...new Set(workspaceViewRoots)]
126
212
  };
127
213
  }
128
214
 
129
- function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceIdentity): void {
215
+ type CurrentContainerIdentity = Readonly<{ id: string; labels: Record<string, string> }>;
216
+
217
+ function inspectNamedContainerIdentity(target: RmTarget, container: string): CurrentContainerIdentity {
218
+ const raw = runSafeEngine(target.engine, 'docker', [
219
+ 'inspect', '--format', '{{json .}}', container
220
+ ]).trim();
221
+ let parsed: unknown;
222
+ try {
223
+ parsed = JSON.parse(raw);
224
+ } catch {
225
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is invalid`);
226
+ }
227
+ const value = Array.isArray(parsed) && parsed.length === 1 ? parsed[0] : parsed;
228
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
229
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
230
+ }
231
+ const candidate = value as {
232
+ Id?: unknown;
233
+ Config?: { Labels?: unknown };
234
+ };
235
+ if (typeof candidate.Id !== 'string' || !candidate.Id
236
+ || !candidate.Config?.Labels || typeof candidate.Config.Labels !== 'object'
237
+ || Array.isArray(candidate.Config.Labels)
238
+ || Object.values(candidate.Config.Labels).some((value) => typeof value !== 'string')) {
239
+ throw new Error(`SANDBOX_WORKSPACE_IDENTITY_UNKNOWN: container '${container}' inspection is incomplete`);
240
+ }
241
+ return { id: candidate.Id, labels: candidate.Config.Labels as Record<string, string> };
242
+ }
243
+
244
+ function assertMatchedContainerIdentities(config: SandboxConfig, target: RmTarget): Map<string, CurrentContainerIdentity> {
245
+ const current = new Map<string, CurrentContainerIdentity>();
246
+ for (const container of target.matchedContainers) {
247
+ const observation = inspectNamedContainerIdentity(target, container);
248
+ const stringLabels = observation.labels;
249
+ const branch = stringLabels[sandboxBranchLabel(config)];
250
+ if (branch !== target.branch) {
251
+ throw new Error(
252
+ `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' branch is ${JSON.stringify(branch ?? null)}, `
253
+ + `but this request is ${JSON.stringify(target.branch)}`
254
+ );
255
+ }
256
+ const identity = parseSandboxWorkspaceIdentity(stringLabels, {
257
+ mode: sandboxWorkspaceModeLabel(config),
258
+ taskId: sandboxTaskIdLabel(config)
259
+ });
260
+ if (identity.mode === 'legacy-invalid' || !sameSandboxWorkspaceIdentity(identity, target.workspace)) {
261
+ const existingDescription = identity.mode === 'task-bound'
262
+ ? `task-bound:${identity.taskId}`
263
+ : identity.mode;
264
+ const requestedDescription = target.workspace.mode === 'task-bound'
265
+ ? `task-bound:${target.workspace.taskId}`
266
+ : target.workspace.mode;
267
+ throw new Error(
268
+ `SANDBOX_WORKSPACE_IDENTITY_CONFLICT: container '${container}' is ${existingDescription}, `
269
+ + `but this request is ${requestedDescription}`
270
+ );
271
+ }
272
+ current.set(container, observation);
273
+ }
274
+ return current;
275
+ }
276
+
277
+ function controlRootContainer(config: SandboxConfig, root: string): string {
278
+ const relative = path.relative(path.join(config.controlBase, config.project), root);
279
+ const [container, identity, ...extra] = relative.split(path.sep);
280
+ if (!container || !identity || extra.length > 0) {
281
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_INVALID: ${root}`);
282
+ }
283
+ return container;
284
+ }
285
+
286
+ function assertControlRootMatchesTarget(root: string, effectiveBranch: string, workspace: SandboxWorkspaceKey): void {
130
287
  const manifestPath = path.join(root, 'manifest.json');
131
- if (!fs.existsSync(manifestPath)) return;
288
+ if (!fs.existsSync(manifestPath)) {
289
+ if (workspace.mode === 'task-bound') {
290
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: ${root}`);
291
+ }
292
+ return;
293
+ }
132
294
  const manifest = readSandboxControlManifest(manifestPath);
133
295
  const identityMatches = workspace.mode === 'task-bound'
134
296
  ? manifest.mode === 'task-bound' && manifest.taskId === workspace.taskId
135
297
  : manifest.mode === 'branch-only';
136
- const branchOnlyFallback = workspace.mode === 'task-bound' && manifest.mode === 'branch-only';
137
- if (manifest.branch !== effectiveBranch || (!identityMatches && !branchOnlyFallback)) {
298
+ if (manifest.branch !== effectiveBranch || !identityMatches) {
138
299
  throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
139
300
  }
140
301
  }
141
302
 
303
+ function preflightRmTarget(
304
+ config: SandboxConfig,
305
+ target: RmTarget
306
+ ): void {
307
+ const current = assertMatchedContainerIdentities(config, target);
308
+ const manifests: ReturnType<typeof readSandboxControlManifest>[] = [];
309
+ for (const root of target.controlRoots.filter((candidate) => fs.existsSync(candidate))) {
310
+ assertLegacyCandidateEvidence(root, config.controlBase, config, target.effectiveBranch, target.matchedContainers);
311
+ assertControlRootMatchesTarget(root, target.effectiveBranch, target.workspace);
312
+ const manifestPath = path.join(root, 'manifest.json');
313
+ if (!fs.existsSync(manifestPath)) continue;
314
+ const manifest = readSandboxControlManifest(manifestPath);
315
+ const rootContainer = controlRootContainer(config, root);
316
+ if (manifest.container !== rootContainer) {
317
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
318
+ }
319
+ const observed = current.get(manifest.container);
320
+ if (observed && (observed.id !== manifest.containerIdentity.id
321
+ || Object.entries(manifest.containerIdentity.labels).some(([key, value]) => observed.labels[key] !== value))) {
322
+ throw new Error(`SANDBOX_CONTROL_TARGET_MISMATCH: ${root}`);
323
+ }
324
+ manifests.push(manifest);
325
+ }
326
+
327
+ if (target.workspace.mode !== 'task-bound') return;
328
+ const matched = new Set(target.matchedContainers);
329
+ const manifestContainers = manifests.map((manifest) => manifest.container);
330
+ if (matched.size > 0 && manifestContainers.some((container) => !matched.has(container))) {
331
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
332
+ }
333
+ for (const container of matched) {
334
+ const matches = manifests.filter((manifest) => manifest.container === container);
335
+ if (matches.length !== 1) {
336
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${container}'`);
337
+ }
338
+ }
339
+ if (matched.size === 0 && manifests.length > 0) {
340
+ const supported = new Set(containerNameCandidates(config, target.effectiveBranch));
341
+ if (manifests.some((manifest) => !supported.has(manifest.container))) {
342
+ throw new Error('SANDBOX_CONTROL_TARGET_MISMATCH: manifest container is outside the cleanup target');
343
+ }
344
+ }
345
+ }
346
+
347
+ function assertCleanupGroupPathsDoNotOverlap(groups: readonly CleanupGroup[]): void {
348
+ const owners = new Map<string, string>();
349
+ for (const group of groups) {
350
+ for (const candidate of group.target.managedPathCandidates ?? []) {
351
+ const key = sandboxManagedPathKey(candidate);
352
+ const existingBranch = owners.get(key);
353
+ if (existingBranch && existingBranch !== group.cleanupTarget.branch) {
354
+ throw new Error(
355
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: managed path '${candidate}' is shared by branches `
356
+ + `${JSON.stringify(existingBranch)} and ${JSON.stringify(group.cleanupTarget.branch)}`
357
+ );
358
+ }
359
+ owners.set(key, group.cleanupTarget.branch);
360
+ }
361
+ }
362
+ }
363
+
364
+ function assertWorktreeBranchesMatchGroups(
365
+ groups: readonly CleanupGroup[],
366
+ inspections: readonly WorktreeInspection[]
367
+ ): void {
368
+ const byPath = new Map(
369
+ inspections.flatMap((inspection) => inspection.status === 'failed'
370
+ ? []
371
+ : [[sandboxManagedPathKey(inspection.snapshot.worktree), inspection.snapshot.branch] as const])
372
+ );
373
+ for (const group of groups) {
374
+ for (const worktree of group.target.existingWorktrees) {
375
+ const registeredBranch = byPath.get(sandboxManagedPathKey(worktree));
376
+ if (registeredBranch && registeredBranch !== group.cleanupTarget.branch) {
377
+ throw new Error(
378
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: worktree '${worktree}' is registered to branch `
379
+ + `${JSON.stringify(registeredBranch)}, but cleanup targets ${JSON.stringify(group.cleanupTarget.branch)}`
380
+ );
381
+ }
382
+ }
383
+ }
384
+ }
385
+
142
386
  function assertLegacyCandidateEvidence(
143
387
  candidate: string,
144
388
  base: string,
@@ -184,7 +428,12 @@ async function removeExactSandboxContainer(
184
428
  const before = await inspect();
185
429
  if (before.state === 'unknown') throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${before.reason}`);
186
430
  if (before.state === 'absent') return;
187
- if (before.running && !runOkEngine(engine, 'docker', ['stop', manifest.containerIdentity.id], { timeout: remaining() })) {
431
+ if (before.running && !runOkEngine(
432
+ engine,
433
+ 'docker',
434
+ ['stop', '--timeout', '1', manifest.containerIdentity.id],
435
+ { timeout: remaining() }
436
+ )) {
188
437
  const afterStop = await inspect();
189
438
  if (afterStop.state !== 'absent' && afterStop.state !== 'found') {
190
439
  throw new Error(`SANDBOX_CONTROL_CONTAINER_UNKNOWN: ${afterStop.reason}`);
@@ -254,10 +503,17 @@ async function authorizeWorktrees(
254
503
  {
255
504
  interactive = Boolean(process.stdin.isTTY),
256
505
  confirm = p.confirm,
257
- isCancel = p.isCancel
258
- }: PromptDependencies & { interactive?: boolean } = {}
506
+ isCancel = p.isCancel,
507
+ recovery = new Map<string, WorktreeRecoveryContext>()
508
+ }: PromptDependencies & { interactive?: boolean; recovery?: ReadonlyMap<string, WorktreeRecoveryContext> } = {}
259
509
  ): Promise<Map<string, WorktreeRemovalPermit>> {
260
- const inspections = inspectWorktrees(worktrees);
510
+ const inspections = worktrees.map((worktree) => {
511
+ const inspection = inspectWorktrees([worktree])[0]!;
512
+ const context = recovery.get(path.resolve(worktree));
513
+ return inspection.status === 'failed' && context
514
+ ? inspectRecoveredWorktree(worktree, context)
515
+ : inspection;
516
+ });
261
517
  const failures = inspections.filter((inspection) => inspection.status === 'failed');
262
518
  if (failures.length > 0) throw new Error(`Unable to inspect worktree(s):\n${blockerMessage(failures)}`);
263
519
  const permits = cleanPermits(inspections);
@@ -283,9 +539,14 @@ async function rmOne(
283
539
  branch: string,
284
540
  options: RmOneOptions = {}
285
541
  ): Promise<void> {
286
- const target = options.target ?? resolveRmTarget(config, tools, branch);
542
+ const target = options.target ?? resolveRmTarget(
543
+ config,
544
+ tools,
545
+ options.cleanupTarget ?? resolveSandboxCleanupTarget(branch, config.repoRoot)
546
+ );
287
547
  const { effectiveBranch, engine, matchedContainers, existingWorktrees, toolCandidates } = target;
288
548
  const { workspace, controlRoots, workspaceViewRoots } = target;
549
+ preflightRmTarget(config, target);
289
550
  const confirm = options.prompt?.confirm ?? p.confirm;
290
551
  const isCancel = options.prompt?.isCancel ?? p.isCancel;
291
552
 
@@ -293,12 +554,19 @@ async function rmOne(
293
554
  p.intro(pc.cyan(`Removing sandbox for ${branch}`));
294
555
  }
295
556
 
557
+ const recovery = options.permits
558
+ ? new Map<string, WorktreeRecoveryContext>()
559
+ : recoveryContexts(config, target, existingWorktrees);
296
560
  const permits = options.permits
297
561
  ? new Map(options.permits)
298
562
  : await authorizeWorktrees(existingWorktrees, {
299
563
  allowDirtyDiscard: options.allowDirtyDiscard ?? true,
300
564
  assumeYes: Boolean(options.assumeYes)
301
- }, options.prompt);
565
+ }, {
566
+ ...options.prompt,
567
+ interactive: options.interactive ?? Boolean(process.stdin.isTTY),
568
+ recovery
569
+ });
302
570
  for (const worktree of existingWorktrees) {
303
571
  const permit = permits.get(path.resolve(worktree));
304
572
  if (!permit) throw new Error(`Missing worktree removal permit: ${worktree}`);
@@ -318,20 +586,27 @@ async function rmOne(
318
586
  return;
319
587
  }
320
588
 
589
+ preflightRmTarget(config, target);
321
590
  const coordinatedContainers = new Set<string>();
322
591
  for (const root of controlRoots.filter((candidate) => fs.existsSync(candidate))) {
323
592
  assertLegacyCandidateEvidence(root, config.controlBase, config, effectiveBranch, matchedContainers);
324
593
  assertControlRootMatchesTarget(root, effectiveBranch, workspace);
325
- const manifest = readSandboxControlManifest(path.join(root, 'manifest.json'));
594
+ const manifestPath = path.join(root, 'manifest.json');
595
+ if (!fs.existsSync(manifestPath)) continue;
596
+ const manifest = readSandboxControlManifest(manifestPath);
326
597
  coordinatedContainers.add(manifest.container);
327
598
  await removeSandboxControlRoot(root, {
328
599
  inspectContainer: (timeoutMs) => inspectSandboxControlContainer(manifest, { timeoutMs }),
329
600
  removeContainer: (timeoutMs) => removeExactSandboxContainer(engine, manifest, timeoutMs)
330
601
  });
602
+ removeEmptyManagedParent(path.join(config.controlBase, config.project), root);
331
603
  }
332
604
 
333
605
  const uncoordinatedContainers = matchedContainers.filter((name) => !coordinatedContainers.has(name));
334
606
  if (uncoordinatedContainers.length > 0) {
607
+ if (workspace.mode === 'task-bound') {
608
+ throw new Error(`SANDBOX_CONTROL_TARGET_EVIDENCE_MISSING: container '${uncoordinatedContainers[0]}'`);
609
+ }
335
610
  const spinner = p.spinner();
336
611
  spinner.start(`Stopping container(s): ${uncoordinatedContainers.join(', ')}`);
337
612
  for (const name of uncoordinatedContainers) {
@@ -583,24 +858,98 @@ async function rmUnbound(
583
858
  options: { dryRun: boolean; assumeYes: boolean }
584
859
  ): Promise<void> {
585
860
  const engine = detectEngine(config);
586
- const { running, nonRunning } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
587
- const removable = [...running, ...nonRunning].filter(
588
- (row) => row.branch && lookupShortIdByBranch(row.branch, config.repoRoot) === null
861
+ const { running, nonRunning } = fetchSandboxRows(
862
+ engine,
863
+ sandboxLabel(config),
864
+ sandboxBranchLabel(config),
865
+ { mode: sandboxWorkspaceModeLabel(config), taskId: sandboxTaskIdLabel(config) }
589
866
  );
867
+ const rows = [...running, ...nonRunning];
590
868
 
591
869
  p.intro(pc.cyan(`Removing sandboxes not bound to an active task for ${config.project}`));
592
870
 
593
- if (removable.length === 0) {
871
+ if (rows.length === 0) {
594
872
  p.outro('No removable sandboxes: every container is bound to an active task (or none exist)');
595
873
  return;
596
874
  }
597
875
 
598
- const targets = removable.map((row) => resolveRmTarget(config, tools, row.branch));
599
- const inspections = inspectWorktrees(targets.flatMap((target) => target.existingWorktrees));
876
+ const candidates: CleanupCandidate[] = rows.map((row) => {
877
+ if (!row.branch || !row.workspaceMode || row.workspaceMode === 'legacy-invalid') {
878
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has incomplete workspace identity`);
879
+ }
880
+ if (row.workspaceMode === 'task-bound') {
881
+ if (!row.taskId) {
882
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' has no task id`);
883
+ }
884
+ const cleanupTarget = resolveSandboxCleanupTarget(row.taskId, config.repoRoot, { allowProtected: true });
885
+ if (cleanupTarget.branch !== row.branch
886
+ || cleanupTarget.workspace.mode !== 'task-bound'
887
+ || cleanupTarget.workspace.taskId !== row.taskId) {
888
+ throw new Error(`SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: container '${row.name}' task identity conflicts with task.md`);
889
+ }
890
+ return { row, cleanupTarget };
891
+ }
892
+ return {
893
+ row,
894
+ cleanupTarget: {
895
+ requestedRef: row.branch,
896
+ branch: row.branch,
897
+ workspace: { mode: 'branch-only' },
898
+ taskState: 'branch-only'
899
+ } satisfies SandboxCleanupTarget
900
+ };
901
+ });
902
+ const branchIdentities = new Map<string, string>();
903
+ const groupedCandidates = new Map<string, CleanupCandidate[]>();
904
+ for (const candidate of candidates) {
905
+ const identity = candidate.cleanupTarget.workspace.mode === 'task-bound'
906
+ ? `task-bound:${candidate.cleanupTarget.workspace.taskId}`
907
+ : 'branch-only';
908
+ const existingIdentity = branchIdentities.get(candidate.cleanupTarget.branch);
909
+ if (existingIdentity && existingIdentity !== identity) {
910
+ throw new Error(
911
+ `SANDBOX_CLEANUP_BATCH_PREFLIGHT_FAILED: branch '${candidate.cleanupTarget.branch}' has conflicting workspace identities`
912
+ );
913
+ }
914
+ branchIdentities.set(candidate.cleanupTarget.branch, identity);
915
+ const groupKey = `${candidate.cleanupTarget.branch}\0${identity}`;
916
+ const group = groupedCandidates.get(groupKey) ?? [];
917
+ group.push(candidate);
918
+ groupedCandidates.set(groupKey, group);
919
+ }
920
+ const groups: CleanupGroup[] = [...groupedCandidates.values()].map((groupCandidates) => {
921
+ const cleanupTarget = groupCandidates[0]!.cleanupTarget;
922
+ const target = resolveRmTarget(config, tools, cleanupTarget, {
923
+ discoveredContainers: groupCandidates.map(({ row }) => row.name)
924
+ });
925
+ preflightRmTarget(config, target);
926
+ return { candidates: groupCandidates, cleanupTarget, target };
927
+ });
928
+ assertCleanupGroupPathsDoNotOverlap(groups);
929
+
930
+ const removableGroups = groups.filter(({ cleanupTarget }) =>
931
+ cleanupTarget.taskState === 'completed' || cleanupTarget.taskState === 'branch-only'
932
+ );
933
+ const removable = removableGroups.flatMap(({ candidates: groupCandidates }) => groupCandidates);
934
+ for (const { candidates: groupCandidates, cleanupTarget } of groups.filter(({ cleanupTarget }) =>
935
+ cleanupTarget.taskState !== 'completed' && cleanupTarget.taskState !== 'branch-only'
936
+ )) {
937
+ for (const { row } of groupCandidates) {
938
+ p.log.message(`Skipped protected sandbox ${row.name} (${cleanupTarget.taskState})`);
939
+ }
940
+ }
941
+
942
+ if (removableGroups.length === 0) {
943
+ p.outro('No removable sandboxes: every container is bound to a protected task (or none exist)');
944
+ return;
945
+ }
946
+
947
+ const inspections = inspectWorktrees(removableGroups.flatMap(({ target }) => target.existingWorktrees));
948
+ assertWorktreeBranchesMatchGroups(removableGroups, inspections);
600
949
  const blockers = inspectionBlockers(inspections);
601
950
  const permits = cleanPermits(inspections);
602
951
 
603
- for (const row of removable) {
952
+ for (const { row } of removable) {
604
953
  p.log.message(`${row.name} ${row.branch}`);
605
954
  }
606
955
  if (blockers.length > 0) p.log.error(blockerMessage(blockers));
@@ -621,17 +970,20 @@ async function rmUnbound(
621
970
  }
622
971
 
623
972
  const failures: { branch: string; message: string }[] = [];
624
- for (const [index, row] of removable.entries()) {
973
+ let failedRows = 0;
974
+ for (const group of removableGroups) {
625
975
  try {
626
- await rmOne(config, tools, row.branch, {
976
+ await rmOne(config, tools, group.cleanupTarget.branch, {
627
977
  assumeYes: options.assumeYes,
628
978
  quiet: true,
629
- target: targets[index],
979
+ target: group.target,
980
+ cleanupTarget: group.cleanupTarget,
630
981
  permits,
631
982
  allowDirtyDiscard: false
632
983
  });
633
984
  } catch (error) {
634
- failures.push({ branch: row.branch, message: error instanceof Error ? error.message : String(error) });
985
+ failedRows += group.candidates.length;
986
+ failures.push({ branch: group.cleanupTarget.branch, message: error instanceof Error ? error.message : String(error) });
635
987
  }
636
988
  }
637
989
 
@@ -640,7 +992,7 @@ async function rmUnbound(
640
992
  p.log.error(`Failed to remove '${failure.branch}': ${failure.message}`);
641
993
  }
642
994
  throw new Error(
643
- `Removed ${removable.length - failures.length}/${removable.length} sandbox(es); ${failures.length} failed`
995
+ `Removed ${removable.length - failedRows}/${removable.length} sandbox(es); ${failures.length} failed`
644
996
  );
645
997
  }
646
998
 
@@ -703,8 +1055,8 @@ export async function rm(args: string[]): Promise<void> {
703
1055
  return;
704
1056
  }
705
1057
 
706
- const target = resolveSandboxTarget(positionals[0] ?? '', config.repoRoot);
707
- await rmOne(config, tools, target.branch);
1058
+ const cleanupTarget = resolveSandboxCleanupTarget(positionals[0] ?? '', config.repoRoot);
1059
+ await rmOne(config, tools, cleanupTarget.branch, { cleanupTarget });
708
1060
  }
709
1061
 
710
1062
  export { authorizeWorktrees, rmOne, rmPurge };
@@ -7,6 +7,7 @@ import { normalizeAgentClients } from '../agent-clients/config.ts';
7
7
  import { isAgentClientId } from '../agent-clients/types.ts';
8
8
  import type { AgentClientState } from '../agent-clients/types.ts';
9
9
  import { validateSandboxEngine } from './engine.ts';
10
+ import type { SandboxEngineInput } from './engine.ts';
10
11
  import { hostJoin } from './engines/wsl2-paths.ts';
11
12
  import { findRuntimeEngineMismatches } from './runtime-engines.ts';
12
13
  import { parseCustomTools } from './tools.ts';
@@ -29,7 +30,7 @@ type PlatformFn = typeof platform;
29
30
  type WriteStderr = (chunk: string) => unknown;
30
31
 
31
32
  type SandboxConfigInput = {
32
- engine?: string | null;
33
+ engine?: SandboxEngineInput;
33
34
  runtimes?: string[];
34
35
  tools?: string[];
35
36
  customTools?: unknown;