@the-open-engine/zeroshot 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 (295) hide show
  1. package/CHANGELOG.md +539 -0
  2. package/LICENSE +21 -0
  3. package/README.md +508 -0
  4. package/cli/commands/inspect-render.js +135 -0
  5. package/cli/commands/inspect.js +294 -0
  6. package/cli/commands/providers.js +149 -0
  7. package/cli/index.js +5431 -0
  8. package/cli/lib/first-run.js +211 -0
  9. package/cli/lib/update-checker.js +281 -0
  10. package/cli/message-formatter-utils.js +75 -0
  11. package/cli/message-formatters-normal.js +275 -0
  12. package/cli/message-formatters-watch.js +185 -0
  13. package/cluster-templates/base-templates/debug-workflow.json +422 -0
  14. package/cluster-templates/base-templates/full-workflow.json +727 -0
  15. package/cluster-templates/base-templates/heavy-validation.json +272 -0
  16. package/cluster-templates/base-templates/quick-validation.json +285 -0
  17. package/cluster-templates/base-templates/single-worker.json +71 -0
  18. package/cluster-templates/base-templates/worker-validator.json +230 -0
  19. package/cluster-templates/conductor-bootstrap.json +130 -0
  20. package/docker/zeroshot-cluster/Dockerfile +153 -0
  21. package/docker/zeroshot-cluster/pre-baked-deps.json +28 -0
  22. package/lib/agent-cli-provider/adapters/claude-parser.d.ts +3 -0
  23. package/lib/agent-cli-provider/adapters/claude-parser.d.ts.map +1 -0
  24. package/lib/agent-cli-provider/adapters/claude-parser.js +122 -0
  25. package/lib/agent-cli-provider/adapters/claude-parser.js.map +1 -0
  26. package/lib/agent-cli-provider/adapters/claude-recovery.d.ts +15 -0
  27. package/lib/agent-cli-provider/adapters/claude-recovery.d.ts.map +1 -0
  28. package/lib/agent-cli-provider/adapters/claude-recovery.js +165 -0
  29. package/lib/agent-cli-provider/adapters/claude-recovery.js.map +1 -0
  30. package/lib/agent-cli-provider/adapters/claude.d.ts +3 -0
  31. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -0
  32. package/lib/agent-cli-provider/adapters/claude.js +181 -0
  33. package/lib/agent-cli-provider/adapters/claude.js.map +1 -0
  34. package/lib/agent-cli-provider/adapters/codex-parser.d.ts +3 -0
  35. package/lib/agent-cli-provider/adapters/codex-parser.d.ts.map +1 -0
  36. package/lib/agent-cli-provider/adapters/codex-parser.js +192 -0
  37. package/lib/agent-cli-provider/adapters/codex-parser.js.map +1 -0
  38. package/lib/agent-cli-provider/adapters/codex.d.ts +3 -0
  39. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -0
  40. package/lib/agent-cli-provider/adapters/codex.js +151 -0
  41. package/lib/agent-cli-provider/adapters/codex.js.map +1 -0
  42. package/lib/agent-cli-provider/adapters/common.d.ts +28 -0
  43. package/lib/agent-cli-provider/adapters/common.d.ts.map +1 -0
  44. package/lib/agent-cli-provider/adapters/common.js +71 -0
  45. package/lib/agent-cli-provider/adapters/common.js.map +1 -0
  46. package/lib/agent-cli-provider/adapters/gemini.d.ts +3 -0
  47. package/lib/agent-cli-provider/adapters/gemini.d.ts.map +1 -0
  48. package/lib/agent-cli-provider/adapters/gemini.js +176 -0
  49. package/lib/agent-cli-provider/adapters/gemini.js.map +1 -0
  50. package/lib/agent-cli-provider/adapters/index.d.ts +9 -0
  51. package/lib/agent-cli-provider/adapters/index.d.ts.map +1 -0
  52. package/lib/agent-cli-provider/adapters/index.js +95 -0
  53. package/lib/agent-cli-provider/adapters/index.js.map +1 -0
  54. package/lib/agent-cli-provider/adapters/opencode.d.ts +3 -0
  55. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -0
  56. package/lib/agent-cli-provider/adapters/opencode.js +228 -0
  57. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -0
  58. package/lib/agent-cli-provider/contract-actions.d.ts +5 -0
  59. package/lib/agent-cli-provider/contract-actions.d.ts.map +1 -0
  60. package/lib/agent-cli-provider/contract-actions.js +140 -0
  61. package/lib/agent-cli-provider/contract-actions.js.map +1 -0
  62. package/lib/agent-cli-provider/contract-env.d.ts +11 -0
  63. package/lib/agent-cli-provider/contract-env.d.ts.map +1 -0
  64. package/lib/agent-cli-provider/contract-env.js +113 -0
  65. package/lib/agent-cli-provider/contract-env.js.map +1 -0
  66. package/lib/agent-cli-provider/contract-envelope.d.ts +49 -0
  67. package/lib/agent-cli-provider/contract-envelope.d.ts.map +1 -0
  68. package/lib/agent-cli-provider/contract-envelope.js +52 -0
  69. package/lib/agent-cli-provider/contract-envelope.js.map +1 -0
  70. package/lib/agent-cli-provider/contract-errors.d.ts +22 -0
  71. package/lib/agent-cli-provider/contract-errors.d.ts.map +1 -0
  72. package/lib/agent-cli-provider/contract-errors.js +71 -0
  73. package/lib/agent-cli-provider/contract-errors.js.map +1 -0
  74. package/lib/agent-cli-provider/contract-fallback.d.ts +7 -0
  75. package/lib/agent-cli-provider/contract-fallback.d.ts.map +1 -0
  76. package/lib/agent-cli-provider/contract-fallback.js +102 -0
  77. package/lib/agent-cli-provider/contract-fallback.js.map +1 -0
  78. package/lib/agent-cli-provider/contract-invoke.d.ts +5 -0
  79. package/lib/agent-cli-provider/contract-invoke.d.ts.map +1 -0
  80. package/lib/agent-cli-provider/contract-invoke.js +89 -0
  81. package/lib/agent-cli-provider/contract-invoke.js.map +1 -0
  82. package/lib/agent-cli-provider/contract-options.d.ts +3 -0
  83. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -0
  84. package/lib/agent-cli-provider/contract-options.js +140 -0
  85. package/lib/agent-cli-provider/contract-options.js.map +1 -0
  86. package/lib/agent-cli-provider/contract-parse.d.ts +15 -0
  87. package/lib/agent-cli-provider/contract-parse.d.ts.map +1 -0
  88. package/lib/agent-cli-provider/contract-parse.js +81 -0
  89. package/lib/agent-cli-provider/contract-parse.js.map +1 -0
  90. package/lib/agent-cli-provider/contract-support.d.ts +18 -0
  91. package/lib/agent-cli-provider/contract-support.d.ts.map +1 -0
  92. package/lib/agent-cli-provider/contract-support.js +154 -0
  93. package/lib/agent-cli-provider/contract-support.js.map +1 -0
  94. package/lib/agent-cli-provider/contract.d.ts +13 -0
  95. package/lib/agent-cli-provider/contract.d.ts.map +1 -0
  96. package/lib/agent-cli-provider/contract.js +30 -0
  97. package/lib/agent-cli-provider/contract.js.map +1 -0
  98. package/lib/agent-cli-provider/env-safety.d.ts +11 -0
  99. package/lib/agent-cli-provider/env-safety.d.ts.map +1 -0
  100. package/lib/agent-cli-provider/env-safety.js +83 -0
  101. package/lib/agent-cli-provider/env-safety.js.map +1 -0
  102. package/lib/agent-cli-provider/errors.d.ts +5 -0
  103. package/lib/agent-cli-provider/errors.d.ts.map +1 -0
  104. package/lib/agent-cli-provider/errors.js +115 -0
  105. package/lib/agent-cli-provider/errors.js.map +1 -0
  106. package/lib/agent-cli-provider/executable.d.ts +3 -0
  107. package/lib/agent-cli-provider/executable.d.ts.map +1 -0
  108. package/lib/agent-cli-provider/executable.js +24 -0
  109. package/lib/agent-cli-provider/executable.js.map +1 -0
  110. package/lib/agent-cli-provider/index.d.ts +8 -0
  111. package/lib/agent-cli-provider/index.d.ts.map +1 -0
  112. package/lib/agent-cli-provider/index.js +31 -0
  113. package/lib/agent-cli-provider/index.js.map +1 -0
  114. package/lib/agent-cli-provider/invoke-evidence.d.ts +4 -0
  115. package/lib/agent-cli-provider/invoke-evidence.d.ts.map +1 -0
  116. package/lib/agent-cli-provider/invoke-evidence.js +17 -0
  117. package/lib/agent-cli-provider/invoke-evidence.js.map +1 -0
  118. package/lib/agent-cli-provider/json.d.ts +16 -0
  119. package/lib/agent-cli-provider/json.d.ts.map +1 -0
  120. package/lib/agent-cli-provider/json.js +110 -0
  121. package/lib/agent-cli-provider/json.js.map +1 -0
  122. package/lib/agent-cli-provider/log-prefix.d.ts +2 -0
  123. package/lib/agent-cli-provider/log-prefix.d.ts.map +1 -0
  124. package/lib/agent-cli-provider/log-prefix.js +22 -0
  125. package/lib/agent-cli-provider/log-prefix.js.map +1 -0
  126. package/lib/agent-cli-provider/process-runner.d.ts +17 -0
  127. package/lib/agent-cli-provider/process-runner.d.ts.map +1 -0
  128. package/lib/agent-cli-provider/process-runner.js +89 -0
  129. package/lib/agent-cli-provider/process-runner.js.map +1 -0
  130. package/lib/agent-cli-provider/redaction.d.ts +9 -0
  131. package/lib/agent-cli-provider/redaction.d.ts.map +1 -0
  132. package/lib/agent-cli-provider/redaction.js +227 -0
  133. package/lib/agent-cli-provider/redaction.js.map +1 -0
  134. package/lib/agent-cli-provider/schema.d.ts +5 -0
  135. package/lib/agent-cli-provider/schema.d.ts.map +1 -0
  136. package/lib/agent-cli-provider/schema.js +136 -0
  137. package/lib/agent-cli-provider/schema.js.map +1 -0
  138. package/lib/agent-cli-provider/single-agent-runtime.d.ts +15 -0
  139. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -0
  140. package/lib/agent-cli-provider/single-agent-runtime.js +228 -0
  141. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -0
  142. package/lib/agent-cli-provider/types.d.ts +194 -0
  143. package/lib/agent-cli-provider/types.d.ts.map +1 -0
  144. package/lib/agent-cli-provider/types.js +12 -0
  145. package/lib/agent-cli-provider/types.js.map +1 -0
  146. package/lib/completion.js +174 -0
  147. package/lib/detached-startup.js +220 -0
  148. package/lib/docker-config.js +220 -0
  149. package/lib/git-remote-utils.js +165 -0
  150. package/lib/id-detector.js +55 -0
  151. package/lib/provider-defaults.js +62 -0
  152. package/lib/provider-detection.js +59 -0
  153. package/lib/provider-names.js +57 -0
  154. package/lib/repo-settings.js +69 -0
  155. package/lib/settings/claude-auth.js +78 -0
  156. package/lib/settings.js +542 -0
  157. package/lib/start-cluster.js +321 -0
  158. package/lib/stream-json-parser.js +67 -0
  159. package/package.json +162 -0
  160. package/scripts/fix-node-pty-permissions.js +75 -0
  161. package/scripts/record-demo.sh +279 -0
  162. package/scripts/setup-merge-queue.sh +170 -0
  163. package/scripts/test-install.sh +40 -0
  164. package/scripts/validate-templates.js +107 -0
  165. package/src/agent/agent-config.js +266 -0
  166. package/src/agent/agent-context-builder.js +189 -0
  167. package/src/agent/agent-context-sections.js +338 -0
  168. package/src/agent/agent-context-sources.js +147 -0
  169. package/src/agent/agent-hook-executor.js +721 -0
  170. package/src/agent/agent-input-injector.js +141 -0
  171. package/src/agent/agent-lifecycle.js +982 -0
  172. package/src/agent/agent-quality-gate-schema.js +93 -0
  173. package/src/agent/agent-quality-gates-context.js +51 -0
  174. package/src/agent/agent-stuck-detector.js +256 -0
  175. package/src/agent/agent-task-executor.js +2028 -0
  176. package/src/agent/agent-trigger-evaluator.js +67 -0
  177. package/src/agent/context-metrics.js +160 -0
  178. package/src/agent/context-pack-builder.js +367 -0
  179. package/src/agent/context-replay-policy.js +51 -0
  180. package/src/agent/guidance-queue.js +77 -0
  181. package/src/agent/output-extraction.js +367 -0
  182. package/src/agent/output-reformatter.js +175 -0
  183. package/src/agent/pr-verification.js +653 -0
  184. package/src/agent/rate-limit-backoff.js +82 -0
  185. package/src/agent/schema-utils.js +146 -0
  186. package/src/agent/validation-platform.js +35 -0
  187. package/src/agent-cli-provider/adapters/claude-parser.ts +133 -0
  188. package/src/agent-cli-provider/adapters/claude-recovery.ts +203 -0
  189. package/src/agent-cli-provider/adapters/claude.ts +247 -0
  190. package/src/agent-cli-provider/adapters/codex-parser.ts +211 -0
  191. package/src/agent-cli-provider/adapters/codex.ts +217 -0
  192. package/src/agent-cli-provider/adapters/common.ts +124 -0
  193. package/src/agent-cli-provider/adapters/gemini.ts +243 -0
  194. package/src/agent-cli-provider/adapters/index.ts +126 -0
  195. package/src/agent-cli-provider/adapters/opencode.ts +286 -0
  196. package/src/agent-cli-provider/contract-actions.ts +150 -0
  197. package/src/agent-cli-provider/contract-env.ts +111 -0
  198. package/src/agent-cli-provider/contract-envelope.ts +110 -0
  199. package/src/agent-cli-provider/contract-errors.ts +66 -0
  200. package/src/agent-cli-provider/contract-fallback.ts +121 -0
  201. package/src/agent-cli-provider/contract-invoke.ts +104 -0
  202. package/src/agent-cli-provider/contract-options.ts +173 -0
  203. package/src/agent-cli-provider/contract-parse.ts +94 -0
  204. package/src/agent-cli-provider/contract-support.ts +167 -0
  205. package/src/agent-cli-provider/contract.ts +56 -0
  206. package/src/agent-cli-provider/env-safety.ts +82 -0
  207. package/src/agent-cli-provider/errors.ts +122 -0
  208. package/src/agent-cli-provider/executable.ts +24 -0
  209. package/src/agent-cli-provider/index.ts +83 -0
  210. package/src/agent-cli-provider/invoke-evidence.ts +18 -0
  211. package/src/agent-cli-provider/json.ts +114 -0
  212. package/src/agent-cli-provider/log-prefix.ts +20 -0
  213. package/src/agent-cli-provider/process-runner.ts +145 -0
  214. package/src/agent-cli-provider/redaction.ts +282 -0
  215. package/src/agent-cli-provider/schema.ts +115 -0
  216. package/src/agent-cli-provider/single-agent-runtime.ts +311 -0
  217. package/src/agent-cli-provider/types.ts +237 -0
  218. package/src/agent-wrapper.js +615 -0
  219. package/src/agents/git-pusher-template.js +705 -0
  220. package/src/attach/attach-client.js +438 -0
  221. package/src/attach/attach-server.js +543 -0
  222. package/src/attach/index.js +37 -0
  223. package/src/attach/protocol.js +220 -0
  224. package/src/attach/ring-buffer.js +121 -0
  225. package/src/attach/send-input.js +88 -0
  226. package/src/attach/socket-discovery.js +267 -0
  227. package/src/claude-task-runner.js +661 -0
  228. package/src/config-router.js +89 -0
  229. package/src/config-validator.js +2202 -0
  230. package/src/copy-worker.js +43 -0
  231. package/src/guidance-topics.js +10 -0
  232. package/src/input-helpers.js +65 -0
  233. package/src/isolation-manager.js +1734 -0
  234. package/src/issue-providers/README.md +305 -0
  235. package/src/issue-providers/azure-devops-provider.js +307 -0
  236. package/src/issue-providers/base-provider.js +232 -0
  237. package/src/issue-providers/github-provider.js +210 -0
  238. package/src/issue-providers/gitlab-provider.js +262 -0
  239. package/src/issue-providers/index.js +196 -0
  240. package/src/issue-providers/jira-provider.js +260 -0
  241. package/src/ledger.js +692 -0
  242. package/src/lib/gc.js +232 -0
  243. package/src/lib/safe-exec.js +88 -0
  244. package/src/logic-engine.js +201 -0
  245. package/src/message-buffer.js +81 -0
  246. package/src/message-bus-bridge.js +144 -0
  247. package/src/message-bus.js +256 -0
  248. package/src/name-generator.js +232 -0
  249. package/src/orchestrator.js +3924 -0
  250. package/src/preflight.js +712 -0
  251. package/src/process-metrics.js +608 -0
  252. package/src/providers/anthropic/index.js +3 -0
  253. package/src/providers/base-provider.js +355 -0
  254. package/src/providers/capabilities.js +60 -0
  255. package/src/providers/google/index.js +3 -0
  256. package/src/providers/index.js +293 -0
  257. package/src/providers/openai/index.js +3 -0
  258. package/src/providers/opencode/index.js +3 -0
  259. package/src/quality-gates.js +143 -0
  260. package/src/schemas/sub-cluster.js +208 -0
  261. package/src/state-snapshot.js +398 -0
  262. package/src/state-snapshotter.js +142 -0
  263. package/src/status-footer.js +1026 -0
  264. package/src/sub-cluster-wrapper.js +693 -0
  265. package/src/task-runner.js +30 -0
  266. package/src/template-resolver.js +425 -0
  267. package/src/template-validation/index.js +338 -0
  268. package/src/template-validation/simulate-consensus-gates.js +324 -0
  269. package/src/template-validation/simulate-random-topology.js +541 -0
  270. package/src/template-validation/simulate-two-stage-validation.js +270 -0
  271. package/src/worktree-claude-config.js +135 -0
  272. package/src/worktree-tooling-env.js +150 -0
  273. package/task-lib/attachable-watcher.js +381 -0
  274. package/task-lib/commands/clean.js +50 -0
  275. package/task-lib/commands/episodes.js +105 -0
  276. package/task-lib/commands/get-log-path.js +23 -0
  277. package/task-lib/commands/kill.js +32 -0
  278. package/task-lib/commands/list.js +105 -0
  279. package/task-lib/commands/logs.js +439 -0
  280. package/task-lib/commands/resume.js +42 -0
  281. package/task-lib/commands/run.js +57 -0
  282. package/task-lib/commands/schedule.js +105 -0
  283. package/task-lib/commands/scheduler-cmd.js +96 -0
  284. package/task-lib/commands/schedules.js +148 -0
  285. package/task-lib/commands/status.js +44 -0
  286. package/task-lib/commands/unschedule.js +16 -0
  287. package/task-lib/completion.js +9 -0
  288. package/task-lib/config.js +11 -0
  289. package/task-lib/name-generator.js +230 -0
  290. package/task-lib/package.json +3 -0
  291. package/task-lib/provider-helper-runtime.js +29 -0
  292. package/task-lib/runner.js +190 -0
  293. package/task-lib/scheduler.js +252 -0
  294. package/task-lib/store.js +529 -0
  295. package/task-lib/watcher.js +305 -0
@@ -0,0 +1,1734 @@
1
+ /**
2
+ * IsolationManager - Docker container lifecycle for isolated cluster execution
3
+ *
4
+ * Handles:
5
+ * - Container creation with workspace mounts
6
+ * - Credential injection for provider CLIs
7
+ * - Command execution inside containers
8
+ * - Container cleanup on stop/kill
9
+ */
10
+
11
+ const { spawn, spawnSync } = require('child_process');
12
+ const { Worker } = require('worker_threads');
13
+ const crypto = require('crypto');
14
+ const path = require('path');
15
+ const os = require('os');
16
+ const fs = require('fs');
17
+ const { loadSettings } = require('../lib/settings');
18
+ const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/claude-auth');
19
+ const { normalizeProviderName } = require('../lib/provider-names');
20
+ const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
21
+ const { getProvider } = require('./providers');
22
+ const { readRepoSettings } = require('../lib/repo-settings');
23
+
24
+ const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
25
+
26
+ function runSync(command, args, options = {}) {
27
+ const timeout = options.timeout ?? 30000;
28
+ const result = spawnSync(command, args, { ...options, timeout });
29
+ if (result.status !== 0 || result.error) {
30
+ const detail = result.error?.message || result.stderr?.toString() || 'no stderr';
31
+ const error = new Error(
32
+ `Command ${command} failed with status ${result.status ?? 'null'}: ${detail}`
33
+ );
34
+ error.status = result.status;
35
+ error.stderr = result.stderr?.toString();
36
+ throw error;
37
+ }
38
+ return result.stdout?.toString() || '';
39
+ }
40
+
41
+ function runShellSync(command, options = {}) {
42
+ return runSync('/bin/bash', ['-lc', command], options);
43
+ }
44
+
45
+ function expandHomePath(value) {
46
+ if (!value) return value;
47
+ if (value === '~') return os.homedir();
48
+ return value.replace(/^~(?=\/|$)/, os.homedir());
49
+ }
50
+
51
+ function pathContains(base, target) {
52
+ const resolvedBase = path.resolve(base);
53
+ const resolvedTarget = path.resolve(target);
54
+ if (resolvedBase === resolvedTarget) return true;
55
+ return resolvedTarget.startsWith(resolvedBase + path.sep);
56
+ }
57
+
58
+ function parsePositiveInteger(value, fieldName) {
59
+ if (value === undefined || value === null || value === '') {
60
+ return null;
61
+ }
62
+
63
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) {
64
+ return value;
65
+ }
66
+
67
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
68
+ const parsed = Number.parseInt(value.trim(), 10);
69
+ if (Number.isSafeInteger(parsed) && parsed > 0) {
70
+ return parsed;
71
+ }
72
+ }
73
+
74
+ throw new Error(`${fieldName} must be a positive integer number of milliseconds`);
75
+ }
76
+
77
+ function resolveWorktreeSetupTimeoutMs(repoSettings = {}, options = {}) {
78
+ const candidates = [
79
+ { value: options.worktreeSetupTimeoutMs, field: 'options.worktreeSetupTimeoutMs' },
80
+ { value: options.setupTimeoutMs, field: 'options.setupTimeoutMs' },
81
+ {
82
+ value: process.env.ZEROSHOT_WORKTREE_SETUP_TIMEOUT_MS,
83
+ field: 'ZEROSHOT_WORKTREE_SETUP_TIMEOUT_MS',
84
+ },
85
+ { value: repoSettings.worktree?.setupTimeoutMs, field: 'worktree.setupTimeoutMs' },
86
+ ];
87
+
88
+ for (const candidate of candidates) {
89
+ const parsed = parsePositiveInteger(candidate.value, candidate.field);
90
+ if (parsed !== null) {
91
+ return parsed;
92
+ }
93
+ }
94
+
95
+ return DEFAULT_WORKTREE_SETUP_TIMEOUT_MS;
96
+ }
97
+
98
+ const DEFAULT_IMAGE = 'zeroshot-cluster-base';
99
+
100
+ class IsolationManager {
101
+ constructor(options = {}) {
102
+ this.image = options.image || DEFAULT_IMAGE;
103
+ this.containers = new Map(); // clusterId -> containerId
104
+ this.isolatedDirs = new Map(); // clusterId -> { path, originalDir }
105
+ this.clusterConfigDirs = new Map(); // clusterId -> configDirPath
106
+ this.worktrees = new Map(); // clusterId -> { path, branch, repoRoot }
107
+ this._exitWatchers = new Map(); // clusterId -> ChildProcess
108
+ }
109
+
110
+ /**
111
+ * Get GitHub token from gh CLI config (hosts.yml)
112
+ * Works with older gh CLI versions that don't have `gh auth token` command
113
+ * @returns {string|null}
114
+ * @private
115
+ */
116
+ _getGhToken() {
117
+ try {
118
+ const hostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
119
+ if (!fs.existsSync(hostsPath)) return null;
120
+
121
+ const content = fs.readFileSync(hostsPath, 'utf8');
122
+ // Match oauth_token: <token> in YAML
123
+ const match = content.match(/oauth_token:\s*(\S+)/);
124
+ return match ? match[1] : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Create and start a container for a cluster
132
+ * @param {string} clusterId - Cluster ID
133
+ * @param {object} config - Container config
134
+ * @param {string} config.workDir - Working directory to mount
135
+ * @param {string} [config.image] - Docker image (default: zeroshot-cluster-base)
136
+ * @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
137
+ * @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
138
+ * @param {boolean} [config.noMounts=false] - Disable all credential mounts
139
+ * @param {string} [config.provider] - Provider name for credential warnings
140
+ * @returns {Promise<string>} Container ID
141
+ */
142
+ async createContainer(clusterId, config) {
143
+ const image = config.image || this.image;
144
+ let workDir = config.workDir || process.cwd();
145
+ const containerName = `zeroshot-cluster-${clusterId}`;
146
+ const reuseExisting = config.reuseExistingWorkspace || false;
147
+
148
+ const runningContainerId = this._getRunningContainerId(clusterId);
149
+ if (runningContainerId) {
150
+ return runningContainerId;
151
+ }
152
+
153
+ this._removeContainerByName(containerName);
154
+
155
+ workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
156
+
157
+ const settings = loadSettings();
158
+ const providerName = normalizeProviderName(
159
+ config.provider || settings.defaultProvider || 'claude'
160
+ );
161
+ const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
162
+
163
+ const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
164
+ console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
165
+
166
+ const args = this._buildBaseDockerArgs({
167
+ containerName,
168
+ workDir,
169
+ containerHome,
170
+ clusterConfigDir,
171
+ });
172
+
173
+ const mountedHosts = this._applyCredentialMounts(args, config, settings, containerHome);
174
+ this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);
175
+
176
+ args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
177
+
178
+ const containerId = await this._spawnContainer(clusterId, args, workDir);
179
+ this._watchContainerExit(clusterId, containerId, config.onExit);
180
+ return containerId;
181
+ }
182
+
183
+ _watchContainerExit(clusterId, containerId, onExit) {
184
+ if (typeof onExit !== 'function') {
185
+ return;
186
+ }
187
+
188
+ const existing = this._exitWatchers.get(clusterId);
189
+ if (existing) {
190
+ try {
191
+ existing.kill('SIGKILL');
192
+ } catch {
193
+ // Ignore
194
+ }
195
+ this._exitWatchers.delete(clusterId);
196
+ }
197
+
198
+ const proc = spawn('docker', ['wait', containerId], { stdio: ['ignore', 'pipe', 'ignore'] });
199
+ this._exitWatchers.set(clusterId, proc);
200
+
201
+ let stdout = '';
202
+ proc.stdout.on('data', (data) => {
203
+ stdout += data.toString();
204
+ });
205
+
206
+ const finalize = () => {
207
+ if (this._exitWatchers.get(clusterId) === proc) {
208
+ this._exitWatchers.delete(clusterId);
209
+ }
210
+ const code = parseInt(stdout.trim(), 10);
211
+ onExit({ clusterId, containerId, exitCode: Number.isFinite(code) ? code : null });
212
+ };
213
+
214
+ proc.on('close', finalize);
215
+ proc.on('error', finalize);
216
+ }
217
+
218
+ _getRunningContainerId(clusterId) {
219
+ const existingId = this.containers.get(clusterId);
220
+ if (!existingId) {
221
+ return null;
222
+ }
223
+
224
+ return this._isContainerRunning(existingId) ? existingId : null;
225
+ }
226
+
227
+ async _prepareIsolatedWorkspace(clusterId, workDir, reuseExisting) {
228
+ if (!this._isGitRepo(workDir)) {
229
+ return workDir;
230
+ }
231
+
232
+ this.isolatedDirs = this.isolatedDirs || new Map();
233
+ const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
234
+
235
+ if (reuseExisting && fs.existsSync(isolatedPath)) {
236
+ console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
237
+ this.isolatedDirs.set(clusterId, {
238
+ path: isolatedPath,
239
+ originalDir: workDir,
240
+ });
241
+ return isolatedPath;
242
+ }
243
+
244
+ const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
245
+ this.isolatedDirs.set(clusterId, {
246
+ path: isolatedDir,
247
+ originalDir: workDir,
248
+ });
249
+ console.log(`[IsolationManager] Created isolated copy at ${isolatedDir}`);
250
+ return isolatedDir;
251
+ }
252
+
253
+ _buildBaseDockerArgs({ containerName, workDir, containerHome, clusterConfigDir }) {
254
+ return [
255
+ 'run',
256
+ '-d',
257
+ '--name',
258
+ containerName,
259
+ '-v',
260
+ `${workDir}:/workspace`,
261
+ '-v',
262
+ '/var/run/docker.sock:/var/run/docker.sock',
263
+ '--group-add',
264
+ this._getDockerGid(),
265
+ '-v',
266
+ `${clusterConfigDir}:${containerHome}/.claude`,
267
+ ];
268
+ }
269
+
270
+ _resolveMountConfig(config, settings) {
271
+ if (config.mounts) {
272
+ return config.mounts;
273
+ }
274
+
275
+ if (process.env.ZEROSHOT_DOCKER_MOUNTS) {
276
+ try {
277
+ return JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
278
+ } catch {
279
+ console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
280
+ return settings.dockerMounts;
281
+ }
282
+ }
283
+
284
+ return settings.dockerMounts;
285
+ }
286
+
287
+ _applyCredentialMounts(args, config, settings, containerHome) {
288
+ const mountedHosts = [];
289
+ if (config.noMounts) {
290
+ return mountedHosts;
291
+ }
292
+
293
+ const mountConfig = this._resolveMountConfig(config, settings);
294
+ const mounts = resolveMounts(mountConfig, { containerHome });
295
+ const claudeContainerPath = path.posix.join(containerHome, '.claude');
296
+
297
+ for (const mount of mounts) {
298
+ if (mount.container === claudeContainerPath) {
299
+ console.warn(
300
+ `[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
301
+ '(Claude config is managed by zeroshot).'
302
+ );
303
+ continue;
304
+ }
305
+
306
+ const hostPath = expandHomePath(mount.host);
307
+
308
+ try {
309
+ const stat = fs.statSync(hostPath);
310
+ if (hostPath.endsWith('config') && !stat.isFile()) {
311
+ continue;
312
+ }
313
+ } catch {
314
+ continue;
315
+ }
316
+
317
+ const mountSpec = mount.readonly
318
+ ? `${hostPath}:${mount.container}:ro`
319
+ : `${hostPath}:${mount.container}`;
320
+ args.push('-v', mountSpec);
321
+ mountedHosts.push(hostPath);
322
+ }
323
+
324
+ const envToPass = this._collectDockerEnvVars(mountConfig, settings);
325
+ for (const [key, value] of Object.entries(envToPass)) {
326
+ args.push('-e', `${key}=${value}`);
327
+ }
328
+
329
+ return mountedHosts;
330
+ }
331
+
332
+ _collectDockerEnvVars(mountConfig, settings) {
333
+ const envToPass = {};
334
+ const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
335
+
336
+ for (const spec of envSpecs) {
337
+ if (spec.forced) {
338
+ envToPass[spec.name] = spec.value;
339
+ } else if (process.env[spec.name]) {
340
+ envToPass[spec.name] = process.env[spec.name];
341
+ }
342
+ }
343
+
344
+ for (const envVar of CLAUDE_AUTH_ENV_VARS) {
345
+ if (process.env[envVar]) {
346
+ envToPass[envVar] = process.env[envVar];
347
+ }
348
+ }
349
+
350
+ const authEnv = resolveClaudeAuth(settings);
351
+ for (const [key, value] of Object.entries(authEnv)) {
352
+ if (!(key in envToPass)) {
353
+ envToPass[key] = value;
354
+ }
355
+ }
356
+
357
+ return envToPass;
358
+ }
359
+
360
+ _warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
361
+ if (providerName === 'claude') {
362
+ return;
363
+ }
364
+
365
+ const provider = getProvider(providerName);
366
+ const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
367
+ const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
368
+ const hasCredentialMount = mountedHosts.some((hostPath) =>
369
+ expandedCreds.some(
370
+ (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
371
+ )
372
+ );
373
+
374
+ if (!hasCredentialMount && expandedCreds.length > 0) {
375
+ const exampleHost = credentialPaths[0];
376
+ const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
377
+ const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
378
+ console.warn(
379
+ `[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
380
+ `Add one with --mount ${exampleHost}:${exampleContainer}:ro`
381
+ );
382
+ }
383
+ }
384
+
385
+ _spawnContainer(clusterId, args, workDir) {
386
+ return new Promise((resolve, reject) => {
387
+ const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
388
+
389
+ let stdout = '';
390
+ let stderr = '';
391
+
392
+ proc.stdout.on('data', (data) => {
393
+ stdout += data;
394
+ });
395
+ proc.stderr.on('data', (data) => {
396
+ stderr += data;
397
+ });
398
+
399
+ proc.on('close', async (code) => {
400
+ if (code !== 0) {
401
+ reject(new Error(`Failed to create container: ${stderr}`));
402
+ return;
403
+ }
404
+
405
+ const containerId = stdout.trim().substring(0, 12);
406
+ this.containers.set(clusterId, containerId);
407
+
408
+ try {
409
+ console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
410
+ if (fs.existsSync(path.join(workDir, 'package.json'))) {
411
+ await this._installDependenciesWithRetry(clusterId);
412
+ }
413
+ } catch (err) {
414
+ console.warn(
415
+ `[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
416
+ );
417
+ }
418
+
419
+ resolve(containerId);
420
+ });
421
+
422
+ proc.on('error', (err) => {
423
+ reject(new Error(`Docker spawn error: ${err.message}`));
424
+ });
425
+ });
426
+ }
427
+
428
+ async _installDependenciesWithRetry(clusterId) {
429
+ console.log(`[IsolationManager] Installing npm dependencies in container...`);
430
+
431
+ const maxRetries = 3;
432
+ const baseDelay = 2000; // 2 seconds
433
+ const installCommand = [
434
+ 'sh',
435
+ '-c',
436
+ [
437
+ 'if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then',
438
+ 'echo "__deps_present__";',
439
+ 'exit 0;',
440
+ 'fi;',
441
+ 'if ! command -v npm >/dev/null 2>&1; then',
442
+ 'echo "__npm_missing__";',
443
+ 'exit 127;',
444
+ 'fi;',
445
+ 'if [ -d /pre-baked-deps/node_modules ]; then',
446
+ 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true;',
447
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline;',
448
+ 'install_code=$?;',
449
+ 'if [ $install_code -ne 0 ]; then',
450
+ 'rm -rf node_modules;',
451
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
452
+ 'fi;',
453
+ 'else',
454
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
455
+ 'fi',
456
+ ].join(' '),
457
+ ];
458
+
459
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
460
+ try {
461
+ const installResult = await this.execInContainer(clusterId, installCommand, {});
462
+ const stdout = installResult.stdout || '';
463
+
464
+ if (installResult.code === 0) {
465
+ if (stdout.includes('__deps_present__')) {
466
+ console.log(
467
+ `[IsolationManager] ✓ Dependencies already installed (skipping npm install)`
468
+ );
469
+ } else {
470
+ console.log(`[IsolationManager] ✓ Dependencies installed`);
471
+ }
472
+ return;
473
+ }
474
+
475
+ const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
476
+ if (attempt < maxRetries) {
477
+ const delay = baseDelay * Math.pow(2, attempt - 1);
478
+ console.warn(
479
+ `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
480
+ );
481
+ console.warn(`[IsolationManager] Error: ${errorOutput}`);
482
+ await new Promise((resolve) => setTimeout(resolve, delay));
483
+ } else {
484
+ console.warn(
485
+ `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
486
+ );
487
+ }
488
+ } catch (execErr) {
489
+ if (attempt < maxRetries) {
490
+ const delay = baseDelay * Math.pow(2, attempt - 1);
491
+ console.warn(
492
+ `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
493
+ );
494
+ console.warn(`[IsolationManager] Error: ${execErr.message}`);
495
+ await new Promise((resolve) => setTimeout(resolve, delay));
496
+ } else {
497
+ throw execErr;
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Execute a command inside the container
505
+ * @param {string} clusterId - Cluster ID
506
+ * @param {string[]} command - Command and arguments
507
+ * @param {object} [options] - Exec options
508
+ * @param {boolean} [options.interactive] - Use -it flags
509
+ * @param {object} [options.env] - Environment variables
510
+ * @param {number} [options.timeout=30000] - Timeout in ms (0 = no timeout). Prevents infinite hangs.
511
+ * @returns {Promise<{stdout: string, stderr: string, code: number}>}
512
+ */
513
+ execInContainer(clusterId, command, options = {}) {
514
+ const containerId = this.containers.get(clusterId);
515
+ if (!containerId) {
516
+ throw new Error(`No container found for cluster ${clusterId}`);
517
+ }
518
+
519
+ const args = ['exec'];
520
+
521
+ if (options.interactive) {
522
+ args.push('-it');
523
+ }
524
+
525
+ // Add environment variables
526
+ if (options.env) {
527
+ for (const [key, value] of Object.entries(options.env)) {
528
+ args.push('-e', `${key}=${value}`);
529
+ }
530
+ }
531
+
532
+ args.push(containerId, ...command);
533
+
534
+ // Default timeout: 30 seconds (prevents infinite hangs)
535
+ const timeout = options.timeout ?? 30000;
536
+
537
+ return new Promise((resolve, reject) => {
538
+ const proc = spawn('docker', args, {
539
+ stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
540
+ });
541
+
542
+ let stdout = '';
543
+ let stderr = '';
544
+ let timedOut = false;
545
+ let timeoutId = null;
546
+
547
+ // Set up timeout if specified (0 = no timeout)
548
+ if (timeout > 0) {
549
+ timeoutId = setTimeout(() => {
550
+ timedOut = true;
551
+ proc.kill('SIGKILL');
552
+ }, timeout);
553
+ }
554
+
555
+ if (!options.interactive) {
556
+ proc.stdout.on('data', (data) => {
557
+ stdout += data;
558
+ });
559
+ proc.stderr.on('data', (data) => {
560
+ stderr += data;
561
+ });
562
+ }
563
+
564
+ proc.on('close', (code) => {
565
+ if (timeoutId) clearTimeout(timeoutId);
566
+ if (timedOut) {
567
+ reject(new Error(`Docker exec timed out after ${timeout}ms`));
568
+ } else {
569
+ resolve({ stdout, stderr, code });
570
+ }
571
+ });
572
+
573
+ proc.on('error', (err) => {
574
+ if (timeoutId) clearTimeout(timeoutId);
575
+ reject(new Error(`Docker exec error: ${err.message}`));
576
+ });
577
+ });
578
+ }
579
+
580
+ /**
581
+ * Spawn a PTY-like process inside the container
582
+ * Returns a child process that can be used like a PTY
583
+ * @param {string} clusterId - Cluster ID
584
+ * @param {string[]} command - Command and arguments
585
+ * @param {object} [options] - Spawn options
586
+ * @returns {ChildProcess}
587
+ */
588
+ spawnInContainer(clusterId, command, options = {}) {
589
+ const containerId = this.containers.get(clusterId);
590
+ if (!containerId) {
591
+ throw new Error(`No container found for cluster ${clusterId}`);
592
+ }
593
+
594
+ // IMPORTANT: Must use -i flag for interactive stdin/stdout communication with commands like 'cat'
595
+ // If omitted, docker exec will not properly connect stdin, causing piped input to be ignored
596
+ // This is required for PTY-like behavior where child process stdin/stdout are used
597
+ const args = ['exec', '-i'];
598
+
599
+ // Add environment variables
600
+ if (options.env) {
601
+ for (const [key, value] of Object.entries(options.env)) {
602
+ args.push('-e', `${key}=${value}`);
603
+ }
604
+ }
605
+
606
+ args.push(containerId, ...command);
607
+
608
+ return spawn('docker', args, {
609
+ stdio: ['pipe', 'pipe', 'pipe'],
610
+ ...options.spawnOptions,
611
+ });
612
+ }
613
+
614
+ /**
615
+ * Stop a container
616
+ * @param {string} clusterId - Cluster ID
617
+ * @param {number} [timeout=10] - Timeout in seconds before SIGKILL
618
+ * @returns {Promise<void>}
619
+ */
620
+ stopContainer(clusterId, timeout = 10, explicitContainerId = null) {
621
+ // Use explicit containerId (from restored state) or in-memory Map
622
+ const containerId = explicitContainerId || this.containers.get(clusterId);
623
+ if (!containerId) {
624
+ return; // Already stopped or never started
625
+ }
626
+
627
+ return new Promise((resolve) => {
628
+ const proc = spawn('docker', ['stop', '-t', String(timeout), containerId], {
629
+ stdio: ['pipe', 'pipe', 'pipe'],
630
+ });
631
+
632
+ proc.on('close', () => {
633
+ resolve();
634
+ });
635
+
636
+ proc.on('error', () => {
637
+ resolve(); // Ignore errors on stop
638
+ });
639
+ });
640
+ }
641
+
642
+ /**
643
+ * Remove a container
644
+ * @param {string} clusterId - Cluster ID
645
+ * @param {boolean} [force=false] - Force remove running container
646
+ * @returns {Promise<void>}
647
+ */
648
+ removeContainer(clusterId, force = false, explicitContainerId = null) {
649
+ // Use explicit containerId (from restored state) or in-memory Map
650
+ const containerId = explicitContainerId || this.containers.get(clusterId);
651
+ if (!containerId) {
652
+ return;
653
+ }
654
+
655
+ const args = ['rm'];
656
+ if (force) {
657
+ args.push('-f');
658
+ }
659
+ args.push(containerId);
660
+
661
+ return new Promise((resolve) => {
662
+ const proc = spawn('docker', args, {
663
+ stdio: ['pipe', 'pipe', 'pipe'],
664
+ });
665
+
666
+ proc.on('close', () => {
667
+ this.containers.delete(clusterId);
668
+ resolve();
669
+ });
670
+
671
+ proc.on('error', () => {
672
+ this.containers.delete(clusterId);
673
+ resolve();
674
+ });
675
+ });
676
+ }
677
+
678
+ /**
679
+ * Stop and remove a container, and optionally clean up isolated dir/config
680
+ * @param {string} clusterId - Cluster ID
681
+ * @param {object} [options] - Cleanup options
682
+ * @param {boolean} [options.preserveWorkspace=false] - If true, keep the isolated workspace (for resume capability)
683
+ * @returns {Promise<void>}
684
+ */
685
+ async cleanup(clusterId, options = {}) {
686
+ const preserveWorkspace = options.preserveWorkspace || false;
687
+
688
+ await this.stopContainer(clusterId);
689
+ await this.removeContainer(clusterId);
690
+
691
+ // Clean up isolated directory if one was created (unless preserveWorkspace is set)
692
+ if (this.isolatedDirs?.has(clusterId)) {
693
+ const isolatedInfo = this.isolatedDirs.get(clusterId);
694
+
695
+ if (preserveWorkspace) {
696
+ console.log(
697
+ `[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
698
+ );
699
+ // Don't delete - but DON'T remove from Map either, resume() needs it
700
+ } else {
701
+ console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
702
+
703
+ // Preserve Terraform state before deleting isolated directory
704
+ this._preserveTerraformState(clusterId, isolatedInfo.path);
705
+
706
+ // Remove the isolated directory
707
+ try {
708
+ fs.rmSync(isolatedInfo.path, { recursive: true, force: true });
709
+ } catch {
710
+ // Ignore
711
+ }
712
+ this.isolatedDirs.delete(clusterId);
713
+ }
714
+ }
715
+
716
+ // Clean up cluster config dir (always - it's recreated on resume)
717
+ this._cleanupClusterConfigDir(clusterId);
718
+ }
719
+
720
+ /**
721
+ * Create an isolated copy of a directory with fresh git repo
722
+ * @private
723
+ * @param {string} clusterId - Cluster ID
724
+ * @param {string} sourceDir - Source directory to copy
725
+ * @returns {Promise<string>} Path to isolated directory
726
+ */
727
+ async _createIsolatedCopy(clusterId, sourceDir) {
728
+ const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
729
+
730
+ // Clean up existing dir
731
+ if (fs.existsSync(isolatedPath)) {
732
+ fs.rmSync(isolatedPath, { recursive: true, force: true });
733
+ }
734
+
735
+ // Create directory
736
+ fs.mkdirSync(isolatedPath, { recursive: true });
737
+
738
+ // Copy files (excluding .git and common build artifacts)
739
+ await this._copyDirExcluding(sourceDir, isolatedPath, [
740
+ '.git',
741
+ 'node_modules',
742
+ '.next',
743
+ 'dist',
744
+ 'build',
745
+ '__pycache__',
746
+ '.pytest_cache',
747
+ '.mypy_cache',
748
+ '.ruff_cache',
749
+ '.venv',
750
+ 'venv',
751
+ '.tox',
752
+ '.eggs',
753
+ '*.egg-info',
754
+ 'coverage',
755
+ '.coverage',
756
+ '.nyc_output',
757
+ '.DS_Store',
758
+ 'Thumbs.db',
759
+ ]);
760
+
761
+ // Get remote URL from original repo (for PR creation)
762
+ let remoteUrl = null;
763
+ try {
764
+ remoteUrl = runSync('git', ['remote', 'get-url', 'origin'], {
765
+ cwd: sourceDir,
766
+ encoding: 'utf8',
767
+ stdio: 'pipe',
768
+ }).trim();
769
+ } catch {
770
+ // No remote configured in source
771
+ }
772
+
773
+ // Initialize fresh git repo with all setup in a single batched command
774
+ // This reduces ~500ms overhead (5 execSync calls @ ~100ms each) to ~100ms (1 call)
775
+ // Issue #22: Batch git operations for 5-10% startup reduction
776
+ const branchName = `zeroshot/${clusterId}`;
777
+
778
+ // Build authenticated remote URL if source had one (needed for git push / PR creation)
779
+ let authRemoteUrl = null;
780
+ if (remoteUrl) {
781
+ authRemoteUrl = remoteUrl;
782
+ const token = this._getGhToken();
783
+ if (token && remoteUrl.startsWith('https://github.com/')) {
784
+ // Convert https://github.com/org/repo.git to https://x-access-token:TOKEN@github.com/org/repo.git
785
+ authRemoteUrl = remoteUrl.replace(
786
+ 'https://github.com/',
787
+ `https://x-access-token:${token}@github.com/`
788
+ );
789
+ }
790
+ }
791
+
792
+ runSync('git', ['init'], { cwd: isolatedPath, stdio: 'pipe' });
793
+ if (authRemoteUrl) {
794
+ runSync('git', ['remote', 'add', 'origin', authRemoteUrl], {
795
+ cwd: isolatedPath,
796
+ stdio: 'pipe',
797
+ });
798
+ }
799
+ runSync('git', ['add', '-A'], { cwd: isolatedPath, stdio: 'pipe' });
800
+ runSync('git', ['commit', '-m', 'Initial commit (isolated copy)', '--allow-empty'], {
801
+ cwd: isolatedPath,
802
+ stdio: 'pipe',
803
+ });
804
+ runSync('git', ['checkout', '-b', branchName], { cwd: isolatedPath, stdio: 'pipe' });
805
+
806
+ return isolatedPath;
807
+ }
808
+
809
+ /**
810
+ * Copy directory excluding certain paths using parallel worker threads
811
+ * Supports exact matches and glob patterns (*.ext)
812
+ *
813
+ * Performance optimization for large repos (10k+ files):
814
+ * - Phase 1: Collect all files async (non-blocking traversal)
815
+ * - Phase 2: Create directory structure (must be sequential)
816
+ * - Phase 3: Copy files in parallel using worker threads
817
+ *
818
+ * @private
819
+ * @param {string} src - Source directory
820
+ * @param {string} dest - Destination directory
821
+ * @param {string[]} exclude - Patterns to exclude
822
+ * @returns {Promise<void>}
823
+ */
824
+ async _copyDirExcluding(src, dest, exclude) {
825
+ // Phase 1: Collect all files and directories
826
+ const files = [];
827
+ const directories = new Set();
828
+
829
+ const shouldIgnoreFsError = (err) =>
830
+ err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
831
+
832
+ const shouldExcludeEntry = (entryName) => {
833
+ return exclude.some((pattern) => {
834
+ if (pattern.startsWith('*.')) {
835
+ return entryName.endsWith(pattern.slice(1));
836
+ }
837
+ return entryName === pattern;
838
+ });
839
+ };
840
+
841
+ const ensureParentDirTracked = (relativePath) => {
842
+ if (relativePath) {
843
+ directories.add(relativePath);
844
+ }
845
+ };
846
+
847
+ const readEntries = (currentSrc) => {
848
+ try {
849
+ return fs.readdirSync(currentSrc, { withFileTypes: true });
850
+ } catch (err) {
851
+ if (shouldIgnoreFsError(err)) {
852
+ return [];
853
+ }
854
+ throw err;
855
+ }
856
+ };
857
+
858
+ function handleEntry(entry, srcPath, relPath, relativePath) {
859
+ if (entry.isSymbolicLink()) {
860
+ const targetStats = fs.statSync(srcPath);
861
+ if (targetStats.isDirectory()) {
862
+ directories.add(relPath);
863
+ collectFiles(srcPath, relPath);
864
+ return;
865
+ }
866
+
867
+ files.push(relPath);
868
+ ensureParentDirTracked(relativePath);
869
+ return;
870
+ }
871
+
872
+ if (entry.isDirectory()) {
873
+ directories.add(relPath);
874
+ collectFiles(srcPath, relPath);
875
+ return;
876
+ }
877
+
878
+ files.push(relPath);
879
+ ensureParentDirTracked(relativePath);
880
+ }
881
+
882
+ function collectFiles(currentSrc, relativePath = '') {
883
+ const entries = readEntries(currentSrc);
884
+
885
+ for (const entry of entries) {
886
+ if (shouldExcludeEntry(entry.name)) {
887
+ continue;
888
+ }
889
+
890
+ const srcPath = path.join(currentSrc, entry.name);
891
+ const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
892
+
893
+ try {
894
+ handleEntry(entry, srcPath, relPath, relativePath);
895
+ } catch (err) {
896
+ if (shouldIgnoreFsError(err)) {
897
+ continue;
898
+ }
899
+ throw err;
900
+ }
901
+ }
902
+ }
903
+
904
+ collectFiles(src);
905
+
906
+ // Phase 2: Create directory structure (sequential - must exist before file copy)
907
+ // Sort directories by depth to ensure parents are created before children
908
+ const sortedDirs = Array.from(directories).sort((a, b) => {
909
+ const depthA = a.split(path.sep).length;
910
+ const depthB = b.split(path.sep).length;
911
+ return depthA - depthB;
912
+ });
913
+
914
+ for (const dir of sortedDirs) {
915
+ const destDir = path.join(dest, dir);
916
+ try {
917
+ fs.mkdirSync(destDir, { recursive: true });
918
+ } catch (err) {
919
+ if (err.code !== 'EEXIST') {
920
+ throw err;
921
+ }
922
+ }
923
+ }
924
+
925
+ // Phase 3: Copy files in parallel using worker threads
926
+ // For small file counts (<100), use synchronous copy (worker overhead not worth it)
927
+ if (files.length < 100) {
928
+ for (const relPath of files) {
929
+ const srcPath = path.join(src, relPath);
930
+ const destPath = path.join(dest, relPath);
931
+ try {
932
+ fs.copyFileSync(srcPath, destPath);
933
+ } catch (err) {
934
+ if (err.code !== 'EACCES' && err.code !== 'EPERM' && err.code !== 'ENOENT') {
935
+ throw err;
936
+ }
937
+ }
938
+ }
939
+ return;
940
+ }
941
+
942
+ // Use worker threads for larger file counts
943
+ const numWorkers = Math.min(4, os.cpus().length);
944
+ const chunkSize = Math.ceil(files.length / numWorkers);
945
+ const workerPath = path.join(__dirname, 'copy-worker.js');
946
+
947
+ // Split files into chunks for workers
948
+ const chunks = [];
949
+ for (let i = 0; i < files.length; i += chunkSize) {
950
+ chunks.push(files.slice(i, i + chunkSize));
951
+ }
952
+
953
+ // Spawn workers and wait for completion
954
+ const workerPromises = chunks.map((chunk) => {
955
+ return new Promise((resolve, reject) => {
956
+ const worker = new Worker(workerPath, {
957
+ workerData: {
958
+ files: chunk,
959
+ sourceBase: src,
960
+ destBase: dest,
961
+ },
962
+ });
963
+
964
+ worker.on('message', (result) => {
965
+ resolve(result);
966
+ });
967
+
968
+ worker.on('error', (err) => {
969
+ reject(err);
970
+ });
971
+
972
+ worker.on('exit', (code) => {
973
+ if (code !== 0) {
974
+ reject(new Error(`Worker exited with code ${code}`));
975
+ }
976
+ });
977
+ });
978
+ });
979
+
980
+ // Wait for all workers to complete (proper async/await - no busy-wait!)
981
+ // FIX: Previous version used busy-wait which blocked the event loop,
982
+ // preventing worker thread messages from being processed (timeout bug)
983
+ await Promise.all(workerPromises);
984
+ }
985
+
986
+ /**
987
+ * Get container ID for a cluster
988
+ * @param {string} clusterId - Cluster ID
989
+ * @returns {string|undefined}
990
+ */
991
+ getContainerId(clusterId) {
992
+ return this.containers.get(clusterId);
993
+ }
994
+
995
+ /**
996
+ * Check if a cluster has an active container
997
+ * @param {string} clusterId - Cluster ID
998
+ * @returns {boolean}
999
+ */
1000
+ hasContainer(clusterId) {
1001
+ const containerId = this.containers.get(clusterId);
1002
+ if (!containerId) return false;
1003
+ return this._isContainerRunning(containerId);
1004
+ }
1005
+
1006
+ /**
1007
+ * Get Claude config directory
1008
+ * @private
1009
+ */
1010
+ _getClaudeConfigDir() {
1011
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
1012
+ }
1013
+
1014
+ /**
1015
+ * Create a fresh Claude config directory for a cluster (avoids permission issues from host)
1016
+ * Copies only essential files: .credentials.json
1017
+ * @private
1018
+ * @param {string} clusterId - Cluster ID
1019
+ * @param {string} containerHome - Container home directory path (e.g., '/root' or '/home/node')
1020
+ * @returns {string} Path to cluster-specific config directory
1021
+ */
1022
+ _createClusterConfigDir(clusterId, containerHome = '/root') {
1023
+ const sourceDir = this._getClaudeConfigDir();
1024
+ const configDir = path.join(os.tmpdir(), 'zeroshot-cluster-configs', clusterId);
1025
+
1026
+ // Clean up existing dir
1027
+ if (fs.existsSync(configDir)) {
1028
+ fs.rmSync(configDir, { recursive: true, force: true });
1029
+ }
1030
+
1031
+ // Create fresh directory and required subdirectories
1032
+ fs.mkdirSync(configDir, { recursive: true });
1033
+ const hooksDir = path.join(configDir, 'hooks');
1034
+ fs.mkdirSync(hooksDir, { recursive: true });
1035
+ // CRITICAL: Claude CLI writes session files to projects/ subdirectory
1036
+ const projectsDir = path.join(configDir, 'projects');
1037
+ fs.mkdirSync(projectsDir, { recursive: true });
1038
+
1039
+ // Copy only credentials file (essential for auth)
1040
+ const credentialsFile = path.join(sourceDir, '.credentials.json');
1041
+ if (fs.existsSync(credentialsFile)) {
1042
+ fs.copyFileSync(credentialsFile, path.join(configDir, '.credentials.json'));
1043
+ }
1044
+
1045
+ // Copy hook script to block AskUserQuestion (CRITICAL for autonomous execution)
1046
+ const hookScriptSrc = path.join(__dirname, '..', 'hooks', 'block-ask-user-question.py');
1047
+ const hookScriptDst = path.join(hooksDir, 'block-ask-user-question.py');
1048
+ if (fs.existsSync(hookScriptSrc)) {
1049
+ fs.copyFileSync(hookScriptSrc, hookScriptDst);
1050
+ fs.chmodSync(hookScriptDst, 0o755);
1051
+ }
1052
+
1053
+ // Create settings.json with PreToolUse hook to block AskUserQuestion
1054
+ // This PREVENTS agents from asking questions in non-interactive mode
1055
+ const clusterSettings = {
1056
+ hooks: {
1057
+ PreToolUse: [
1058
+ {
1059
+ matcher: 'AskUserQuestion',
1060
+ hooks: [
1061
+ {
1062
+ type: 'command',
1063
+ command: `${containerHome}/.claude/hooks/block-ask-user-question.py`,
1064
+ },
1065
+ ],
1066
+ },
1067
+ ],
1068
+ },
1069
+ };
1070
+ fs.writeFileSync(
1071
+ path.join(configDir, 'settings.json'),
1072
+ JSON.stringify(clusterSettings, null, 2)
1073
+ );
1074
+
1075
+ // Track for cleanup
1076
+ this.clusterConfigDirs = this.clusterConfigDirs || new Map();
1077
+ this.clusterConfigDirs.set(clusterId, configDir);
1078
+
1079
+ return configDir;
1080
+ }
1081
+
1082
+ /**
1083
+ * Clean up cluster config directory
1084
+ * @private
1085
+ * @param {string} clusterId - Cluster ID
1086
+ */
1087
+ _cleanupClusterConfigDir(clusterId) {
1088
+ if (!this.clusterConfigDirs?.has(clusterId)) return;
1089
+
1090
+ const configDir = this.clusterConfigDirs.get(clusterId);
1091
+ try {
1092
+ fs.rmSync(configDir, { recursive: true, force: true });
1093
+ } catch {
1094
+ // Ignore
1095
+ }
1096
+ this.clusterConfigDirs.delete(clusterId);
1097
+ }
1098
+
1099
+ /**
1100
+ * Preserve Terraform state files before cleanup
1101
+ * Checks both terraform/ subdirectory and root directory
1102
+ * @private
1103
+ * @param {string} clusterId - Cluster ID
1104
+ * @param {string} isolatedPath - Path to isolated directory
1105
+ */
1106
+ _preserveTerraformState(clusterId, isolatedPath) {
1107
+ const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
1108
+ const checkDirs = [isolatedPath, path.join(isolatedPath, 'terraform')];
1109
+ const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
1110
+
1111
+ const hasStateFiles = (checkDir) => {
1112
+ if (!fs.existsSync(checkDir)) {
1113
+ return false;
1114
+ }
1115
+
1116
+ return stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
1117
+ };
1118
+
1119
+ const copyStateFiles = (checkDir) => {
1120
+ let copied = false;
1121
+
1122
+ for (const file of stateFiles) {
1123
+ const srcPath = path.join(checkDir, file);
1124
+ if (!fs.existsSync(srcPath)) {
1125
+ continue;
1126
+ }
1127
+
1128
+ const destPath = path.join(stateDir, file);
1129
+ try {
1130
+ fs.copyFileSync(srcPath, destPath);
1131
+ console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
1132
+ copied = true;
1133
+ } catch (err) {
1134
+ console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
1135
+ }
1136
+ }
1137
+
1138
+ return copied;
1139
+ };
1140
+
1141
+ let foundState = false;
1142
+
1143
+ for (const checkDir of checkDirs) {
1144
+ if (!hasStateFiles(checkDir)) {
1145
+ continue;
1146
+ }
1147
+
1148
+ fs.mkdirSync(stateDir, { recursive: true });
1149
+ foundState = copyStateFiles(checkDir);
1150
+ break;
1151
+ }
1152
+
1153
+ if (!foundState) {
1154
+ console.log(`[IsolationManager] No Terraform state found to preserve`);
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Get host's docker group GID (for Docker socket access inside container)
1160
+ * @private
1161
+ * @returns {string} Docker group GID
1162
+ */
1163
+ _getDockerGid() {
1164
+ try {
1165
+ // Get docker group info: "docker:x:999:user1,user2"
1166
+ const result = runSync('getent', ['group', 'docker'], { encoding: 'utf8' });
1167
+ const gid = result.split(':')[2];
1168
+ return gid.trim();
1169
+ } catch {
1170
+ // Fallback: common docker GID is 999
1171
+ console.warn('[IsolationManager] Could not detect docker GID, using default 999');
1172
+ return '999';
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Check if a container is running
1178
+ * @private
1179
+ */
1180
+ _isContainerRunning(containerId) {
1181
+ try {
1182
+ const result = runSync('docker', ['inspect', '-f', '{{.State.Running}}', containerId], {
1183
+ encoding: 'utf8',
1184
+ });
1185
+ return result.trim() === 'true';
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ }
1190
+
1191
+ /**
1192
+ * Remove container by name (cleanup before create)
1193
+ * @private
1194
+ */
1195
+ _removeContainerByName(name) {
1196
+ try {
1197
+ runSync('docker', ['rm', '-f', name], { encoding: 'utf8' });
1198
+ } catch {
1199
+ // Ignore - container doesn't exist
1200
+ }
1201
+ }
1202
+
1203
+ /**
1204
+ * Check if Docker is available
1205
+ * @returns {boolean}
1206
+ */
1207
+ static isDockerAvailable() {
1208
+ try {
1209
+ // Require both CLI binary and a reachable daemon.
1210
+ runSync('docker', ['info'], { encoding: 'utf8', stdio: 'pipe' });
1211
+ return true;
1212
+ } catch {
1213
+ return false;
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Check if the base image exists
1219
+ * @param {string} [image] - Image name to check
1220
+ * @returns {boolean}
1221
+ */
1222
+ static imageExists(image = DEFAULT_IMAGE) {
1223
+ try {
1224
+ runSync('docker', ['image', 'inspect', image], {
1225
+ encoding: 'utf8',
1226
+ stdio: 'pipe',
1227
+ });
1228
+ return true;
1229
+ } catch {
1230
+ return false;
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * Build the Docker image with retry logic
1236
+ * @param {string} [image] - Image name to build
1237
+ * @param {number} [maxRetries=3] - Maximum retry attempts
1238
+ * @returns {Promise<void>}
1239
+ */
1240
+ static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3) {
1241
+ // Repository root is one level up from src/
1242
+ const repoRoot = path.join(__dirname, '..');
1243
+ const dockerfilePath = path.join(repoRoot, 'docker', 'zeroshot-cluster', 'Dockerfile');
1244
+
1245
+ if (!fs.existsSync(dockerfilePath)) {
1246
+ throw new Error(`Dockerfile not found at ${dockerfilePath}`);
1247
+ }
1248
+
1249
+ console.log(`[IsolationManager] Building Docker image '${image}'...`);
1250
+
1251
+ const baseDelay = 3000; // 3 seconds
1252
+
1253
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1254
+ try {
1255
+ // CRITICAL: Run from repo root so build context includes package.json and src/
1256
+ // Use -f flag to specify Dockerfile location
1257
+ runSync('docker', ['build', '-f', 'docker/zeroshot-cluster/Dockerfile', '-t', image, '.'], {
1258
+ cwd: repoRoot,
1259
+ encoding: 'utf8',
1260
+ stdio: 'inherit',
1261
+ });
1262
+
1263
+ console.log(`[IsolationManager] ✓ Image '${image}' built successfully`);
1264
+ return;
1265
+ } catch (err) {
1266
+ if (attempt < maxRetries) {
1267
+ const delay = baseDelay * Math.pow(2, attempt - 1);
1268
+ console.warn(
1269
+ `[IsolationManager] ⚠️ Docker build failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
1270
+ );
1271
+ console.warn(`[IsolationManager] Error: ${err.message}`);
1272
+ await new Promise((resolve) => setTimeout(resolve, delay));
1273
+ } else {
1274
+ throw new Error(
1275
+ `Failed to build Docker image '${image}' after ${maxRetries} attempts: ${err.message}`
1276
+ );
1277
+ }
1278
+ }
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Ensure Docker image exists, building it if necessary
1284
+ * @param {string} [image] - Image name to ensure
1285
+ * @param {boolean} [autoBuild=true] - Auto-build if missing
1286
+ * @returns {Promise<void>}
1287
+ */
1288
+ static async ensureImage(image = DEFAULT_IMAGE, autoBuild = true) {
1289
+ if (this.imageExists(image)) {
1290
+ return;
1291
+ }
1292
+
1293
+ if (!autoBuild) {
1294
+ throw new Error(
1295
+ `Docker image '${image}' not found. Build it with:\n` +
1296
+ ` docker build -t ${image} zeroshot/cluster/docker/zeroshot-cluster/`
1297
+ );
1298
+ }
1299
+
1300
+ console.log(`[IsolationManager] Image '${image}' not found, building automatically...`);
1301
+ await this.buildImage(image);
1302
+ }
1303
+
1304
+ /**
1305
+ * Check if directory is a git repository
1306
+ * @private
1307
+ */
1308
+ _isGitRepo(dir) {
1309
+ try {
1310
+ runSync('git', ['rev-parse', '--git-dir'], {
1311
+ cwd: dir,
1312
+ encoding: 'utf8',
1313
+ stdio: 'pipe',
1314
+ });
1315
+ return true;
1316
+ } catch {
1317
+ return false;
1318
+ }
1319
+ }
1320
+
1321
+ /**
1322
+ * Get the git repository root for a directory
1323
+ * @private
1324
+ */
1325
+ _getGitRoot(dir) {
1326
+ try {
1327
+ return runSync('git', ['rev-parse', '--show-toplevel'], {
1328
+ cwd: dir,
1329
+ encoding: 'utf8',
1330
+ stdio: 'pipe',
1331
+ }).trim();
1332
+ } catch {
1333
+ return null;
1334
+ }
1335
+ }
1336
+
1337
+ /**
1338
+ * Create worktree-based isolation for a cluster (lightweight alternative to Docker)
1339
+ * Creates a git worktree at ~/.zeroshot/worktrees/{clusterId}
1340
+ * @param {string} clusterId - Cluster ID
1341
+ * @param {string} workDir - Original working directory (must be a git repo)
1342
+ * @returns {{ path: string, branch: string, repoRoot: string }}
1343
+ */
1344
+ createWorktreeIsolation(clusterId, workDir, options = {}) {
1345
+ if (!this._isGitRepo(workDir)) {
1346
+ throw new Error(
1347
+ `Worktree isolation requires a git repository. ${workDir} is not a git repo.`
1348
+ );
1349
+ }
1350
+
1351
+ const worktreeInfo = this.createWorktree(clusterId, workDir, options);
1352
+ this.worktrees.set(clusterId, worktreeInfo);
1353
+
1354
+ console.log(`[IsolationManager] Created worktree isolation at ${worktreeInfo.path}`);
1355
+ console.log(`[IsolationManager] Branch: ${worktreeInfo.branch}`);
1356
+
1357
+ return worktreeInfo;
1358
+ }
1359
+
1360
+ /**
1361
+ * Clean up worktree isolation for a cluster
1362
+ * @param {string} clusterId - Cluster ID
1363
+ * @param {object} [options] - Cleanup options
1364
+ * @param {boolean} [options.preserveBranch=true] - Keep the branch after removing worktree
1365
+ */
1366
+ cleanupWorktreeIsolation(clusterId, options = {}) {
1367
+ const worktreeInfo = this.worktrees.get(clusterId);
1368
+ if (!worktreeInfo) {
1369
+ return; // No worktree to clean up
1370
+ }
1371
+
1372
+ this.removeWorktree(worktreeInfo, options);
1373
+ this.worktrees.delete(clusterId);
1374
+
1375
+ console.log(`[IsolationManager] Cleaned up worktree isolation for ${clusterId}`);
1376
+ }
1377
+
1378
+ /**
1379
+ * Create a git worktree for isolated work
1380
+ * @param {string} clusterId - Cluster ID (used as branch name)
1381
+ * @param {string} workDir - Original working directory
1382
+ * @param {object} [options] - Worktree creation options
1383
+ * @param {string} [options.baseRef] - Git ref to base the worktree branch on
1384
+ * @param {number} [options.worktreeSetupTimeoutMs] - Setup command timeout in milliseconds
1385
+ * @returns {{ path: string, branch: string, repoRoot: string }}
1386
+ */
1387
+ createWorktree(clusterId, workDir, options = {}) {
1388
+ const repoRoot = this._getGitRoot(workDir);
1389
+ if (!repoRoot) {
1390
+ throw new Error(`Cannot find git root for ${workDir}`);
1391
+ }
1392
+ console.log(`[IsolationManager] Worktree setup phase: preparing git worktree`);
1393
+ console.log(`[IsolationManager] Source repo: ${repoRoot}`);
1394
+
1395
+ // Disk space guard: prevent worktree creation when disk is critically low.
1396
+ // Uses standalone gc module (no Orchestrator dependency — avoids circular require).
1397
+ const { gcOrphanedWorktrees, getDiskSpace, countOrphanedWorktrees } = require('./lib/gc');
1398
+ const MIN_DISK_GB = 10;
1399
+ const AUTO_GC_THRESHOLD_PERCENT = 80;
1400
+
1401
+ const diskCheck = getDiskSpace(os.homedir());
1402
+ if (diskCheck) {
1403
+ // Auto-GC when disk usage exceeds threshold
1404
+ if (diskCheck.usagePercent > AUTO_GC_THRESHOLD_PERCENT) {
1405
+ const orphanCount = countOrphanedWorktrees();
1406
+ if (orphanCount > 0) {
1407
+ console.log(
1408
+ `[IsolationManager] Disk at ${diskCheck.usagePercent.toFixed(0)}% usage, ` +
1409
+ `running auto-GC on ${orphanCount} orphaned worktree(s)...`
1410
+ );
1411
+ const gcResult = gcOrphanedWorktrees();
1412
+ if (gcResult.orphanedWorktrees.length > 0 || gcResult.orphanedDbs.length > 0) {
1413
+ console.log(
1414
+ `[IsolationManager] Auto-GC: removed ${gcResult.orphanedWorktrees.length} worktree(s), ` +
1415
+ `${gcResult.orphanedDbs.length} db file(s)`
1416
+ );
1417
+ }
1418
+ }
1419
+
1420
+ // Re-check disk after GC
1421
+ const afterGc = getDiskSpace(os.homedir());
1422
+ if (afterGc && afterGc.available < MIN_DISK_GB * 1e9) {
1423
+ throw new Error(
1424
+ `Insufficient disk space: ${(afterGc.available / 1e9).toFixed(1)}GB available, ` +
1425
+ `need ${MIN_DISK_GB}GB minimum. Run 'zeroshot gc' to clean up orphaned worktrees, ` +
1426
+ `or 'zeroshot purge' to remove all cluster data.`
1427
+ );
1428
+ }
1429
+ } else if (diskCheck.available < MIN_DISK_GB * 1e9) {
1430
+ throw new Error(
1431
+ `Insufficient disk space: ${(diskCheck.available / 1e9).toFixed(1)}GB available, ` +
1432
+ `need ${MIN_DISK_GB}GB minimum. Run 'zeroshot gc' to clean up orphaned worktrees, ` +
1433
+ `or 'zeroshot purge' to remove all cluster data.`
1434
+ );
1435
+ }
1436
+ }
1437
+
1438
+ // Priority: 1) options.baseRef, 2) repo settings, 3) HEAD (default)
1439
+ let worktreeBaseRef = options.baseRef || null;
1440
+ let worktreeSetupCommand = null;
1441
+ let repoSettings = {};
1442
+ try {
1443
+ const repoSettingsResult = readRepoSettings(repoRoot);
1444
+ repoSettings = repoSettingsResult.settings || {};
1445
+ const candidate = repoSettings.worktree?.baseRef;
1446
+ worktreeSetupCommand = repoSettings.worktree?.setup || null;
1447
+ if (
1448
+ !worktreeBaseRef &&
1449
+ typeof candidate === 'string' &&
1450
+ /^[A-Za-z0-9._/-]+$/.test(candidate.trim())
1451
+ ) {
1452
+ worktreeBaseRef = candidate.trim();
1453
+ }
1454
+ } catch {
1455
+ // ignore
1456
+ }
1457
+ const worktreeSetupTimeoutMs = resolveWorktreeSetupTimeoutMs(repoSettings, options);
1458
+
1459
+ // Best-effort ensure origin/<branch> exists locally if requested.
1460
+ if (worktreeBaseRef && worktreeBaseRef.startsWith('origin/')) {
1461
+ const branch = worktreeBaseRef.slice('origin/'.length);
1462
+ try {
1463
+ runSync('git', ['fetch', 'origin', branch], {
1464
+ cwd: repoRoot,
1465
+ encoding: 'utf8',
1466
+ stdio: 'pipe',
1467
+ });
1468
+ } catch {
1469
+ // ignore
1470
+ }
1471
+ }
1472
+
1473
+ // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
1474
+ const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1475
+ let branchName = baseBranchName;
1476
+
1477
+ // Worktree path in persistent location (survives reboots)
1478
+ const worktreePath = path.join(os.homedir(), '.zeroshot', 'worktrees', clusterId);
1479
+
1480
+ // Ensure parent directory exists
1481
+ const parentDir = path.dirname(worktreePath);
1482
+ if (!fs.existsSync(parentDir)) {
1483
+ fs.mkdirSync(parentDir, { recursive: true });
1484
+ }
1485
+
1486
+ // Best-effort cleanup of stale worktree metadata and directory.
1487
+ // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1488
+ // git may keep the branch "checked out" and block deletion/reuse.
1489
+ try {
1490
+ runSync('git', ['worktree', 'remove', '--force', worktreePath], {
1491
+ cwd: repoRoot,
1492
+ encoding: 'utf8',
1493
+ stdio: 'pipe',
1494
+ });
1495
+ } catch {
1496
+ // ignore
1497
+ }
1498
+ try {
1499
+ runSync('git', ['worktree', 'prune'], { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1500
+ } catch {
1501
+ // ignore
1502
+ }
1503
+ try {
1504
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1505
+ } catch {
1506
+ // ignore
1507
+ }
1508
+
1509
+ const baseRef = worktreeBaseRef || 'HEAD';
1510
+ console.log(`[IsolationManager] Worktree base ref: ${baseRef}`);
1511
+ console.log(`[IsolationManager] Worktree path: ${worktreePath}`);
1512
+
1513
+ // Create worktree with new branch based on baseRef (retry on branch collision/in-use)
1514
+ for (let attempt = 0; attempt < 10; attempt++) {
1515
+ // Best-effort delete if branch exists and is not in use by another worktree.
1516
+ try {
1517
+ runSync('git', ['branch', '-D', branchName], {
1518
+ cwd: repoRoot,
1519
+ encoding: 'utf8',
1520
+ stdio: 'pipe',
1521
+ });
1522
+ } catch {
1523
+ // ignore
1524
+ }
1525
+
1526
+ try {
1527
+ runSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
1528
+ cwd: repoRoot,
1529
+ encoding: 'utf8',
1530
+ stdio: 'pipe',
1531
+ });
1532
+ console.log(`[IsolationManager] Worktree setup phase: created branch ${branchName}`);
1533
+ break;
1534
+ } catch (err) {
1535
+ const stderr = (
1536
+ err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1537
+ ).toLowerCase();
1538
+ const isBranchCollision =
1539
+ stderr.includes('already exists') ||
1540
+ stderr.includes('cannot delete branch') ||
1541
+ stderr.includes('checked out');
1542
+
1543
+ if (attempt < 9 && isBranchCollision) {
1544
+ branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1545
+ try {
1546
+ runSync('git', ['worktree', 'prune'], {
1547
+ cwd: repoRoot,
1548
+ encoding: 'utf8',
1549
+ stdio: 'pipe',
1550
+ });
1551
+ } catch {
1552
+ // ignore
1553
+ }
1554
+ continue;
1555
+ }
1556
+ throw err;
1557
+ }
1558
+ }
1559
+
1560
+ // Run repo-configured setup command (e.g. npm ci)
1561
+ if (worktreeSetupCommand && typeof worktreeSetupCommand === 'string') {
1562
+ console.log(`[IsolationManager] Worktree setup phase: running repo setup command`);
1563
+ console.log(
1564
+ `[IsolationManager] Setup command: ${worktreeSetupCommand} ` +
1565
+ `(timeout ${worktreeSetupTimeoutMs}ms)`
1566
+ );
1567
+ console.log(`[IsolationManager] Setup command output follows`);
1568
+ runShellSync(worktreeSetupCommand, {
1569
+ cwd: worktreePath,
1570
+ encoding: 'utf8',
1571
+ stdio: 'inherit',
1572
+ timeout: worktreeSetupTimeoutMs,
1573
+ });
1574
+ console.log(`[IsolationManager] Worktree setup phase: repo setup command complete`);
1575
+ }
1576
+
1577
+ console.log(`[IsolationManager] ✓ Worktree setup complete`);
1578
+
1579
+ return {
1580
+ path: worktreePath,
1581
+ branch: branchName,
1582
+ repoRoot,
1583
+ };
1584
+ }
1585
+
1586
+ /**
1587
+ * Kill detached processes whose command line is scoped to a worktree path.
1588
+ * Claude Code hook daemons survive agent shutdown because they run detached
1589
+ * and keep the worktree path in argv; cleaning them up here prevents orphaned
1590
+ * daemons from accumulating across stopped or deleted worktrees.
1591
+ * @param {string} worktreePath
1592
+ * @returns {number[]} PIDs signalled with SIGTERM
1593
+ */
1594
+ cleanupWorktreeProcesses(worktreePath) {
1595
+ if (!worktreePath || process.platform === 'win32') {
1596
+ return [];
1597
+ }
1598
+
1599
+ let processList = '';
1600
+ try {
1601
+ processList = runSync('ps', ['axww', '-o', 'pid=,command='], {
1602
+ encoding: 'utf8',
1603
+ stdio: 'pipe',
1604
+ timeout: 5000,
1605
+ });
1606
+ } catch {
1607
+ return [];
1608
+ }
1609
+
1610
+ const resolvedWorktreePath = path.resolve(worktreePath);
1611
+ const signalledPids = [];
1612
+
1613
+ for (const line of String(processList).split('\n')) {
1614
+ const match = line.trim().match(/^(\d+)\s+(.*)$/);
1615
+ if (!match) {
1616
+ continue;
1617
+ }
1618
+
1619
+ const pid = Number.parseInt(match[1], 10);
1620
+ const command = match[2];
1621
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) {
1622
+ continue;
1623
+ }
1624
+
1625
+ if (!command.includes(resolvedWorktreePath)) {
1626
+ continue;
1627
+ }
1628
+
1629
+ try {
1630
+ process.kill(pid, 'SIGTERM');
1631
+ signalledPids.push(pid);
1632
+ } catch (error) {
1633
+ if (error.code !== 'ESRCH') {
1634
+ throw error;
1635
+ }
1636
+ }
1637
+ }
1638
+
1639
+ return signalledPids;
1640
+ }
1641
+
1642
+ /**
1643
+ * Remove a git worktree
1644
+ * @param {{ path: string, branch: string, repoRoot: string }} worktreeInfo
1645
+ * @param {object} [options] - Removal options
1646
+ * @param {boolean} [options.deleteBranch=false] - Also delete the branch
1647
+ */
1648
+ removeWorktree(worktreeInfo, _options = {}) {
1649
+ this.cleanupWorktreeProcesses(worktreeInfo.path);
1650
+
1651
+ // Tear down any Docker Compose services that may have been started in this worktree.
1652
+ // Without this, containers keep running with host port mappings after the worktree is deleted,
1653
+ // blocking port allocation for the main project or other worktrees.
1654
+ const composePath = path.join(worktreeInfo.path, 'docker-compose.yml');
1655
+ if (fs.existsSync(composePath)) {
1656
+ try {
1657
+ runSync('docker', ['compose', 'down', '--remove-orphans', '--volumes', '--timeout', '10'], {
1658
+ cwd: worktreeInfo.path,
1659
+ encoding: 'utf8',
1660
+ stdio: 'pipe',
1661
+ timeout: 30000,
1662
+ });
1663
+ } catch {
1664
+ // Best-effort: compose project may not have been started, or Docker may not be running
1665
+ }
1666
+ }
1667
+
1668
+ // Remove the worktree (prefer git so metadata is cleaned up).
1669
+ try {
1670
+ runSync('git', ['worktree', 'remove', '--force', worktreeInfo.path], {
1671
+ cwd: worktreeInfo.repoRoot,
1672
+ encoding: 'utf8',
1673
+ stdio: 'pipe',
1674
+ });
1675
+ } catch {
1676
+ // If git worktree metadata is stale, prune and retry once.
1677
+ try {
1678
+ runSync('git', ['worktree', 'prune'], {
1679
+ cwd: worktreeInfo.repoRoot,
1680
+ encoding: 'utf8',
1681
+ stdio: 'pipe',
1682
+ });
1683
+ } catch {
1684
+ // ignore
1685
+ }
1686
+ try {
1687
+ runSync('git', ['worktree', 'remove', '--force', worktreeInfo.path], {
1688
+ cwd: worktreeInfo.repoRoot,
1689
+ encoding: 'utf8',
1690
+ stdio: 'pipe',
1691
+ });
1692
+ } catch {
1693
+ // Last resort: delete directory, then prune stale worktree entries.
1694
+ try {
1695
+ fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1696
+ } catch {
1697
+ // ignore
1698
+ }
1699
+ try {
1700
+ runSync('git', ['worktree', 'prune'], {
1701
+ cwd: worktreeInfo.repoRoot,
1702
+ encoding: 'utf8',
1703
+ stdio: 'pipe',
1704
+ });
1705
+ } catch {
1706
+ // ignore
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ // Optionally delete the branch (only if not merged)
1712
+ // We leave this commented out - let the user decide to keep/delete branches
1713
+ // try {
1714
+ // execSync(`git branch -D "${worktreeInfo.branch}" 2>/dev/null`, {
1715
+ // cwd: worktreeInfo.repoRoot,
1716
+ // encoding: 'utf8',
1717
+ // stdio: 'pipe'
1718
+ // });
1719
+ // } catch {
1720
+ // // Ignore - branch may have been merged or deleted
1721
+ // }
1722
+ }
1723
+
1724
+ /**
1725
+ * Get worktree info for a cluster
1726
+ * @param {string} clusterId - Cluster ID
1727
+ * @returns {{ path: string, branch: string, repoRoot: string }|undefined}
1728
+ */
1729
+ getWorktreeInfo(clusterId) {
1730
+ return this.worktrees.get(clusterId);
1731
+ }
1732
+ }
1733
+
1734
+ module.exports = IsolationManager;