@covibes/zeroshot 5.2.1 → 5.4.0

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 (98) hide show
  1. package/CHANGELOG.md +174 -189
  2. package/README.md +226 -195
  3. package/cli/commands/providers.js +149 -0
  4. package/cli/index.js +3145 -2366
  5. package/cli/lib/first-run.js +40 -3
  6. package/cli/message-formatters-normal.js +28 -6
  7. package/cluster-templates/base-templates/debug-workflow.json +24 -78
  8. package/cluster-templates/base-templates/full-workflow.json +99 -316
  9. package/cluster-templates/base-templates/single-worker.json +23 -15
  10. package/cluster-templates/base-templates/worker-validator.json +105 -36
  11. package/cluster-templates/conductor-bootstrap.json +9 -7
  12. package/lib/docker-config.js +14 -1
  13. package/lib/git-remote-utils.js +165 -0
  14. package/lib/id-detector.js +10 -7
  15. package/lib/provider-defaults.js +62 -0
  16. package/lib/provider-detection.js +59 -0
  17. package/lib/provider-names.js +57 -0
  18. package/lib/settings/claude-auth.js +78 -0
  19. package/lib/settings.js +298 -15
  20. package/lib/stream-json-parser.js +4 -238
  21. package/package.json +27 -6
  22. package/scripts/setup-merge-queue.sh +170 -0
  23. package/scripts/validate-templates.js +100 -0
  24. package/src/agent/agent-config.js +140 -63
  25. package/src/agent/agent-context-builder.js +336 -165
  26. package/src/agent/agent-hook-executor.js +337 -67
  27. package/src/agent/agent-lifecycle.js +386 -287
  28. package/src/agent/agent-stuck-detector.js +7 -7
  29. package/src/agent/agent-task-executor.js +944 -683
  30. package/src/agent/output-extraction.js +217 -0
  31. package/src/agent/output-reformatter.js +175 -0
  32. package/src/agent/schema-utils.js +146 -0
  33. package/src/agent-wrapper.js +112 -31
  34. package/src/agents/git-pusher-template.js +285 -0
  35. package/src/claude-task-runner.js +145 -44
  36. package/src/config-router.js +13 -13
  37. package/src/config-validator.js +1049 -563
  38. package/src/input-helpers.js +65 -0
  39. package/src/isolation-manager.js +499 -320
  40. package/src/issue-providers/README.md +305 -0
  41. package/src/issue-providers/azure-devops-provider.js +273 -0
  42. package/src/issue-providers/base-provider.js +232 -0
  43. package/src/issue-providers/github-provider.js +179 -0
  44. package/src/issue-providers/gitlab-provider.js +241 -0
  45. package/src/issue-providers/index.js +196 -0
  46. package/src/issue-providers/jira-provider.js +239 -0
  47. package/src/ledger.js +50 -11
  48. package/src/lib/safe-exec.js +88 -0
  49. package/src/orchestrator.js +1348 -757
  50. package/src/preflight.js +306 -149
  51. package/src/process-metrics.js +98 -56
  52. package/src/providers/anthropic/cli-builder.js +73 -0
  53. package/src/providers/anthropic/index.js +204 -0
  54. package/src/providers/anthropic/models.js +23 -0
  55. package/src/providers/anthropic/output-parser.js +177 -0
  56. package/src/providers/base-provider.js +251 -0
  57. package/src/providers/capabilities.js +60 -0
  58. package/src/providers/google/cli-builder.js +55 -0
  59. package/src/providers/google/index.js +116 -0
  60. package/src/providers/google/models.js +24 -0
  61. package/src/providers/google/output-parser.js +101 -0
  62. package/src/providers/index.js +91 -0
  63. package/src/providers/openai/cli-builder.js +133 -0
  64. package/src/providers/openai/index.js +136 -0
  65. package/src/providers/openai/models.js +21 -0
  66. package/src/providers/openai/output-parser.js +143 -0
  67. package/src/providers/opencode/cli-builder.js +42 -0
  68. package/src/providers/opencode/index.js +103 -0
  69. package/src/providers/opencode/models.js +55 -0
  70. package/src/providers/opencode/output-parser.js +122 -0
  71. package/src/schemas/sub-cluster.js +68 -39
  72. package/src/status-footer.js +94 -48
  73. package/src/sub-cluster-wrapper.js +92 -36
  74. package/src/task-runner.js +8 -6
  75. package/src/template-resolver.js +12 -9
  76. package/src/tui/data-poller.js +123 -99
  77. package/src/tui/formatters.js +4 -3
  78. package/src/tui/keybindings.js +259 -318
  79. package/src/tui/layout.js +20 -3
  80. package/src/tui/renderer.js +11 -21
  81. package/task-lib/attachable-watcher.js +150 -111
  82. package/task-lib/claude-recovery.js +156 -0
  83. package/task-lib/commands/episodes.js +105 -0
  84. package/task-lib/commands/list.js +3 -3
  85. package/task-lib/commands/logs.js +231 -189
  86. package/task-lib/commands/resume.js +3 -2
  87. package/task-lib/commands/run.js +12 -3
  88. package/task-lib/commands/schedules.js +111 -61
  89. package/task-lib/config.js +0 -2
  90. package/task-lib/runner.js +128 -50
  91. package/task-lib/scheduler.js +3 -3
  92. package/task-lib/store.js +464 -152
  93. package/task-lib/tui/formatters.js +94 -45
  94. package/task-lib/tui/renderer.js +13 -3
  95. package/task-lib/tui.js +73 -32
  96. package/task-lib/watcher.js +157 -100
  97. package/src/agents/git-pusher-agent.json +0 -20
  98. package/src/github.js +0 -103
@@ -3,18 +3,23 @@
3
3
  *
4
4
  * Handles:
5
5
  * - Container creation with workspace mounts
6
- * - Credential injection for Claude CLI
6
+ * - Credential injection for provider CLIs
7
7
  * - Command execution inside containers
8
8
  * - Container cleanup on stop/kill
9
9
  */
10
10
 
11
- const { spawn, execSync } = require('child_process');
11
+ const { spawn } = require('child_process');
12
+ const { execSync } = require('./lib/safe-exec'); // Enforces timeouts - prevents infinite hangs
12
13
  const { Worker } = require('worker_threads');
14
+ const crypto = require('crypto');
13
15
  const path = require('path');
14
16
  const os = require('os');
15
17
  const fs = require('fs');
16
18
  const { loadSettings } = require('../lib/settings');
19
+ const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/claude-auth');
20
+ const { normalizeProviderName } = require('../lib/provider-names');
17
21
  const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
22
+ const { getProvider } = require('./providers');
18
23
 
19
24
  /**
20
25
  * Escape a string for safe use in shell commands
@@ -28,6 +33,19 @@ function escapeShell(str) {
28
33
  return `'${str.replace(/'/g, "'\\''")}'`;
29
34
  }
30
35
 
36
+ function expandHomePath(value) {
37
+ if (!value) return value;
38
+ if (value === '~') return os.homedir();
39
+ return value.replace(/^~(?=\/|$)/, os.homedir());
40
+ }
41
+
42
+ function pathContains(base, target) {
43
+ const resolvedBase = path.resolve(base);
44
+ const resolvedTarget = path.resolve(target);
45
+ if (resolvedBase === resolvedTarget) return true;
46
+ return resolvedTarget.startsWith(resolvedBase + path.sep);
47
+ }
48
+
31
49
  const DEFAULT_IMAGE = 'zeroshot-cluster-base';
32
50
 
33
51
  class IsolationManager {
@@ -68,6 +86,7 @@ class IsolationManager {
68
86
  * @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
69
87
  * @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
70
88
  * @param {boolean} [config.noMounts=false] - Disable all credential mounts
89
+ * @param {string} [config.provider] - Provider name for credential warnings
71
90
  * @returns {Promise<string>} Container ID
72
91
  */
73
92
  async createContainer(clusterId, config) {
@@ -76,145 +95,207 @@ class IsolationManager {
76
95
  const containerName = `zeroshot-cluster-${clusterId}`;
77
96
  const reuseExisting = config.reuseExistingWorkspace || false;
78
97
 
79
- // Check if container already exists
80
- if (this.containers.has(clusterId)) {
81
- const existingId = this.containers.get(clusterId);
82
- if (this._isContainerRunning(existingId)) {
83
- return existingId;
84
- }
98
+ const runningContainerId = this._getRunningContainerId(clusterId);
99
+ if (runningContainerId) {
100
+ return runningContainerId;
85
101
  }
86
102
 
87
- // Clean up any existing container with same name
88
103
  this._removeContainerByName(containerName);
89
104
 
90
- // For isolation mode: copy files to temp dir with fresh git repo (100% isolated)
91
- // No worktrees - cleaner, no host path dependencies
92
- // EXCEPTION: On resume (reuseExisting=true), skip copy and use existing workspace
93
- if (this._isGitRepo(workDir)) {
94
- const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
95
-
96
- if (reuseExisting && fs.existsSync(isolatedPath)) {
97
- // Resume mode: reuse existing isolated workspace (contains agent's work)
98
- console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
99
- this.isolatedDirs = this.isolatedDirs || new Map();
100
- this.isolatedDirs.set(clusterId, {
101
- path: isolatedPath,
102
- originalDir: workDir,
103
- });
104
- workDir = isolatedPath;
105
- } else {
106
- // Fresh start: create new isolated copy
107
- const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
108
- this.isolatedDirs = this.isolatedDirs || new Map();
109
- this.isolatedDirs.set(clusterId, {
110
- path: isolatedDir,
111
- originalDir: workDir,
112
- });
113
- workDir = isolatedDir;
114
- console.log(`[IsolationManager] Created isolated copy at ${workDir}`);
115
- }
116
- }
105
+ workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
117
106
 
118
- // Resolve container home directory EARLY - needed for Claude config mount and hooks
119
107
  const settings = loadSettings();
108
+ const providerName = normalizeProviderName(
109
+ config.provider || settings.defaultProvider || 'claude'
110
+ );
120
111
  const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
121
112
 
122
- // Create fresh Claude config dir for this cluster (avoids permission issues from host)
123
113
  const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
124
114
  console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
125
115
 
126
- // Build docker run command
127
- // NOTE: Container runs as 'node' user (uid 1000) for --dangerously-skip-permissions
128
- const args = [
116
+ const args = this._buildBaseDockerArgs({
117
+ containerName,
118
+ workDir,
119
+ containerHome,
120
+ clusterConfigDir,
121
+ });
122
+
123
+ const mountedHosts = this._applyCredentialMounts(args, config, settings, containerHome);
124
+ this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);
125
+
126
+ args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
127
+
128
+ return this._spawnContainer(clusterId, args, workDir);
129
+ }
130
+
131
+ _getRunningContainerId(clusterId) {
132
+ const existingId = this.containers.get(clusterId);
133
+ if (!existingId) {
134
+ return null;
135
+ }
136
+
137
+ return this._isContainerRunning(existingId) ? existingId : null;
138
+ }
139
+
140
+ async _prepareIsolatedWorkspace(clusterId, workDir, reuseExisting) {
141
+ if (!this._isGitRepo(workDir)) {
142
+ return workDir;
143
+ }
144
+
145
+ this.isolatedDirs = this.isolatedDirs || new Map();
146
+ const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
147
+
148
+ if (reuseExisting && fs.existsSync(isolatedPath)) {
149
+ console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
150
+ this.isolatedDirs.set(clusterId, {
151
+ path: isolatedPath,
152
+ originalDir: workDir,
153
+ });
154
+ return isolatedPath;
155
+ }
156
+
157
+ const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
158
+ this.isolatedDirs.set(clusterId, {
159
+ path: isolatedDir,
160
+ originalDir: workDir,
161
+ });
162
+ console.log(`[IsolationManager] Created isolated copy at ${isolatedDir}`);
163
+ return isolatedDir;
164
+ }
165
+
166
+ _buildBaseDockerArgs({ containerName, workDir, containerHome, clusterConfigDir }) {
167
+ return [
129
168
  'run',
130
- '-d', // detached
169
+ '-d',
131
170
  '--name',
132
171
  containerName,
133
- // Mount workspace
134
172
  '-v',
135
173
  `${workDir}:/workspace`,
136
- // Mount Docker socket for Docker-in-Docker (e2e tests need docker compose)
137
174
  '-v',
138
175
  '/var/run/docker.sock:/var/run/docker.sock',
139
- // Add node user to host's docker group (fixes permission denied)
140
- // CRITICAL: Without this, agent can't run docker commands inside container
141
176
  '--group-add',
142
177
  this._getDockerGid(),
143
- // Mount fresh Claude config to container user's home (read-write - Claude CLI writes settings, todos, etc.)
144
178
  '-v',
145
179
  `${clusterConfigDir}:${containerHome}/.claude`,
146
180
  ];
181
+ }
147
182
 
148
- // Add configurable credential mounts
149
- // Priority: CLI config > env var > settings > defaults
150
- if (!config.noMounts) {
151
- let mountConfig;
183
+ _resolveMountConfig(config, settings) {
184
+ if (config.mounts) {
185
+ return config.mounts;
186
+ }
152
187
 
153
- if (config.mounts) {
154
- // CLI override
155
- mountConfig = config.mounts;
156
- } else if (process.env.ZEROSHOT_DOCKER_MOUNTS) {
157
- // Environment override
158
- try {
159
- mountConfig = JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
160
- } catch {
161
- console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
162
- mountConfig = settings.dockerMounts;
163
- }
164
- } else {
165
- // User settings
166
- mountConfig = settings.dockerMounts;
188
+ if (process.env.ZEROSHOT_DOCKER_MOUNTS) {
189
+ try {
190
+ return JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
191
+ } catch {
192
+ console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
193
+ return settings.dockerMounts;
167
194
  }
195
+ }
168
196
 
169
- // Resolve presets to actual mount specs (containerHome already resolved above)
170
- const mounts = resolveMounts(mountConfig, { containerHome });
197
+ return settings.dockerMounts;
198
+ }
171
199
 
172
- for (const mount of mounts) {
173
- const hostPath = mount.host.replace(/^~/, os.homedir());
200
+ _applyCredentialMounts(args, config, settings, containerHome) {
201
+ const mountedHosts = [];
202
+ if (config.noMounts) {
203
+ return mountedHosts;
204
+ }
174
205
 
175
- // Check path exists and is mountable
176
- try {
177
- const stat = fs.statSync(hostPath);
178
- // Files ending in 'config' must be files (some systems have dirs)
179
- if (hostPath.endsWith('config') && !stat.isFile()) {
180
- continue;
181
- }
182
- } catch {
183
- // Path doesn't exist - skip silently
206
+ const mountConfig = this._resolveMountConfig(config, settings);
207
+ const mounts = resolveMounts(mountConfig, { containerHome });
208
+ const claudeContainerPath = path.posix.join(containerHome, '.claude');
209
+
210
+ for (const mount of mounts) {
211
+ if (mount.container === claudeContainerPath) {
212
+ console.warn(
213
+ `[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
214
+ '(Claude config is managed by zeroshot).'
215
+ );
216
+ continue;
217
+ }
218
+
219
+ const hostPath = expandHomePath(mount.host);
220
+
221
+ try {
222
+ const stat = fs.statSync(hostPath);
223
+ if (hostPath.endsWith('config') && !stat.isFile()) {
184
224
  continue;
185
225
  }
226
+ } catch {
227
+ continue;
228
+ }
186
229
 
187
- const mountSpec = mount.readonly
188
- ? `${hostPath}:${mount.container}:ro`
189
- : `${hostPath}:${mount.container}`;
190
- args.push('-v', mountSpec);
230
+ const mountSpec = mount.readonly
231
+ ? `${hostPath}:${mount.container}:ro`
232
+ : `${hostPath}:${mount.container}`;
233
+ args.push('-v', mountSpec);
234
+ mountedHosts.push(hostPath);
235
+ }
236
+
237
+ const envToPass = this._collectDockerEnvVars(mountConfig, settings);
238
+ for (const [key, value] of Object.entries(envToPass)) {
239
+ args.push('-e', `${key}=${value}`);
240
+ }
241
+
242
+ return mountedHosts;
243
+ }
244
+
245
+ _collectDockerEnvVars(mountConfig, settings) {
246
+ const envToPass = {};
247
+ const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
248
+
249
+ for (const spec of envSpecs) {
250
+ if (spec.forced) {
251
+ envToPass[spec.name] = spec.value;
252
+ } else if (process.env[spec.name]) {
253
+ envToPass[spec.name] = process.env[spec.name];
191
254
  }
255
+ }
192
256
 
193
- // Pass env vars based on enabled presets
194
- const envSpecs = expandEnvPatterns(
195
- resolveEnvs(mountConfig, settings.dockerEnvPassthrough)
196
- );
197
- for (const spec of envSpecs) {
198
- if (spec.forced) {
199
- // Forced value - always pass with specified value
200
- args.push('-e', `${spec.name}=${spec.value}`);
201
- } else if (process.env[spec.name]) {
202
- // Dynamic value - only pass if set in environment
203
- args.push('-e', `${spec.name}=${process.env[spec.name]}`);
204
- }
257
+ for (const envVar of CLAUDE_AUTH_ENV_VARS) {
258
+ if (process.env[envVar]) {
259
+ envToPass[envVar] = process.env[envVar];
260
+ }
261
+ }
262
+
263
+ const authEnv = resolveClaudeAuth(settings);
264
+ for (const [key, value] of Object.entries(authEnv)) {
265
+ if (!(key in envToPass)) {
266
+ envToPass[key] = value;
205
267
  }
206
268
  }
207
269
 
208
- // Finish docker args
209
- args.push(
210
- '-w',
211
- '/workspace',
212
- image,
213
- 'tail',
214
- '-f',
215
- '/dev/null'
270
+ return envToPass;
271
+ }
272
+
273
+ _warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
274
+ if (providerName === 'claude') {
275
+ return;
276
+ }
277
+
278
+ const provider = getProvider(providerName);
279
+ const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
280
+ const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
281
+ const hasCredentialMount = mountedHosts.some((hostPath) =>
282
+ expandedCreds.some(
283
+ (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
284
+ )
216
285
  );
217
286
 
287
+ if (!hasCredentialMount && expandedCreds.length > 0) {
288
+ const exampleHost = credentialPaths[0];
289
+ const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
290
+ const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
291
+ console.warn(
292
+ `[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
293
+ `Add one with --mount ${exampleHost}:${exampleContainer}:ro`
294
+ );
295
+ }
296
+ }
297
+
298
+ _spawnContainer(clusterId, args, workDir) {
218
299
  return new Promise((resolve, reject) => {
219
300
  const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
220
301
 
@@ -229,138 +310,26 @@ class IsolationManager {
229
310
  });
230
311
 
231
312
  proc.on('close', async (code) => {
232
- if (code === 0) {
233
- const containerId = stdout.trim().substring(0, 12);
234
- this.containers.set(clusterId, containerId);
235
-
236
- // Install dependencies if package.json exists
237
- // This enables e2e tests and other npm-based tools to run
238
- // OPTIMIZATION: Use pre-baked deps when possible (30-40% faster startup)
239
- // See: GitHub issue #20
240
- try {
241
- console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
242
- if (fs.existsSync(path.join(workDir, 'package.json'))) {
243
- // Check if node_modules already exists in container (pre-baked or previous run)
244
- const checkResult = await this.execInContainer(
245
- clusterId,
246
- ['sh', '-c', 'test -d node_modules && test -f node_modules/.package-lock.json && echo "exists"'],
247
- {}
248
- );
249
-
250
- if (checkResult.code === 0 && checkResult.stdout.trim() === 'exists') {
251
- console.log(`[IsolationManager] ✓ Dependencies already installed (skipping npm install)`);
252
- } else {
253
- // Check if npm is available in container
254
- const npmCheck = await this.execInContainer(clusterId, ['which', 'npm'], {});
255
- if (npmCheck.code !== 0) {
256
- console.log(`[IsolationManager] npm not available in container, skipping dependency install`);
257
- } else {
258
- // Issue #20: Try to use pre-baked dependencies first
259
- // Check if pre-baked deps exist and can satisfy project requirements
260
- const preBakeCheck = await this.execInContainer(
261
- clusterId,
262
- ['sh', '-c', 'test -d /pre-baked-deps/node_modules && echo "exists"'],
263
- {}
264
- );
265
-
266
- if (preBakeCheck.code === 0 && preBakeCheck.stdout.trim() === 'exists') {
267
- console.log(`[IsolationManager] Checking if pre-baked deps satisfy requirements...`);
268
-
269
- // Copy pre-baked deps, then run npm install to add any missing
270
- // This is faster than full npm install: copy is ~2s, npm install adds ~5-10s for missing
271
- const copyResult = await this.execInContainer(
272
- clusterId,
273
- ['sh', '-c', 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true'],
274
- {}
275
- );
276
-
277
- if (copyResult.code === 0) {
278
- console.log(`[IsolationManager] ✓ Copied pre-baked dependencies`);
279
-
280
- // Run npm install to add any missing deps (much faster with pre-baked base)
281
- const installResult = await this.execInContainer(
282
- clusterId,
283
- ['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline'],
284
- {}
285
- );
286
-
287
- if (installResult.code === 0) {
288
- console.log(`[IsolationManager] ✓ Dependencies installed (pre-baked + incremental)`);
289
- } else {
290
- // Fallback: full install (pre-baked copy may have caused issues)
291
- console.warn(`[IsolationManager] Incremental install failed, falling back to full install`);
292
- await this.execInContainer(
293
- clusterId,
294
- ['sh', '-c', 'rm -rf node_modules && npm_config_engine_strict=false npm install --no-audit --no-fund'],
295
- {}
296
- );
297
- console.log(`[IsolationManager] ✓ Dependencies installed (full fallback)`);
298
- }
299
- }
300
- } else {
301
- // No pre-baked deps, full npm install with retries
302
- console.log(`[IsolationManager] Installing npm dependencies in container...`);
303
-
304
- // Retry npm install with exponential backoff (network issues are common)
305
- const maxRetries = 3;
306
- const baseDelay = 2000; // 2 seconds
307
- let installResult = null;
308
-
309
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
310
- try {
311
- installResult = await this.execInContainer(
312
- clusterId,
313
- ['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund'],
314
- {}
315
- );
316
-
317
- if (installResult.code === 0) {
318
- console.log(`[IsolationManager] ✓ Dependencies installed`);
319
- break; // Success - exit retry loop
320
- }
321
-
322
- // Failed - retry if not last attempt
323
- // Use stderr if available, otherwise stdout (npm writes some errors to stdout)
324
- const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
325
- if (attempt < maxRetries) {
326
- const delay = baseDelay * Math.pow(2, attempt - 1);
327
- console.warn(
328
- `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
329
- );
330
- console.warn(`[IsolationManager] Error: ${errorOutput}`);
331
- await new Promise((_resolve) => setTimeout(_resolve, delay));
332
- } else {
333
- console.warn(
334
- `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
335
- );
336
- }
337
- } catch (execErr) {
338
- if (attempt < maxRetries) {
339
- const delay = baseDelay * Math.pow(2, attempt - 1);
340
- console.warn(
341
- `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
342
- );
343
- console.warn(`[IsolationManager] Error: ${execErr.message}`);
344
- await new Promise((_resolve) => setTimeout(_resolve, delay));
345
- } else {
346
- throw execErr; // Re-throw on last attempt
347
- }
348
- }
349
- }
350
- }
351
- }
352
- }
353
- }
354
- } catch (err) {
355
- console.warn(
356
- `[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
357
- );
358
- }
359
-
360
- resolve(containerId);
361
- } else {
313
+ if (code !== 0) {
362
314
  reject(new Error(`Failed to create container: ${stderr}`));
315
+ return;
363
316
  }
317
+
318
+ const containerId = stdout.trim().substring(0, 12);
319
+ this.containers.set(clusterId, containerId);
320
+
321
+ try {
322
+ console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
323
+ if (fs.existsSync(path.join(workDir, 'package.json'))) {
324
+ await this._installDependenciesWithRetry(clusterId);
325
+ }
326
+ } catch (err) {
327
+ console.warn(
328
+ `[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
329
+ );
330
+ }
331
+
332
+ resolve(containerId);
364
333
  });
365
334
 
366
335
  proc.on('error', (err) => {
@@ -369,6 +338,81 @@ class IsolationManager {
369
338
  });
370
339
  }
371
340
 
341
+ async _installDependenciesWithRetry(clusterId) {
342
+ console.log(`[IsolationManager] Installing npm dependencies in container...`);
343
+
344
+ const maxRetries = 3;
345
+ const baseDelay = 2000; // 2 seconds
346
+ const installCommand = [
347
+ 'sh',
348
+ '-c',
349
+ [
350
+ 'if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then',
351
+ 'echo "__deps_present__";',
352
+ 'exit 0;',
353
+ 'fi;',
354
+ 'if ! command -v npm >/dev/null 2>&1; then',
355
+ 'echo "__npm_missing__";',
356
+ 'exit 127;',
357
+ 'fi;',
358
+ 'if [ -d /pre-baked-deps/node_modules ]; then',
359
+ 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true;',
360
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline;',
361
+ 'install_code=$?;',
362
+ 'if [ $install_code -ne 0 ]; then',
363
+ 'rm -rf node_modules;',
364
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
365
+ 'fi;',
366
+ 'else',
367
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
368
+ 'fi',
369
+ ].join(' '),
370
+ ];
371
+
372
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
373
+ try {
374
+ const installResult = await this.execInContainer(clusterId, installCommand, {});
375
+ const stdout = installResult.stdout || '';
376
+
377
+ if (installResult.code === 0) {
378
+ if (stdout.includes('__deps_present__')) {
379
+ console.log(
380
+ `[IsolationManager] ✓ Dependencies already installed (skipping npm install)`
381
+ );
382
+ } else {
383
+ console.log(`[IsolationManager] ✓ Dependencies installed`);
384
+ }
385
+ return;
386
+ }
387
+
388
+ const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
389
+ if (attempt < maxRetries) {
390
+ const delay = baseDelay * Math.pow(2, attempt - 1);
391
+ console.warn(
392
+ `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
393
+ );
394
+ console.warn(`[IsolationManager] Error: ${errorOutput}`);
395
+ await new Promise((resolve) => setTimeout(resolve, delay));
396
+ } else {
397
+ console.warn(
398
+ `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
399
+ );
400
+ }
401
+ } catch (execErr) {
402
+ if (attempt < maxRetries) {
403
+ const delay = baseDelay * Math.pow(2, attempt - 1);
404
+ console.warn(
405
+ `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
406
+ );
407
+ console.warn(`[IsolationManager] Error: ${execErr.message}`);
408
+ await new Promise((resolve) => setTimeout(resolve, delay));
409
+ } else {
410
+ throw execErr;
411
+ }
412
+ }
413
+ }
414
+ }
415
+
372
416
  /**
373
417
  * Execute a command inside the container
374
418
  * @param {string} clusterId - Cluster ID
@@ -376,6 +420,7 @@ class IsolationManager {
376
420
  * @param {object} [options] - Exec options
377
421
  * @param {boolean} [options.interactive] - Use -it flags
378
422
  * @param {object} [options.env] - Environment variables
423
+ * @param {number} [options.timeout=30000] - Timeout in ms (0 = no timeout). Prevents infinite hangs.
379
424
  * @returns {Promise<{stdout: string, stderr: string, code: number}>}
380
425
  */
381
426
  execInContainer(clusterId, command, options = {}) {
@@ -399,6 +444,9 @@ class IsolationManager {
399
444
 
400
445
  args.push(containerId, ...command);
401
446
 
447
+ // Default timeout: 30 seconds (prevents infinite hangs)
448
+ const timeout = options.timeout ?? 30000;
449
+
402
450
  return new Promise((resolve, reject) => {
403
451
  const proc = spawn('docker', args, {
404
452
  stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
@@ -406,6 +454,16 @@ class IsolationManager {
406
454
 
407
455
  let stdout = '';
408
456
  let stderr = '';
457
+ let timedOut = false;
458
+ let timeoutId = null;
459
+
460
+ // Set up timeout if specified (0 = no timeout)
461
+ if (timeout > 0) {
462
+ timeoutId = setTimeout(() => {
463
+ timedOut = true;
464
+ proc.kill('SIGKILL');
465
+ }, timeout);
466
+ }
409
467
 
410
468
  if (!options.interactive) {
411
469
  proc.stdout.on('data', (data) => {
@@ -417,10 +475,16 @@ class IsolationManager {
417
475
  }
418
476
 
419
477
  proc.on('close', (code) => {
420
- resolve({ stdout, stderr, code });
478
+ if (timeoutId) clearTimeout(timeoutId);
479
+ if (timedOut) {
480
+ reject(new Error(`Docker exec timed out after ${timeout}ms`));
481
+ } else {
482
+ resolve({ stdout, stderr, code });
483
+ }
421
484
  });
422
485
 
423
486
  proc.on('error', (err) => {
487
+ if (timeoutId) clearTimeout(timeoutId);
424
488
  reject(new Error(`Docker exec error: ${err.message}`));
425
489
  });
426
490
  });
@@ -542,7 +606,9 @@ class IsolationManager {
542
606
  const isolatedInfo = this.isolatedDirs.get(clusterId);
543
607
 
544
608
  if (preserveWorkspace) {
545
- console.log(`[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`);
609
+ console.log(
610
+ `[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
611
+ );
546
612
  // Don't delete - but DON'T remove from Map either, resume() needs it
547
613
  } else {
548
614
  console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
@@ -677,58 +743,80 @@ class IsolationManager {
677
743
  const files = [];
678
744
  const directories = new Set();
679
745
 
680
- const collectFiles = (currentSrc, relativePath = '') => {
681
- let entries;
746
+ const shouldIgnoreFsError = (err) =>
747
+ err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
748
+
749
+ const shouldExcludeEntry = (entryName) => {
750
+ return exclude.some((pattern) => {
751
+ if (pattern.startsWith('*.')) {
752
+ return entryName.endsWith(pattern.slice(1));
753
+ }
754
+ return entryName === pattern;
755
+ });
756
+ };
757
+
758
+ const ensureParentDirTracked = (relativePath) => {
759
+ if (relativePath) {
760
+ directories.add(relativePath);
761
+ }
762
+ };
763
+
764
+ const readEntries = (currentSrc) => {
682
765
  try {
683
- entries = fs.readdirSync(currentSrc, { withFileTypes: true });
766
+ return fs.readdirSync(currentSrc, { withFileTypes: true });
684
767
  } catch (err) {
685
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
686
- return;
768
+ if (shouldIgnoreFsError(err)) {
769
+ return [];
687
770
  }
688
771
  throw err;
689
772
  }
773
+ };
774
+
775
+ function handleEntry(entry, srcPath, relPath, relativePath) {
776
+ if (entry.isSymbolicLink()) {
777
+ const targetStats = fs.statSync(srcPath);
778
+ if (targetStats.isDirectory()) {
779
+ directories.add(relPath);
780
+ collectFiles(srcPath, relPath);
781
+ return;
782
+ }
783
+
784
+ files.push(relPath);
785
+ ensureParentDirTracked(relativePath);
786
+ return;
787
+ }
788
+
789
+ if (entry.isDirectory()) {
790
+ directories.add(relPath);
791
+ collectFiles(srcPath, relPath);
792
+ return;
793
+ }
794
+
795
+ files.push(relPath);
796
+ ensureParentDirTracked(relativePath);
797
+ }
798
+
799
+ function collectFiles(currentSrc, relativePath = '') {
800
+ const entries = readEntries(currentSrc);
690
801
 
691
802
  for (const entry of entries) {
692
- // Check exclusions (exact match or glob pattern)
693
- const shouldExclude = exclude.some((pattern) => {
694
- if (pattern.startsWith('*.')) {
695
- return entry.name.endsWith(pattern.slice(1));
696
- }
697
- return entry.name === pattern;
698
- });
699
- if (shouldExclude) continue;
803
+ if (shouldExcludeEntry(entry.name)) {
804
+ continue;
805
+ }
700
806
 
701
807
  const srcPath = path.join(currentSrc, entry.name);
702
808
  const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
703
809
 
704
810
  try {
705
- // Handle symlinks: resolve to actual target
706
- if (entry.isSymbolicLink()) {
707
- const targetStats = fs.statSync(srcPath);
708
- if (targetStats.isDirectory()) {
709
- directories.add(relPath);
710
- collectFiles(srcPath, relPath);
711
- } else {
712
- files.push(relPath);
713
- // Ensure parent directory is tracked
714
- if (relativePath) directories.add(relativePath);
715
- }
716
- } else if (entry.isDirectory()) {
717
- directories.add(relPath);
718
- collectFiles(srcPath, relPath);
719
- } else {
720
- files.push(relPath);
721
- // Ensure parent directory is tracked
722
- if (relativePath) directories.add(relativePath);
723
- }
811
+ handleEntry(entry, srcPath, relPath, relativePath);
724
812
  } catch (err) {
725
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
813
+ if (shouldIgnoreFsError(err)) {
726
814
  continue;
727
815
  }
728
816
  throw err;
729
817
  }
730
818
  }
731
- };
819
+ }
732
820
 
733
821
  collectFiles(src);
734
822
 
@@ -896,7 +984,10 @@ class IsolationManager {
896
984
  ],
897
985
  },
898
986
  };
899
- fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify(clusterSettings, null, 2));
987
+ fs.writeFileSync(
988
+ path.join(configDir, 'settings.json'),
989
+ JSON.stringify(clusterSettings, null, 2)
990
+ );
900
991
 
901
992
  // Track for cleanup
902
993
  this.clusterConfigDirs = this.clusterConfigDirs || new Map();
@@ -932,33 +1023,48 @@ class IsolationManager {
932
1023
  _preserveTerraformState(clusterId, isolatedPath) {
933
1024
  const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
934
1025
  const checkDirs = [isolatedPath, path.join(isolatedPath, 'terraform')];
1026
+ const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
1027
+
1028
+ const hasStateFiles = (checkDir) => {
1029
+ if (!fs.existsSync(checkDir)) {
1030
+ return false;
1031
+ }
1032
+
1033
+ return stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
1034
+ };
1035
+
1036
+ const copyStateFiles = (checkDir) => {
1037
+ let copied = false;
1038
+
1039
+ for (const file of stateFiles) {
1040
+ const srcPath = path.join(checkDir, file);
1041
+ if (!fs.existsSync(srcPath)) {
1042
+ continue;
1043
+ }
1044
+
1045
+ const destPath = path.join(stateDir, file);
1046
+ try {
1047
+ fs.copyFileSync(srcPath, destPath);
1048
+ console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
1049
+ copied = true;
1050
+ } catch (err) {
1051
+ console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
1052
+ }
1053
+ }
1054
+
1055
+ return copied;
1056
+ };
935
1057
 
936
1058
  let foundState = false;
937
1059
 
938
1060
  for (const checkDir of checkDirs) {
939
- if (!fs.existsSync(checkDir)) continue;
940
-
941
- const hasStateFiles = stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
942
-
943
- if (hasStateFiles) {
944
- const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
945
- fs.mkdirSync(stateDir, { recursive: true });
946
-
947
- for (const file of stateFiles) {
948
- const srcPath = path.join(checkDir, file);
949
- if (fs.existsSync(srcPath)) {
950
- const destPath = path.join(stateDir, file);
951
- try {
952
- fs.copyFileSync(srcPath, destPath);
953
- console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
954
- foundState = true;
955
- } catch (err) {
956
- console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
957
- }
958
- }
959
- }
960
- break; // Only backup from first dir with state files
1061
+ if (!hasStateFiles(checkDir)) {
1062
+ continue;
961
1063
  }
1064
+
1065
+ fs.mkdirSync(stateDir, { recursive: true });
1066
+ foundState = copyStateFiles(checkDir);
1067
+ break;
962
1068
  }
963
1069
 
964
1070
  if (!foundState) {
@@ -990,9 +1096,12 @@ class IsolationManager {
990
1096
  */
991
1097
  _isContainerRunning(containerId) {
992
1098
  try {
993
- const result = execSync(`docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`, {
994
- encoding: 'utf8',
995
- });
1099
+ const result = execSync(
1100
+ `docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`,
1101
+ {
1102
+ encoding: 'utf8',
1103
+ }
1104
+ );
996
1105
  return result.trim() === 'true';
997
1106
  } catch {
998
1107
  return false;
@@ -1017,7 +1126,8 @@ class IsolationManager {
1017
1126
  */
1018
1127
  static isDockerAvailable() {
1019
1128
  try {
1020
- execSync('docker --version', { encoding: 'utf8', stdio: 'pipe' });
1129
+ // Require both CLI binary and a reachable daemon.
1130
+ execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
1021
1131
  return true;
1022
1132
  } catch {
1023
1133
  return false;
@@ -1146,14 +1256,16 @@ class IsolationManager {
1146
1256
 
1147
1257
  /**
1148
1258
  * Create worktree-based isolation for a cluster (lightweight alternative to Docker)
1149
- * Creates a git worktree at /tmp/zeroshot-worktrees/{clusterId}
1259
+ * Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId}
1150
1260
  * @param {string} clusterId - Cluster ID
1151
1261
  * @param {string} workDir - Original working directory (must be a git repo)
1152
1262
  * @returns {{ path: string, branch: string, repoRoot: string }}
1153
1263
  */
1154
1264
  createWorktreeIsolation(clusterId, workDir) {
1155
1265
  if (!this._isGitRepo(workDir)) {
1156
- throw new Error(`Worktree isolation requires a git repository. ${workDir} is not a git repo.`);
1266
+ throw new Error(
1267
+ `Worktree isolation requires a git repository. ${workDir} is not a git repo.`
1268
+ );
1157
1269
  }
1158
1270
 
1159
1271
  const worktreeInfo = this.createWorktree(clusterId, workDir);
@@ -1196,7 +1308,8 @@ class IsolationManager {
1196
1308
  }
1197
1309
 
1198
1310
  // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
1199
- const branchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1311
+ const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1312
+ let branchName = baseBranchName;
1200
1313
 
1201
1314
  // Worktree path in tmp
1202
1315
  const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId);
@@ -1207,34 +1320,73 @@ class IsolationManager {
1207
1320
  fs.mkdirSync(parentDir, { recursive: true });
1208
1321
  }
1209
1322
 
1210
- // Remove existing worktree if it exists (cleanup from previous run)
1323
+ // Best-effort cleanup of stale worktree metadata and directory.
1324
+ // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1325
+ // git may keep the branch "checked out" and block deletion/reuse.
1211
1326
  try {
1212
- execSync(`git worktree remove --force "${worktreePath}" 2>/dev/null`, {
1327
+ execSync(`git worktree remove --force ${escapeShell(worktreePath)}`, {
1213
1328
  cwd: repoRoot,
1214
1329
  encoding: 'utf8',
1215
1330
  stdio: 'pipe',
1216
1331
  });
1217
1332
  } catch {
1218
- // Ignore - worktree doesn't exist
1333
+ // ignore
1219
1334
  }
1220
-
1221
- // Delete the branch if it exists (from previous run)
1222
1335
  try {
1223
- execSync(`git branch -D "${branchName}" 2>/dev/null`, {
1224
- cwd: repoRoot,
1225
- encoding: 'utf8',
1226
- stdio: 'pipe',
1227
- });
1336
+ execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1337
+ } catch {
1338
+ // ignore
1339
+ }
1340
+ try {
1341
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1228
1342
  } catch {
1229
- // Ignore - branch doesn't exist
1343
+ // ignore
1230
1344
  }
1231
1345
 
1232
- // Create worktree with new branch based on HEAD
1233
- execSync(`git worktree add -b "${branchName}" "${worktreePath}" HEAD`, {
1234
- cwd: repoRoot,
1235
- encoding: 'utf8',
1236
- stdio: 'pipe',
1237
- });
1346
+ // Create worktree with new branch based on HEAD (retry on branch collision/in-use)
1347
+ for (let attempt = 0; attempt < 10; attempt++) {
1348
+ // Best-effort delete if branch exists and is not in use by another worktree.
1349
+ try {
1350
+ execSync(`git branch -D ${escapeShell(branchName)}`, {
1351
+ cwd: repoRoot,
1352
+ encoding: 'utf8',
1353
+ stdio: 'pipe',
1354
+ });
1355
+ } catch {
1356
+ // ignore
1357
+ }
1358
+
1359
+ try {
1360
+ execSync(
1361
+ `git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} HEAD`,
1362
+ {
1363
+ cwd: repoRoot,
1364
+ encoding: 'utf8',
1365
+ stdio: 'pipe',
1366
+ }
1367
+ );
1368
+ break;
1369
+ } catch (err) {
1370
+ const stderr = (
1371
+ err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1372
+ ).toLowerCase();
1373
+ const isBranchCollision =
1374
+ stderr.includes('already exists') ||
1375
+ stderr.includes('cannot delete branch') ||
1376
+ stderr.includes('checked out');
1377
+
1378
+ if (attempt < 9 && isBranchCollision) {
1379
+ branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1380
+ try {
1381
+ execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1382
+ } catch {
1383
+ // ignore
1384
+ }
1385
+ continue;
1386
+ }
1387
+ throw err;
1388
+ }
1389
+ }
1238
1390
 
1239
1391
  return {
1240
1392
  path: worktreePath,
@@ -1250,19 +1402,46 @@ class IsolationManager {
1250
1402
  * @param {boolean} [options.deleteBranch=false] - Also delete the branch
1251
1403
  */
1252
1404
  removeWorktree(worktreeInfo, _options = {}) {
1405
+ // Remove the worktree (prefer git so metadata is cleaned up).
1253
1406
  try {
1254
- // Remove the worktree
1255
- execSync(`git worktree remove --force "${worktreeInfo.path}" 2>/dev/null`, {
1407
+ execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1256
1408
  cwd: worktreeInfo.repoRoot,
1257
1409
  encoding: 'utf8',
1258
1410
  stdio: 'pipe',
1259
1411
  });
1260
1412
  } catch {
1261
- // Fallback: manually remove directory if worktree command fails
1413
+ // If git worktree metadata is stale, prune and retry once.
1262
1414
  try {
1263
- fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1415
+ execSync('git worktree prune', {
1416
+ cwd: worktreeInfo.repoRoot,
1417
+ encoding: 'utf8',
1418
+ stdio: 'pipe',
1419
+ });
1420
+ } catch {
1421
+ // ignore
1422
+ }
1423
+ try {
1424
+ execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1425
+ cwd: worktreeInfo.repoRoot,
1426
+ encoding: 'utf8',
1427
+ stdio: 'pipe',
1428
+ });
1264
1429
  } catch {
1265
- // Ignore
1430
+ // Last resort: delete directory, then prune stale worktree entries.
1431
+ try {
1432
+ fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1433
+ } catch {
1434
+ // ignore
1435
+ }
1436
+ try {
1437
+ execSync('git worktree prune', {
1438
+ cwd: worktreeInfo.repoRoot,
1439
+ encoding: 'utf8',
1440
+ stdio: 'pipe',
1441
+ });
1442
+ } catch {
1443
+ // ignore
1444
+ }
1266
1445
  }
1267
1446
  }
1268
1447