@the-open-engine/zeroshot 6.3.0 → 6.5.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 (149) hide show
  1. package/cli/commands/providers.js +11 -5
  2. package/cli/index.js +83 -17
  3. package/cli/lib/first-run.js +14 -3
  4. package/docker/zeroshot-cluster/Dockerfile +11 -1
  5. package/lib/agent-cli-provider/acp-stdio-runner.d.ts +4 -0
  6. package/lib/agent-cli-provider/acp-stdio-runner.d.ts.map +1 -0
  7. package/lib/agent-cli-provider/acp-stdio-runner.js +259 -0
  8. package/lib/agent-cli-provider/acp-stdio-runner.js.map +1 -0
  9. package/lib/agent-cli-provider/adapters/acp.d.ts +23 -0
  10. package/lib/agent-cli-provider/adapters/acp.d.ts.map +1 -0
  11. package/lib/agent-cli-provider/adapters/acp.js +373 -0
  12. package/lib/agent-cli-provider/adapters/acp.js.map +1 -0
  13. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  14. package/lib/agent-cli-provider/adapters/claude.js +2 -29
  15. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  16. package/lib/agent-cli-provider/adapters/copilot-parser.d.ts +3 -0
  17. package/lib/agent-cli-provider/adapters/copilot-parser.d.ts.map +1 -0
  18. package/lib/agent-cli-provider/adapters/copilot-parser.js +154 -0
  19. package/lib/agent-cli-provider/adapters/copilot-parser.js.map +1 -0
  20. package/lib/agent-cli-provider/adapters/copilot.d.ts +3 -0
  21. package/lib/agent-cli-provider/adapters/copilot.d.ts.map +1 -0
  22. package/lib/agent-cli-provider/adapters/copilot.js +176 -0
  23. package/lib/agent-cli-provider/adapters/copilot.js.map +1 -0
  24. package/lib/agent-cli-provider/adapters/gateway.d.ts +6 -0
  25. package/lib/agent-cli-provider/adapters/gateway.d.ts.map +1 -0
  26. package/lib/agent-cli-provider/adapters/gateway.js +161 -0
  27. package/lib/agent-cli-provider/adapters/gateway.js.map +1 -0
  28. package/lib/agent-cli-provider/adapters/gemini.d.ts.map +1 -1
  29. package/lib/agent-cli-provider/adapters/gemini.js +9 -2
  30. package/lib/agent-cli-provider/adapters/gemini.js.map +1 -1
  31. package/lib/agent-cli-provider/adapters/index.d.ts.map +1 -1
  32. package/lib/agent-cli-provider/adapters/index.js +6 -34
  33. package/lib/agent-cli-provider/adapters/index.js.map +1 -1
  34. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  35. package/lib/agent-cli-provider/adapters/opencode.js +5 -1
  36. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  37. package/lib/agent-cli-provider/adapters/pi.d.ts +3 -0
  38. package/lib/agent-cli-provider/adapters/pi.d.ts.map +1 -0
  39. package/lib/agent-cli-provider/adapters/pi.js +331 -0
  40. package/lib/agent-cli-provider/adapters/pi.js.map +1 -0
  41. package/lib/agent-cli-provider/claude-command.d.ts +6 -0
  42. package/lib/agent-cli-provider/claude-command.d.ts.map +1 -0
  43. package/lib/agent-cli-provider/claude-command.js +43 -0
  44. package/lib/agent-cli-provider/claude-command.js.map +1 -0
  45. package/lib/agent-cli-provider/contract-actions.d.ts.map +1 -1
  46. package/lib/agent-cli-provider/contract-actions.js +13 -17
  47. package/lib/agent-cli-provider/contract-actions.js.map +1 -1
  48. package/lib/agent-cli-provider/contract-invoke.d.ts.map +1 -1
  49. package/lib/agent-cli-provider/contract-invoke.js +10 -3
  50. package/lib/agent-cli-provider/contract-invoke.js.map +1 -1
  51. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  52. package/lib/agent-cli-provider/contract-options.js +57 -2
  53. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  54. package/lib/agent-cli-provider/contract-support.d.ts +1 -0
  55. package/lib/agent-cli-provider/contract-support.d.ts.map +1 -1
  56. package/lib/agent-cli-provider/contract-support.js +25 -3
  57. package/lib/agent-cli-provider/contract-support.js.map +1 -1
  58. package/lib/agent-cli-provider/errors.d.ts +1 -0
  59. package/lib/agent-cli-provider/errors.d.ts.map +1 -1
  60. package/lib/agent-cli-provider/errors.js +14 -5
  61. package/lib/agent-cli-provider/errors.js.map +1 -1
  62. package/lib/agent-cli-provider/gateway-client.d.ts +32 -0
  63. package/lib/agent-cli-provider/gateway-client.d.ts.map +1 -0
  64. package/lib/agent-cli-provider/gateway-client.js +135 -0
  65. package/lib/agent-cli-provider/gateway-client.js.map +1 -0
  66. package/lib/agent-cli-provider/gateway-runner.d.ts +9 -0
  67. package/lib/agent-cli-provider/gateway-runner.d.ts.map +1 -0
  68. package/lib/agent-cli-provider/gateway-runner.js +294 -0
  69. package/lib/agent-cli-provider/gateway-runner.js.map +1 -0
  70. package/lib/agent-cli-provider/gateway-tools.d.ts +14 -0
  71. package/lib/agent-cli-provider/gateway-tools.d.ts.map +1 -0
  72. package/lib/agent-cli-provider/gateway-tools.js +495 -0
  73. package/lib/agent-cli-provider/gateway-tools.js.map +1 -0
  74. package/lib/agent-cli-provider/index.d.ts +3 -2
  75. package/lib/agent-cli-provider/index.d.ts.map +1 -1
  76. package/lib/agent-cli-provider/index.js +14 -1
  77. package/lib/agent-cli-provider/index.js.map +1 -1
  78. package/lib/agent-cli-provider/invoke-evidence.d.ts +2 -2
  79. package/lib/agent-cli-provider/invoke-evidence.d.ts.map +1 -1
  80. package/lib/agent-cli-provider/invoke-evidence.js +17 -3
  81. package/lib/agent-cli-provider/invoke-evidence.js.map +1 -1
  82. package/lib/agent-cli-provider/provider-registry.d.ts +437 -0
  83. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -0
  84. package/lib/agent-cli-provider/provider-registry.js +411 -0
  85. package/lib/agent-cli-provider/provider-registry.js.map +1 -0
  86. package/lib/agent-cli-provider/single-agent-runtime.d.ts +7 -0
  87. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  88. package/lib/agent-cli-provider/single-agent-runtime.js +139 -19
  89. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  90. package/lib/agent-cli-provider/types.d.ts +106 -4
  91. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  92. package/lib/agent-cli-provider/types.js.map +1 -1
  93. package/lib/detached-startup.js +4 -2
  94. package/lib/docker-config.js +24 -13
  95. package/lib/provider-defaults.js +15 -4
  96. package/lib/provider-detection.js +2 -0
  97. package/lib/provider-names.js +32 -12
  98. package/lib/repo-settings.js +15 -1
  99. package/lib/run-mode.js +18 -6
  100. package/lib/run-plan.js +32 -0
  101. package/lib/settings/claude-auth.js +3 -6
  102. package/lib/settings.js +17 -2
  103. package/lib/setup-apply.js +300 -0
  104. package/lib/setup-journal.js +109 -0
  105. package/lib/setup-plan.js +406 -0
  106. package/lib/setup-undo.js +88 -0
  107. package/lib/start-cluster.js +34 -9
  108. package/lib/stream-json-parser.js +7 -5
  109. package/package.json +33 -3
  110. package/scripts/live-provider-smoke.js +221 -0
  111. package/src/agent/agent-lifecycle.js +7 -4
  112. package/src/agent/agent-quality-gate-schema.js +2 -2
  113. package/src/agent/agent-task-executor.js +41 -2
  114. package/src/agent-cli-provider/acp-stdio-runner.ts +314 -0
  115. package/src/agent-cli-provider/adapters/acp.ts +493 -0
  116. package/src/agent-cli-provider/adapters/claude.ts +2 -32
  117. package/src/agent-cli-provider/adapters/copilot-parser.ts +166 -0
  118. package/src/agent-cli-provider/adapters/copilot.ts +231 -0
  119. package/src/agent-cli-provider/adapters/gateway.ts +186 -0
  120. package/src/agent-cli-provider/adapters/gemini.ts +9 -2
  121. package/src/agent-cli-provider/adapters/index.ts +5 -36
  122. package/src/agent-cli-provider/adapters/opencode.ts +4 -1
  123. package/src/agent-cli-provider/adapters/pi.ts +410 -0
  124. package/src/agent-cli-provider/claude-command.ts +47 -0
  125. package/src/agent-cli-provider/contract-actions.ts +14 -17
  126. package/src/agent-cli-provider/contract-invoke.ts +12 -3
  127. package/src/agent-cli-provider/contract-options.ts +72 -3
  128. package/src/agent-cli-provider/contract-support.ts +30 -3
  129. package/src/agent-cli-provider/errors.ts +14 -4
  130. package/src/agent-cli-provider/gateway-client.ts +170 -0
  131. package/src/agent-cli-provider/gateway-runner.ts +353 -0
  132. package/src/agent-cli-provider/gateway-tools.ts +616 -0
  133. package/src/agent-cli-provider/index.ts +28 -0
  134. package/src/agent-cli-provider/invoke-evidence.ts +24 -3
  135. package/src/agent-cli-provider/provider-registry.ts +515 -0
  136. package/src/agent-cli-provider/single-agent-runtime.ts +192 -18
  137. package/src/agent-cli-provider/types.ts +145 -4
  138. package/src/config-validator.js +33 -15
  139. package/src/isolation-manager.js +124 -19
  140. package/src/ledger.js +3 -0
  141. package/src/message-bus.js +12 -2
  142. package/src/orchestrator.js +37 -17
  143. package/src/preflight.js +83 -30
  144. package/src/providers/capabilities.js +15 -42
  145. package/src/providers/index.js +52 -72
  146. package/src/worktree-claude-config.js +17 -0
  147. package/task-lib/commands/run.js +1 -0
  148. package/task-lib/provider-helper-runtime.js +11 -0
  149. package/task-lib/runner.js +7 -0
@@ -16,8 +16,13 @@ const os = require('os');
16
16
  const fs = require('fs');
17
17
  const { loadSettings } = require('../lib/settings');
18
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');
19
+ const { normalizeProviderName, getProviderMetadata } = require('../lib/provider-names');
20
+ const {
21
+ MOUNT_PRESETS,
22
+ resolveMounts,
23
+ resolveEnvs,
24
+ expandEnvPatterns,
25
+ } = require('../lib/docker-config');
21
26
  const { getProvider } = require('./providers');
22
27
  const { readRepoSettings } = require('../lib/repo-settings');
23
28
  const { provisionClaudeCredentials } = require('./claude-credentials');
@@ -98,6 +103,24 @@ function resolveWorktreeSetupTimeoutMs(repoSettings = {}, options = {}) {
98
103
 
99
104
  const DEFAULT_IMAGE = 'zeroshot-cluster-base';
100
105
 
106
+ /**
107
+ * Shell command that installs a provider's CLI inside the cluster image, or null when the
108
+ * provider is baked into the base image (e.g. Claude) or has no single-command installer.
109
+ * Sourced from the provider registry (docker.install) so nothing here is provider-specific.
110
+ * @param {string} providerName
111
+ * @returns {string|null}
112
+ */
113
+ function providerDockerInstall(providerName) {
114
+ if (!providerName) return null;
115
+ try {
116
+ const metadata = getProviderMetadata(providerName);
117
+ const install = metadata && metadata.docker && metadata.docker.install;
118
+ return typeof install === 'string' && install.trim() ? install.trim() : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
101
124
  class IsolationManager {
102
125
  constructor(options = {}) {
103
126
  this.image = options.image || DEFAULT_IMAGE;
@@ -171,7 +194,13 @@ class IsolationManager {
171
194
  clusterConfigDir,
172
195
  });
173
196
 
174
- const mountedHosts = this._applyCredentialMounts(args, config, settings, containerHome);
197
+ const mountedHosts = this._applyCredentialMounts(
198
+ args,
199
+ config,
200
+ settings,
201
+ containerHome,
202
+ providerName
203
+ );
175
204
  this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);
176
205
 
177
206
  args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
@@ -285,13 +314,25 @@ class IsolationManager {
285
314
  return settings.dockerMounts;
286
315
  }
287
316
 
288
- _applyCredentialMounts(args, config, settings, containerHome) {
317
+ // Auto-activate the running provider's own credential preset (mount + env) so `--docker` works
318
+ // without listing it in dockerMounts. Claude is mounted separately, so skip it.
319
+ _withActiveProviderPreset(mountConfig, providerName) {
320
+ if (!providerName || providerName === 'claude') return mountConfig;
321
+ if (!MOUNT_PRESETS[providerName]) return mountConfig;
322
+ if (mountConfig.some((item) => item === providerName)) return mountConfig;
323
+ return [...mountConfig, providerName];
324
+ }
325
+
326
+ _applyCredentialMounts(args, config, settings, containerHome, providerName) {
289
327
  const mountedHosts = [];
290
328
  if (config.noMounts) {
291
329
  return mountedHosts;
292
330
  }
293
331
 
294
- const mountConfig = this._resolveMountConfig(config, settings);
332
+ const mountConfig = this._withActiveProviderPreset(
333
+ this._resolveMountConfig(config, settings),
334
+ providerName
335
+ );
295
336
  const mounts = resolveMounts(mountConfig, { containerHome });
296
337
  const claudeContainerPath = path.posix.join(containerHome, '.claude');
297
338
 
@@ -363,16 +404,40 @@ class IsolationManager {
363
404
  return;
364
405
  }
365
406
 
407
+ const metadata = getProviderMetadata(providerName);
366
408
  const provider = getProvider(providerName);
409
+
410
+ // An env token (e.g. COPILOT_GITHUB_TOKEN) is a complete credential on its own.
411
+ const credentialEnvKeys = metadata.credentialEnvKeys || [];
412
+ if (credentialEnvKeys.some((key) => process.env[key])) {
413
+ return;
414
+ }
415
+
416
+ // A mount only counts if it carries the secret (credentialInMount !== false).
417
+ const credentialInMount = metadata.docker && metadata.docker.credentialInMount === false;
367
418
  const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
368
419
  const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
369
- const hasCredentialMount = mountedHosts.some((hostPath) =>
370
- expandedCreds.some(
371
- (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
372
- )
373
- );
420
+ const hasCredentialMount =
421
+ !credentialInMount &&
422
+ mountedHosts.some((hostPath) =>
423
+ expandedCreds.some(
424
+ (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
425
+ )
426
+ );
427
+ if (hasCredentialMount) {
428
+ return;
429
+ }
430
+
431
+ if (credentialInMount && credentialEnvKeys.length > 0) {
432
+ console.warn(
433
+ `[IsolationManager] ⚠️ ${provider.displayName} could not find credentials for Docker. ` +
434
+ `Its login token is not stored in a mountable file — export one of ` +
435
+ `${credentialEnvKeys.join(', ')} before running with --docker.`
436
+ );
437
+ return;
438
+ }
374
439
 
375
- if (!hasCredentialMount && expandedCreds.length > 0) {
440
+ if (expandedCreds.length > 0) {
376
441
  const exampleHost = credentialPaths[0];
377
442
  const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
378
443
  const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
@@ -1230,13 +1295,40 @@ class IsolationManager {
1230
1295
  }
1231
1296
  }
1232
1297
 
1298
+ /**
1299
+ * Resolve the cluster image tag for a provider. Providers baked into the base image (e.g.
1300
+ * Claude) run on the base image directly; providers with a `docker.install` command get a
1301
+ * per-provider image variant `<baseImage>-<providerId>` whose install step is a Docker-cached
1302
+ * layer (built once, reused thereafter).
1303
+ * @param {string} providerName
1304
+ * @param {string} [baseImage]
1305
+ * @returns {string}
1306
+ */
1307
+ static imageForProvider(providerName, baseImage = DEFAULT_IMAGE) {
1308
+ if (!providerDockerInstall(providerName)) {
1309
+ return baseImage;
1310
+ }
1311
+ return `${baseImage}-${normalizeProviderName(providerName)}`;
1312
+ }
1313
+
1314
+ /**
1315
+ * Docker `--build-arg` values that install a provider's CLI into its image variant, or [] when
1316
+ * the provider is baked into the base image or has no installer.
1317
+ * @param {string} providerName
1318
+ * @returns {string[]}
1319
+ */
1320
+ static providerBuildArgs(providerName) {
1321
+ const install = providerDockerInstall(providerName);
1322
+ return install ? [`PROVIDER_INSTALL=${install}`] : [];
1323
+ }
1324
+
1233
1325
  /**
1234
1326
  * Build the Docker image with retry logic
1235
1327
  * @param {string} [image] - Image name to build
1236
1328
  * @param {number} [maxRetries=3] - Maximum retry attempts
1237
1329
  * @returns {Promise<void>}
1238
1330
  */
1239
- static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3) {
1331
+ static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3, buildArgs = []) {
1240
1332
  // Repository root is one level up from src/
1241
1333
  const repoRoot = path.join(__dirname, '..');
1242
1334
  const dockerfilePath = path.join(repoRoot, 'docker', 'zeroshot-cluster', 'Dockerfile');
@@ -1245,6 +1337,12 @@ class IsolationManager {
1245
1337
  throw new Error(`Dockerfile not found at ${dockerfilePath}`);
1246
1338
  }
1247
1339
 
1340
+ // Each buildArg becomes a `--build-arg KEY=VALUE` pair (e.g. the per-provider install command).
1341
+ const buildArgFlags = [];
1342
+ for (const arg of buildArgs) {
1343
+ buildArgFlags.push('--build-arg', arg);
1344
+ }
1345
+
1248
1346
  console.log(`[IsolationManager] Building Docker image '${image}'...`);
1249
1347
 
1250
1348
  const baseDelay = 3000; // 3 seconds
@@ -1253,11 +1351,18 @@ class IsolationManager {
1253
1351
  try {
1254
1352
  // CRITICAL: Run from repo root so build context includes package.json and src/
1255
1353
  // Use -f flag to specify Dockerfile location
1256
- runSync('docker', ['build', '-f', 'docker/zeroshot-cluster/Dockerfile', '-t', image, '.'], {
1257
- cwd: repoRoot,
1258
- encoding: 'utf8',
1259
- stdio: 'inherit',
1260
- });
1354
+ runSync(
1355
+ 'docker',
1356
+ ['build', '-f', 'docker/zeroshot-cluster/Dockerfile', ...buildArgFlags, '-t', image, '.'],
1357
+ {
1358
+ cwd: repoRoot,
1359
+ encoding: 'utf8',
1360
+ stdio: 'inherit',
1361
+ // No timeout: image builds legitimately take many minutes (apt, tool downloads,
1362
+ // provider install). runSync's 30s default would kill every build (ETIMEDOUT).
1363
+ timeout: 0,
1364
+ }
1365
+ );
1261
1366
 
1262
1367
  console.log(`[IsolationManager] ✓ Image '${image}' built successfully`);
1263
1368
  return;
@@ -1284,7 +1389,7 @@ class IsolationManager {
1284
1389
  * @param {boolean} [autoBuild=true] - Auto-build if missing
1285
1390
  * @returns {Promise<void>}
1286
1391
  */
1287
- static async ensureImage(image = DEFAULT_IMAGE, autoBuild = true) {
1392
+ static async ensureImage(image = DEFAULT_IMAGE, autoBuild = true, buildArgs = []) {
1288
1393
  if (this.imageExists(image)) {
1289
1394
  return;
1290
1395
  }
@@ -1297,7 +1402,7 @@ class IsolationManager {
1297
1402
  }
1298
1403
 
1299
1404
  console.log(`[IsolationManager] Image '${image}' not found, building automatically...`);
1300
- await this.buildImage(image);
1405
+ await this.buildImage(image, 3, buildArgs);
1301
1406
  }
1302
1407
 
1303
1408
  /**
package/src/ledger.js CHANGED
@@ -715,6 +715,9 @@ class Ledger extends EventEmitter {
715
715
  * Close the database connection
716
716
  */
717
717
  close() {
718
+ if (this._closed) {
719
+ return;
720
+ }
718
721
  this._closed = true; // Set flag BEFORE closing to prevent race conditions
719
722
  this.db.close();
720
723
  }
@@ -24,12 +24,14 @@ class MessageBus extends EventEmitter {
24
24
  this.setMaxListeners(MAX_LISTENERS);
25
25
  this.ledger = ledger || new Ledger();
26
26
  this.wsClients = new Set();
27
+ this._closed = false;
27
28
 
28
29
  // Forward ledger events
29
- this.ledger.on('message', (message) => {
30
+ this._forwardLedgerMessage = (message) => {
30
31
  this.emit('message', message);
31
32
  this._broadcastToWebSocket(message);
32
- });
33
+ };
34
+ this.ledger.on('message', this._forwardLedgerMessage);
33
35
  }
34
36
 
35
37
  /**
@@ -241,6 +243,14 @@ class MessageBus extends EventEmitter {
241
243
  * Close the message bus
242
244
  */
243
245
  close() {
246
+ if (this._closed) {
247
+ return;
248
+ }
249
+ this._closed = true;
250
+
251
+ this.ledger.off('message', this._forwardLedgerMessage);
252
+ this.removeAllListeners();
253
+
244
254
  // Close all WebSocket connections
245
255
  for (const ws of this.wsClients) {
246
256
  try {
@@ -47,6 +47,7 @@ const configValidator = require('./config-validator');
47
47
  const TemplateResolver = require('./template-resolver');
48
48
  const { loadSettings } = require('../lib/settings');
49
49
  const { normalizeProviderName } = require('../lib/provider-names');
50
+ const { resolveRunPlan } = require('../lib/run-plan');
50
51
  const { getProvider } = require('./providers');
51
52
  const StateSnapshotter = require('./state-snapshotter');
52
53
  const { resolveClusterRequiredQualityGates } = require('./quality-gates');
@@ -212,14 +213,11 @@ function applyPushBlockedRepairTriggers(config) {
212
213
  }
213
214
  }
214
215
 
215
- function resolveAutoMerge(options) {
216
- return Boolean(options.ship) || Boolean(options.autoMerge);
217
- }
218
-
219
216
  function buildPrOptions(options, requiredQualityGates) {
220
217
  // autoMerge must always be persisted (even when no other PR fields are set) so that
221
218
  // `zeroshot run --pr` (autoMerge=false) vs `--ship` (autoMerge=true) survives resume.
222
- const autoMerge = resolveAutoMerge(options);
219
+ // Derived from the canonical run plan — never recomputed from ship/pr here.
220
+ const autoMerge = resolveRunPlan(options).autoMerge;
223
221
 
224
222
  return {
225
223
  prBase: options.prBase || null,
@@ -577,6 +575,12 @@ class Orchestrator {
577
575
  // missing - a ledger write. Read-only orchestrators must never write, so they
578
576
  // skip it; they only need to read existing snapshots, not produce new ones.
579
577
  if (!this.readonly) {
578
+ this._registerClusterSubscriptions({
579
+ messageBus,
580
+ clusterId,
581
+ isolationManager,
582
+ containerId: isolation?.containerId || null,
583
+ });
580
584
  this._startSnapshotter(clusterContext);
581
585
  }
582
586
  this._log(`[Orchestrator] Loaded cluster: ${clusterId} with ${agents.length} agents`);
@@ -1134,11 +1138,12 @@ class Orchestrator {
1134
1138
  const messageBus = new MessageBus(ledger);
1135
1139
 
1136
1140
  // Handle isolation mode (Docker container OR git worktree)
1137
- const { isolationManager, containerId, worktreeInfo } = await this._initializeIsolation(
1138
- options,
1139
- config,
1140
- clusterId
1141
- );
1141
+ const {
1142
+ isolationManager,
1143
+ containerId,
1144
+ worktreeInfo,
1145
+ image: resolvedIsolationImage,
1146
+ } = await this._initializeIsolation(options, config, clusterId);
1142
1147
  let requiredQualityGates = resolveClusterRequiredQualityGates(config, options);
1143
1148
  let commandProofs = resolveClusterCommandProofs(config, options);
1144
1149
  options.requiredQualityGates = requiredQualityGates;
@@ -1184,7 +1189,9 @@ class Orchestrator {
1184
1189
  ? {
1185
1190
  enabled: true,
1186
1191
  containerId,
1187
- image: options.isolationImage || 'zeroshot-cluster-base',
1192
+ // Provider-specific image variant (resolved in _initializeIsolation) so resume
1193
+ // recreates the container on the same image that has the provider CLI installed.
1194
+ image: resolvedIsolationImage || options.isolationImage || 'zeroshot-cluster-base',
1188
1195
  manager: isolationManager,
1189
1196
  workDir: options.cwd || process.cwd(), // Persisted for resume
1190
1197
  }
@@ -1725,20 +1732,30 @@ class Orchestrator {
1725
1732
  let isolationManager = null;
1726
1733
  let containerId = null;
1727
1734
  let worktreeInfo = null;
1735
+ let isolationImage = null;
1728
1736
 
1729
1737
  if (options.isolation) {
1730
1738
  if (!IsolationManager.isDockerAvailable()) {
1731
1739
  throw new Error('Docker is not available. Install Docker to use --docker mode.');
1732
1740
  }
1733
1741
 
1734
- const image = options.isolationImage || 'zeroshot-cluster-base';
1735
- await IsolationManager.ensureImage(image);
1742
+ // Resolve the provider first so the cluster runs on that provider's image variant, which
1743
+ // installs the provider CLI as a Docker-cached layer. Providers baked into the base image
1744
+ // (e.g. Claude) resolve back to the base image unchanged.
1745
+ const providerName = this._resolveClusterProvider(config);
1746
+ const baseImage = options.isolationImage || 'zeroshot-cluster-base';
1747
+ const image = IsolationManager.imageForProvider(providerName, baseImage);
1748
+ await IsolationManager.ensureImage(
1749
+ image,
1750
+ true,
1751
+ IsolationManager.providerBuildArgs(providerName)
1752
+ );
1753
+ isolationImage = image;
1736
1754
 
1737
1755
  isolationManager = new IsolationManager({ image });
1738
1756
  this._log(`[Orchestrator] Starting cluster in isolation mode (image: ${image})`);
1739
1757
 
1740
1758
  const workDir = options.cwd || process.cwd();
1741
- const providerName = this._resolveClusterProvider(config);
1742
1759
  containerId = await isolationManager.createContainer(clusterId, {
1743
1760
  workDir,
1744
1761
  image,
@@ -1766,7 +1783,7 @@ class Orchestrator {
1766
1783
  this._log(`[Orchestrator] Branch: ${worktreeInfo.branch}`);
1767
1784
  }
1768
1785
 
1769
- return { isolationManager, containerId, worktreeInfo };
1786
+ return { isolationManager, containerId, worktreeInfo, image: isolationImage };
1770
1787
  }
1771
1788
 
1772
1789
  _applyAutoPrConfig(config, inputData, options) {
@@ -1825,7 +1842,7 @@ class Orchestrator {
1825
1842
  mergeQueue: options.mergeQueue,
1826
1843
  closeIssue: options.closeIssue,
1827
1844
  requiredQualityGates: options.requiredQualityGates,
1828
- autoMerge: resolveAutoMerge(options),
1845
+ autoMerge: resolveRunPlan(options).autoMerge,
1829
1846
  cwd: options.cwd,
1830
1847
  });
1831
1848
 
@@ -2316,6 +2333,9 @@ class Orchestrator {
2316
2333
  * Call before deleting storageDir to prevent ENOENT race conditions during cleanup
2317
2334
  */
2318
2335
  close() {
2336
+ if (this.closed) {
2337
+ return;
2338
+ }
2319
2339
  this.closed = true;
2320
2340
  }
2321
2341
 
@@ -4103,7 +4123,7 @@ Continue from where you left off. Review your previous output to understand what
4103
4123
 
4104
4124
  // Exported for testing (PR options persistence, e.g. autoMerge for --pr vs --ship).
4105
4125
  Orchestrator.buildPrOptions = buildPrOptions;
4106
- Orchestrator.resolveAutoMerge = resolveAutoMerge;
4126
+ Orchestrator.resolveRunPlan = resolveRunPlan;
4107
4127
 
4108
4128
  module.exports = Orchestrator;
4109
4129
  module.exports.DuplicateClusterError = DuplicateClusterError;
package/src/preflight.js CHANGED
@@ -19,7 +19,12 @@ const {
19
19
  resolveClaudeAuth,
20
20
  } = require('../lib/settings/claude-auth.js');
21
21
  const { loadSettings, getClaudeCommand } = require('../lib/settings.js');
22
- const { normalizeProviderName } = require('../lib/provider-names');
22
+ const {
23
+ VALID_PROVIDERS,
24
+ getProviderMetadata,
25
+ normalizeProviderName,
26
+ resolveProviderCommand,
27
+ } = require('../lib/provider-names');
23
28
  const { detectGitContext } = require('../lib/git-remote-utils');
24
29
  const { readKeychainCredentials } = require('./claude-credentials');
25
30
 
@@ -343,10 +348,21 @@ function buildClaudeCommand(options) {
343
348
  return options.claudeCommand || [command, ...args].join(' ');
344
349
  }
345
350
 
351
+ function buildRegistryInstallRecovery(metadata, command) {
352
+ return [
353
+ ...metadata.installInstructions.split('\n').filter(Boolean),
354
+ `Then run: ${command} --version`,
355
+ ];
356
+ }
357
+
346
358
  function validateClaudeProvider(options) {
347
359
  const errors = [];
348
360
  const warnings = [];
361
+ const metadata = getProviderMetadata('claude');
349
362
  const claudeCommand = buildClaudeCommand(options);
363
+ const claudeCommandParts = claudeCommand.trim().split(/\s+/);
364
+ const usesDefaultClaudeCommand =
365
+ claudeCommandParts.length === 1 && claudeCommandParts[0] === metadata.binary;
350
366
 
351
367
  const claude = getClaudeVersion(claudeCommand);
352
368
  if (!claude.installed) {
@@ -354,12 +370,8 @@ function validateClaudeProvider(options) {
354
370
  formatError(
355
371
  'Claude command not available',
356
372
  claude.error,
357
- claudeCommand === 'claude'
358
- ? [
359
- 'Install Claude CLI: npm install -g @anthropic-ai/claude-code',
360
- 'Or: brew install claude (macOS)',
361
- 'Then run: claude --version',
362
- ]
373
+ usesDefaultClaudeCommand
374
+ ? buildRegistryInstallRecovery(metadata, metadata.binary)
363
375
  : [
364
376
  `Command '${claudeCommand}' not found`,
365
377
  'Check settings: zeroshot settings',
@@ -404,41 +416,82 @@ function validateCliProvider(command, title, detail, recovery) {
404
416
  return { errors, warnings: [] };
405
417
  }
406
418
 
407
- function validateProvider(providerName, options) {
408
- const validatorByProvider = {
409
- claude: () => validateClaudeProvider(options),
410
- codex: () =>
411
- validateCliProvider('codex', 'Codex CLI not available', 'Command "codex" not installed', [
412
- 'Install Codex CLI: npm install -g @openai/codex',
413
- 'Then run: codex --version',
414
- ]),
415
- gemini: () =>
416
- validateCliProvider('gemini', 'Gemini CLI not available', 'Command "gemini" not installed', [
417
- 'Install Gemini CLI: npm install -g @google/gemini-cli',
418
- 'Then run: gemini --version',
419
- ]),
420
- opencode: () =>
421
- validateCliProvider(
422
- 'opencode',
423
- 'Opencode CLI not available',
424
- 'Command "opencode" not installed',
425
- ['Install Opencode CLI: see https://opencode.ai', 'Then run: opencode --version']
419
+ function validateGatewayProvider() {
420
+ const { getProvider } = require('./providers');
421
+ const provider = getProvider('gateway');
422
+ if (provider.isAvailable()) {
423
+ return { errors: [], warnings: [] };
424
+ }
425
+ return {
426
+ errors: [
427
+ formatError(
428
+ 'Gateway provider not configured',
429
+ 'providerSettings.gateway must define baseUrl, apiKey, model, and toolPolicy before gateway can run.',
430
+ [
431
+ 'Run: zeroshot settings',
432
+ 'Set providerSettings.gateway.baseUrl to your OpenAI-compatible endpoint base URL',
433
+ 'Set providerSettings.gateway.apiKey and providerSettings.gateway.model',
434
+ 'Set providerSettings.gateway.toolPolicy.roots and toolPolicy.commands explicitly',
435
+ ]
426
436
  ),
437
+ ],
438
+ warnings: [],
427
439
  };
440
+ }
428
441
 
429
- const validator = validatorByProvider[providerName];
430
- if (!validator) {
442
+ function validateProvider(providerName, options) {
443
+ let metadata;
444
+ try {
445
+ metadata = getProviderMetadata(providerName);
446
+ } catch {
431
447
  return {
432
448
  errors: [
433
449
  formatError('Unknown provider', `Provider "${providerName}" is not supported`, [
434
- 'Use claude, codex, gemini, or opencode',
450
+ `Use ${VALID_PROVIDERS.join(', ')}`,
435
451
  ]),
436
452
  ],
437
453
  warnings: [],
438
454
  };
439
455
  }
440
456
 
441
- return validator();
457
+ if (metadata.command.kind === 'configured-claude') {
458
+ return validateClaudeProvider(options);
459
+ }
460
+ if (providerName === 'gateway') {
461
+ return validateGatewayProvider();
462
+ }
463
+
464
+ return validateRegistryCliProvider(providerName);
465
+ }
466
+
467
+ function validateRegistryCliProvider(providerName) {
468
+ const metadata = getProviderMetadata(providerName);
469
+ const { command } = resolveProviderCommand(providerName);
470
+ const installSteps = metadata.installInstructions.split('\n').filter(Boolean);
471
+ if (!commandExists(command)) {
472
+ return validateCliProvider(
473
+ command,
474
+ `${metadata.displayName} CLI not available`,
475
+ `Command "${command}" not installed`,
476
+ [...installSteps, `Then run: ${command} --version`]
477
+ );
478
+ }
479
+
480
+ const { getProvider } = require('./providers');
481
+ if (getProvider(providerName).isAvailable()) {
482
+ return { errors: [], warnings: [] };
483
+ }
484
+
485
+ return {
486
+ errors: [
487
+ formatError(
488
+ `${metadata.displayName} CLI not available`,
489
+ `Command "${command}" is installed but did not produce usable --help/--version output`,
490
+ [...installSteps, `Then run: ${command} --version`]
491
+ ),
492
+ ],
493
+ warnings: [],
494
+ };
442
495
  }
443
496
 
444
497
  function validateGhRequirement() {
@@ -1,48 +1,21 @@
1
- const { normalizeProviderName } = require('../../lib/provider-names');
1
+ const {
2
+ PROVIDER_CAPABILITIES,
3
+ normalizeProviderName,
4
+ providerSupportsCapability,
5
+ } = require('../../lib/provider-names');
2
6
 
3
- const CAPABILITIES = {
4
- claude: {
5
- dockerIsolation: true,
6
- worktreeIsolation: true,
7
- mcpServers: true,
8
- jsonSchema: true,
9
- streamJson: true,
10
- thinkingMode: true,
11
- reasoningEffort: false,
12
- },
13
- codex: {
14
- dockerIsolation: true,
15
- worktreeIsolation: true,
16
- mcpServers: true,
17
- jsonSchema: true,
18
- streamJson: true,
19
- thinkingMode: true,
20
- reasoningEffort: true,
21
- },
22
- gemini: {
23
- dockerIsolation: true,
24
- worktreeIsolation: true,
25
- mcpServers: true,
26
- jsonSchema: 'experimental',
27
- streamJson: true,
28
- thinkingMode: true,
29
- reasoningEffort: false,
30
- },
31
- opencode: {
32
- dockerIsolation: true,
33
- worktreeIsolation: true,
34
- mcpServers: true,
35
- jsonSchema: 'experimental',
36
- streamJson: true,
37
- thinkingMode: true,
38
- reasoningEffort: true,
39
- },
40
- };
7
+ const CAPABILITIES = Object.freeze(
8
+ Object.fromEntries(
9
+ Object.entries(PROVIDER_CAPABILITIES).map(([provider, capabilities]) => [
10
+ provider,
11
+ Object.freeze({ ...capabilities }),
12
+ ])
13
+ )
14
+ );
41
15
 
42
16
  function checkCapability(provider, capability) {
43
- const caps = CAPABILITIES[normalizeProviderName(provider)];
44
- if (!caps) return false;
45
- return caps[capability] === true;
17
+ if (!provider) return false;
18
+ return providerSupportsCapability(provider, capability);
46
19
  }
47
20
 
48
21
  function warnIfExperimental(provider, capability) {