@the-open-engine/zeroshot 6.7.1 → 6.8.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 (44) hide show
  1. package/cli/index.js +114 -16
  2. package/lib/agent-cli-provider/adapters/common.js +1 -1
  3. package/lib/agent-cli-provider/adapters/common.js.map +1 -1
  4. package/lib/agent-cli-provider/adapters/opencode-models.d.ts +7 -0
  5. package/lib/agent-cli-provider/adapters/opencode-models.d.ts.map +1 -0
  6. package/lib/agent-cli-provider/adapters/opencode-models.js +87 -0
  7. package/lib/agent-cli-provider/adapters/opencode-models.js.map +1 -0
  8. package/lib/agent-cli-provider/adapters/opencode.d.ts.map +1 -1
  9. package/lib/agent-cli-provider/adapters/opencode.js +6 -58
  10. package/lib/agent-cli-provider/adapters/opencode.js.map +1 -1
  11. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/single-agent-runtime.js +27 -13
  13. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  14. package/lib/agent-cli-provider/types.d.ts +1 -0
  15. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  16. package/lib/agent-cli-provider/types.js.map +1 -1
  17. package/lib/git-remote-utils.js +90 -7
  18. package/package.json +6 -15
  19. package/protocol/openengine-cluster/v1/worker.schema.json +47 -0
  20. package/scripts/assert-release-published.js +161 -2
  21. package/scripts/release-dry-run.js +83 -0
  22. package/scripts/release-preflight.js +24 -12
  23. package/scripts/release-recovery.js +149 -0
  24. package/scripts/semantic-release-notes.js +44 -0
  25. package/scripts/setup-merge-queue.sh +104 -118
  26. package/src/agent/agent-task-executor.js +18 -9
  27. package/src/agent-cli-provider/adapters/common.ts +1 -1
  28. package/src/agent-cli-provider/adapters/opencode-models.ts +100 -0
  29. package/src/agent-cli-provider/adapters/opencode.ts +8 -65
  30. package/src/agent-cli-provider/single-agent-runtime.ts +51 -33
  31. package/src/agent-cli-provider/types.ts +1 -0
  32. package/src/agent-wrapper.js +14 -2
  33. package/src/agents/git-pusher-template.js +94 -19
  34. package/src/attach/socket-discovery.js +9 -19
  35. package/src/attach/socket-paths.js +85 -0
  36. package/src/claude-task-runner.js +121 -37
  37. package/src/config-validator.js +2 -2
  38. package/src/isolation-manager.js +164 -74
  39. package/src/orchestrator.js +83 -34
  40. package/src/providers/index.js +5 -0
  41. package/src/task-run-model-args.js +155 -0
  42. package/task-lib/attachable-watcher.js +3 -9
  43. package/task-lib/commands/run.js +17 -2
  44. package/task-lib/runner.js +87 -29
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Deterministic attach socket paths.
3
+ *
4
+ * Unix-domain sockets have a small path budget (104 bytes on macOS). Keep the
5
+ * live socket namespace independent from HOME while retaining one namespace
6
+ * per OS user and Zeroshot home.
7
+ */
8
+
9
+ const crypto = require('crypto');
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const SOCKET_ROOT = process.platform === 'win32' ? null : '/tmp';
15
+ const SOCKET_DIR_MODE = 0o700;
16
+
17
+ function shortHash(value) {
18
+ return crypto.createHash('sha256').update(value).digest('hex').slice(0, 16);
19
+ }
20
+
21
+ function userNamespace() {
22
+ if (typeof process.getuid === 'function') {
23
+ return String(process.getuid());
24
+ }
25
+ return shortHash(os.userInfo().username);
26
+ }
27
+
28
+ function resolveHomeDir(env = process.env) {
29
+ return env.ZEROSHOT_HOME || env.HOME || env.USERPROFILE || os.homedir();
30
+ }
31
+
32
+ function getSocketDir(homeDir = resolveHomeDir()) {
33
+ if (process.platform === 'win32') {
34
+ return path.join(homeDir, '.zeroshot', 'sockets');
35
+ }
36
+ return path.join(SOCKET_ROOT, `zeroshot-${userNamespace()}-${shortHash(homeDir)}`);
37
+ }
38
+
39
+ function assertSafeSocketDir(socketDir) {
40
+ const stat = fs.lstatSync(socketDir);
41
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
42
+ throw new Error(`Attach socket path is not a directory: ${socketDir}`);
43
+ }
44
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
45
+ throw new Error(`Attach socket directory is not owned by the current user: ${socketDir}`);
46
+ }
47
+ }
48
+
49
+ function ensureOwnedDirectory(socketDir) {
50
+ fs.mkdirSync(socketDir, { recursive: true, mode: SOCKET_DIR_MODE });
51
+ assertSafeSocketDir(socketDir);
52
+ if (process.platform !== 'win32') {
53
+ fs.chmodSync(socketDir, SOCKET_DIR_MODE);
54
+ }
55
+ return socketDir;
56
+ }
57
+
58
+ function ensureSocketDir(homeDir = resolveHomeDir()) {
59
+ const socketDir = getSocketDir(homeDir);
60
+ return ensureOwnedDirectory(socketDir);
61
+ }
62
+
63
+ function getTaskSocketPath(taskId, homeDir = resolveHomeDir()) {
64
+ return path.join(ensureSocketDir(homeDir), `${taskId}.sock`);
65
+ }
66
+
67
+ function getAgentSocketPath(clusterId, agentId, homeDir = resolveHomeDir()) {
68
+ const clusterDir = path.join(ensureSocketDir(homeDir), clusterId);
69
+ ensureOwnedDirectory(clusterDir);
70
+ return path.join(clusterDir, `${agentId}.sock`);
71
+ }
72
+
73
+ function getClusterSocketPath(clusterId, homeDir = resolveHomeDir()) {
74
+ return path.join(ensureSocketDir(homeDir), `${clusterId}.sock`);
75
+ }
76
+
77
+ module.exports = {
78
+ SOCKET_DIR_MODE,
79
+ resolveHomeDir,
80
+ getSocketDir,
81
+ ensureSocketDir,
82
+ getTaskSocketPath,
83
+ getAgentSocketPath,
84
+ getClusterSocketPath,
85
+ };
@@ -13,6 +13,18 @@ const { normalizeProviderName } = require('../lib/provider-names');
13
13
  const { getProvider } = require('./providers');
14
14
  const { prependWorktreeToolBinToEnv } = require('./worktree-tooling-env');
15
15
  const { prepareClaudeConfigDir } = require('./worktree-claude-config');
16
+ const {
17
+ appendTaskRunModelArgs,
18
+ wrapTaskRunWithIsolatedSettings,
19
+ } = require('./task-run-model-args');
20
+
21
+ function rejectCallerSuppliedModelProvenance(options) {
22
+ if (Object.prototype.hasOwnProperty.call(options, 'modelSpecSource')) {
23
+ throw new Error(
24
+ 'modelSpecSource is derived from model versus modelLevel/default and cannot be supplied.'
25
+ );
26
+ }
27
+ }
16
28
 
17
29
  function runCommand(command, args, options = {}, callback = null) {
18
30
  const timeout = options.timeout ?? 30000;
@@ -112,10 +124,11 @@ class ClaudeTaskRunner extends TaskRunner {
112
124
  * Execute a task via zeroshot CLI
113
125
  *
114
126
  * @param {string} context - Full prompt/context
115
- * @param {{agentId?: string, model?: string, outputFormat?: string, jsonSchema?: any, strictSchema?: boolean, cwd?: string, worktreePath?: string|null, isolation?: any}} options - Execution options
127
+ * @param {{agentId?: string, model?: string, modelLevel?: string, modelSpec?: Object|null, reasoningEffort?: string, outputFormat?: string, jsonSchema?: any, strictSchema?: boolean, cwd?: string, worktreePath?: string|null, isolation?: any}} options - Execution options
116
128
  * @returns {Promise<{success: boolean, output: string, error: string|null, taskId?: string}>}
117
129
  */
118
130
  async run(context, options = {}) {
131
+ rejectCallerSuppliedModelProvenance(options);
119
132
  const {
120
133
  agentId = 'unknown',
121
134
  provider,
@@ -137,7 +150,7 @@ class ClaudeTaskRunner extends TaskRunner {
137
150
  providerName,
138
151
  settings
139
152
  );
140
- const resolvedModelSpec = this._resolveModelSpec({
153
+ const modelSelection = this._resolveModelSelection({
141
154
  explicitModelSpec,
142
155
  model,
143
156
  reasoningEffort,
@@ -146,13 +159,13 @@ class ClaudeTaskRunner extends TaskRunner {
146
159
  providerSettings,
147
160
  levelOverrides,
148
161
  });
162
+ const resolvedModelSpec = modelSelection.modelSpec;
149
163
 
150
164
  // Isolation mode delegates to separate method
151
165
  if (isolation?.enabled) {
152
166
  return this._runIsolated(context, {
153
167
  ...options,
154
168
  provider: providerName,
155
- modelSpec: resolvedModelSpec,
156
169
  });
157
170
  }
158
171
 
@@ -168,6 +181,7 @@ class ClaudeTaskRunner extends TaskRunner {
168
181
  providerName,
169
182
  runOutputFormat,
170
183
  resolvedModelSpec,
184
+ modelSpecSource: modelSelection.source,
171
185
  jsonSchema,
172
186
  });
173
187
 
@@ -204,25 +218,74 @@ class ClaudeTaskRunner extends TaskRunner {
204
218
  providerSettings,
205
219
  levelOverrides,
206
220
  }) {
207
- if (explicitModelSpec) {
208
- if (explicitModelSpec.model) {
209
- providerModule.validateModelId(explicitModelSpec.model);
210
- }
211
- return explicitModelSpec;
212
- }
221
+ return this._resolveModelSelection({
222
+ explicitModelSpec,
223
+ model,
224
+ reasoningEffort,
225
+ modelLevel,
226
+ providerModule,
227
+ providerSettings,
228
+ levelOverrides,
229
+ }).modelSpec;
230
+ }
213
231
 
232
+ _resolveModelSelection({
233
+ explicitModelSpec,
234
+ model,
235
+ reasoningEffort,
236
+ modelLevel,
237
+ providerModule,
238
+ providerSettings,
239
+ levelOverrides,
240
+ }) {
214
241
  if (model) {
215
242
  providerModule.validateModelId(model);
216
- return { model, reasoningEffort };
243
+ return {
244
+ source: 'direct',
245
+ modelSpec: { model, reasoningEffort },
246
+ };
217
247
  }
218
248
 
219
- const level = modelLevel || providerSettings.defaultLevel || providerModule.getDefaultLevel();
220
- let resolvedModelSpec = providerModule.resolveModelSpec(level, levelOverrides);
221
- if (reasoningEffort) {
222
- resolvedModelSpec = { ...resolvedModelSpec, reasoningEffort };
249
+ if (explicitModelSpec?.model !== undefined) {
250
+ providerModule.validateModelId(explicitModelSpec.model);
251
+ return {
252
+ source: 'direct',
253
+ modelSpec: explicitModelSpec,
254
+ };
223
255
  }
224
256
 
225
- return resolvedModelSpec;
257
+ return {
258
+ source: 'provider-level',
259
+ modelSpec: this._resolveProviderLevelModelSpec({
260
+ explicitModelSpec,
261
+ modelLevel,
262
+ reasoningEffort,
263
+ providerModule,
264
+ providerSettings,
265
+ levelOverrides,
266
+ }),
267
+ };
268
+ }
269
+
270
+ _resolveProviderLevelModelSpec({
271
+ explicitModelSpec,
272
+ modelLevel,
273
+ reasoningEffort,
274
+ providerModule,
275
+ providerSettings,
276
+ levelOverrides,
277
+ }) {
278
+ const level =
279
+ explicitModelSpec?.level ||
280
+ modelLevel ||
281
+ providerSettings.defaultLevel ||
282
+ providerModule.getDefaultLevel();
283
+ const resolvedModelSpec = providerModule.resolveModelSpec(level, levelOverrides);
284
+ const resolvedReasoningEffort = explicitModelSpec?.reasoningEffort || reasoningEffort;
285
+
286
+ return resolvedReasoningEffort
287
+ ? { ...resolvedModelSpec, reasoningEffort: resolvedReasoningEffort }
288
+ : resolvedModelSpec;
226
289
  }
227
290
 
228
291
  _resolveOutputFormat({ outputFormat, jsonSchema, strictSchema }) {
@@ -232,16 +295,16 @@ class ClaudeTaskRunner extends TaskRunner {
232
295
  return jsonSchema && outputFormat === 'json' && !strictSchema ? 'stream-json' : outputFormat;
233
296
  }
234
297
 
235
- _buildRunArgs({ context, providerName, runOutputFormat, resolvedModelSpec, jsonSchema }) {
298
+ _buildRunArgs({
299
+ context,
300
+ providerName,
301
+ runOutputFormat,
302
+ resolvedModelSpec,
303
+ modelSpecSource = 'direct',
304
+ jsonSchema,
305
+ }) {
236
306
  const args = ['task', 'run', '--output-format', runOutputFormat, '--provider', providerName];
237
-
238
- if (resolvedModelSpec?.model) {
239
- args.push('--model', resolvedModelSpec.model);
240
- }
241
-
242
- if (resolvedModelSpec?.reasoningEffort) {
243
- args.push('--reasoning-effort', resolvedModelSpec.reasoningEffort);
244
- }
307
+ appendTaskRunModelArgs(args, resolvedModelSpec, modelSpecSource);
245
308
 
246
309
  // Pass schema to CLI only when using json output (strictSchema=true or no conflict)
247
310
  if (jsonSchema && runOutputFormat === 'json') {
@@ -542,20 +605,40 @@ class ClaudeTaskRunner extends TaskRunner {
542
605
  /**
543
606
  * Run task in isolated Docker container
544
607
  * @param {string} context
545
- * @param {{agentId?: string, model?: string, outputFormat?: string, jsonSchema?: any, strictSchema?: boolean, isolation?: any}} options
608
+ * @param {{agentId?: string, provider?: string, model?: string, modelLevel?: string, modelSpec?: Object|null, reasoningEffort?: string, outputFormat?: string, jsonSchema?: any, strictSchema?: boolean, isolation?: any}} options
546
609
  * @returns {Promise<{success: boolean, output: string, error: string|null}>}
547
610
  */
548
611
  _runIsolated(context, options) {
612
+ rejectCallerSuppliedModelProvenance(options);
549
613
  const {
550
614
  agentId = 'unknown',
551
615
  provider = 'claude',
552
- modelSpec = null,
616
+ model = null,
617
+ modelLevel = null,
618
+ modelSpec: explicitModelSpec = null,
619
+ reasoningEffort = null,
553
620
  outputFormat = 'stream-json',
554
621
  jsonSchema = null,
555
622
  strictSchema = false,
556
623
  isolation,
557
624
  } = options;
558
625
  const { manager, clusterId } = isolation;
626
+ const settings = loadSettings();
627
+ const { providerModule, providerSettings, levelOverrides } = this._getProviderContext(
628
+ provider,
629
+ settings
630
+ );
631
+ const modelSelection = this._resolveModelSelection({
632
+ explicitModelSpec,
633
+ model,
634
+ reasoningEffort,
635
+ modelLevel,
636
+ providerModule,
637
+ providerSettings,
638
+ levelOverrides,
639
+ });
640
+ const modelSpec = modelSelection.modelSpec;
641
+ const modelSpecSource = modelSelection.source;
559
642
 
560
643
  this._log(`📦 [${agentId}]: Running task in isolated container...`);
561
644
 
@@ -565,7 +648,7 @@ class ClaudeTaskRunner extends TaskRunner {
565
648
  ? 'stream-json'
566
649
  : desiredOutputFormat;
567
650
 
568
- const command = [
651
+ let command = [
569
652
  'zeroshot',
570
653
  'task',
571
654
  'run',
@@ -575,13 +658,7 @@ class ClaudeTaskRunner extends TaskRunner {
575
658
  provider,
576
659
  ];
577
660
 
578
- if (modelSpec?.model) {
579
- command.push('--model', modelSpec.model);
580
- }
581
-
582
- if (modelSpec?.reasoningEffort) {
583
- command.push('--reasoning-effort', modelSpec.reasoningEffort);
584
- }
661
+ appendTaskRunModelArgs(command, modelSpec, modelSpecSource);
585
662
 
586
663
  if (jsonSchema && runOutputFormat === 'json') {
587
664
  command.push('--json-schema', JSON.stringify(jsonSchema));
@@ -597,16 +674,23 @@ class ClaudeTaskRunner extends TaskRunner {
597
674
  }
598
675
 
599
676
  command.push(finalContext);
677
+ command = wrapTaskRunWithIsolatedSettings(command, {
678
+ providerName: provider,
679
+ settings,
680
+ modelSpecSource,
681
+ modelSpec,
682
+ });
600
683
 
601
684
  return new Promise((resolve, reject) => {
602
685
  let output = '';
603
686
  let resolved = false;
604
687
 
605
688
  const proc = manager.spawnInContainer(clusterId, command, {
606
- env:
607
- provider === 'claude' && modelSpec?.model
689
+ env: {
690
+ ...(provider === 'claude' && modelSpec?.model
608
691
  ? { ANTHROPIC_MODEL: modelSpec.model, ZEROSHOT_BLOCK_ASK_USER: '1' }
609
- : {},
692
+ : {}),
693
+ },
610
694
  });
611
695
 
612
696
  proc.stdout.on('data', (/** @type {Buffer} */ data) => {
@@ -2053,8 +2053,8 @@ function validateProviderSettings(provider, providerSettings) {
2053
2053
  `Invalid model override (must be non-empty string) for provider "${provider}"`
2054
2054
  );
2055
2055
  }
2056
- if (override?.model) {
2057
- providerModule.validateModelId(override.model);
2056
+ if (override?.model || (provider === 'opencode' && override?.model === '')) {
2057
+ providerModule.resolveModelSpec(level, { [level]: override });
2058
2058
  }
2059
2059
  if (override?.reasoningEffort && !providerSupportsCapability(provider, 'reasoningEffort')) {
2060
2060
  throw new Error(
@@ -28,6 +28,7 @@ const { readRepoSettings } = require('../lib/repo-settings');
28
28
  const { provisionClaudeCredentials } = require('./claude-credentials');
29
29
 
30
30
  const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
31
+ const FRESH_BASE_REF_PREFIX = 'refs/zeroshot/base-fetch';
31
32
 
32
33
  function runSync(command, args, options = {}) {
33
34
  const timeout = options.timeout ?? 30000;
@@ -48,6 +49,61 @@ function runShellSync(command, options = {}) {
48
49
  return runSync('/bin/bash', ['-lc', command], options);
49
50
  }
50
51
 
52
+ function resolveCommit(repoRoot, ref) {
53
+ return runSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], {
54
+ cwd: repoRoot,
55
+ encoding: 'utf8',
56
+ stdio: 'pipe',
57
+ }).trim();
58
+ }
59
+
60
+ function deleteTemporaryBaseRef(repoRoot, temporaryRef) {
61
+ try {
62
+ runSync('git', ['update-ref', '-d', temporaryRef], {
63
+ cwd: repoRoot,
64
+ encoding: 'utf8',
65
+ stdio: 'pipe',
66
+ });
67
+ } catch (err) {
68
+ console.warn(
69
+ `[IsolationManager] Warning: failed to remove temporary base ref ${temporaryRef}: ${err.message}`
70
+ );
71
+ }
72
+ }
73
+
74
+ function fetchFreshRemoteBase(repoRoot, remoteName, branch) {
75
+ const temporaryRef = `${FRESH_BASE_REF_PREFIX}/${crypto.randomBytes(16).toString('hex')}`;
76
+
77
+ try {
78
+ runSync(
79
+ 'git',
80
+ [
81
+ 'fetch',
82
+ '--atomic',
83
+ '--no-tags',
84
+ '--no-write-fetch-head',
85
+ '--refmap=',
86
+ '--',
87
+ remoteName,
88
+ `+refs/heads/${branch}:${temporaryRef}`,
89
+ ],
90
+ {
91
+ cwd: repoRoot,
92
+ encoding: 'utf8',
93
+ stdio: 'pipe',
94
+ }
95
+ );
96
+
97
+ return {
98
+ baseSha: resolveCommit(repoRoot, temporaryRef),
99
+ temporaryRef,
100
+ };
101
+ } catch (err) {
102
+ deleteTemporaryBaseRef(repoRoot, temporaryRef);
103
+ throw err;
104
+ }
105
+ }
106
+
51
107
  function expandHomePath(value) {
52
108
  if (!value) return value;
53
109
  if (value === '~') return os.homedir();
@@ -127,7 +183,7 @@ class IsolationManager {
127
183
  this.containers = new Map(); // clusterId -> containerId
128
184
  this.isolatedDirs = new Map(); // clusterId -> { path, originalDir }
129
185
  this.clusterConfigDirs = new Map(); // clusterId -> configDirPath
130
- this.worktrees = new Map(); // clusterId -> { path, branch, repoRoot }
186
+ this.worktrees = new Map(); // clusterId -> { path, branch, repoRoot, baseRef, baseSha }
131
187
  this._exitWatchers = new Map(); // clusterId -> ChildProcess
132
188
  }
133
189
 
@@ -1443,7 +1499,7 @@ class IsolationManager {
1443
1499
  * Creates a git worktree at ~/.zeroshot/worktrees/{clusterId}
1444
1500
  * @param {string} clusterId - Cluster ID
1445
1501
  * @param {string} workDir - Original working directory (must be a git repo)
1446
- * @returns {{ path: string, branch: string, repoRoot: string }}
1502
+ * @returns {{ path: string, branch: string, repoRoot: string, baseRef: string, baseSha: string }}
1447
1503
  */
1448
1504
  createWorktreeIsolation(clusterId, workDir, options = {}) {
1449
1505
  if (!this._isGitRepo(workDir)) {
@@ -1485,8 +1541,10 @@ class IsolationManager {
1485
1541
  * @param {string} workDir - Original working directory
1486
1542
  * @param {object} [options] - Worktree creation options
1487
1543
  * @param {string} [options.baseRef] - Git ref to base the worktree branch on
1544
+ * @param {string} [options.remoteName=origin] - Remote used by a remote base ref
1545
+ * @param {boolean} [options.requireFreshBase=false] - Require a freshly fetched remote base
1488
1546
  * @param {number} [options.worktreeSetupTimeoutMs] - Setup command timeout in milliseconds
1489
- * @returns {{ path: string, branch: string, repoRoot: string }}
1547
+ * @returns {{ path: string, branch: string, repoRoot: string, baseRef: string, baseSha: string }}
1490
1548
  */
1491
1549
  createWorktree(clusterId, workDir, options = {}) {
1492
1550
  const repoRoot = this._getGitRoot(workDir);
@@ -1560,20 +1618,42 @@ class IsolationManager {
1560
1618
  }
1561
1619
  const worktreeSetupTimeoutMs = resolveWorktreeSetupTimeoutMs(repoSettings, options);
1562
1620
 
1563
- // Best-effort ensure origin/<branch> exists locally if requested.
1564
- if (worktreeBaseRef && worktreeBaseRef.startsWith('origin/')) {
1565
- const branch = worktreeBaseRef.slice('origin/'.length);
1566
- try {
1567
- runSync('git', ['fetch', 'origin', branch], {
1568
- cwd: repoRoot,
1569
- encoding: 'utf8',
1570
- stdio: 'pipe',
1571
- });
1572
- } catch {
1573
- // ignore
1621
+ const baseRef = worktreeBaseRef || 'HEAD';
1622
+ const remoteName = options.remoteName || 'origin';
1623
+ let baseSha;
1624
+ let temporaryBaseRef = null;
1625
+
1626
+ if (worktreeBaseRef && worktreeBaseRef.startsWith(`${remoteName}/`)) {
1627
+ const branch = worktreeBaseRef.slice(remoteName.length + 1);
1628
+ const remoteTrackingRef = `refs/remotes/${remoteName}/${branch}`;
1629
+
1630
+ if (options.requireFreshBase === true) {
1631
+ const freshBase = fetchFreshRemoteBase(repoRoot, remoteName, branch);
1632
+ baseSha = freshBase.baseSha;
1633
+ temporaryBaseRef = freshBase.temporaryRef;
1634
+ } else {
1635
+ try {
1636
+ runSync(
1637
+ 'git',
1638
+ ['fetch', '--no-tags', '--', remoteName, `+refs/heads/${branch}:${remoteTrackingRef}`],
1639
+ {
1640
+ cwd: repoRoot,
1641
+ encoding: 'utf8',
1642
+ stdio: 'pipe',
1643
+ }
1644
+ );
1645
+ } catch {
1646
+ // Repository worktree settings historically allowed an offline cached remote ref.
1647
+ }
1648
+ baseSha = resolveCommit(repoRoot, remoteTrackingRef);
1574
1649
  }
1650
+ } else {
1651
+ baseSha = resolveCommit(repoRoot, baseRef);
1575
1652
  }
1576
1653
 
1654
+ console.log(`[IsolationManager] Worktree base ref: ${baseRef}`);
1655
+ console.log(`[IsolationManager] Worktree base commit: ${baseSha}`);
1656
+
1577
1657
  // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
1578
1658
  const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1579
1659
  let branchName = baseBranchName;
@@ -1581,44 +1661,18 @@ class IsolationManager {
1581
1661
  // Worktree path in persistent location (survives reboots)
1582
1662
  const worktreePath = path.join(os.homedir(), '.zeroshot', 'worktrees', clusterId);
1583
1663
 
1584
- // Ensure parent directory exists
1585
- const parentDir = path.dirname(worktreePath);
1586
- if (!fs.existsSync(parentDir)) {
1587
- fs.mkdirSync(parentDir, { recursive: true });
1588
- }
1589
-
1590
- // Best-effort cleanup of stale worktree metadata and directory.
1591
- // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1592
- // git may keep the branch "checked out" and block deletion/reuse.
1593
1664
  try {
1594
- runSync('git', ['worktree', 'remove', '--force', worktreePath], {
1595
- cwd: repoRoot,
1596
- encoding: 'utf8',
1597
- stdio: 'pipe',
1598
- });
1599
- } catch {
1600
- // ignore
1601
- }
1602
- try {
1603
- runSync('git', ['worktree', 'prune'], { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1604
- } catch {
1605
- // ignore
1606
- }
1607
- try {
1608
- fs.rmSync(worktreePath, { recursive: true, force: true });
1609
- } catch {
1610
- // ignore
1611
- }
1612
-
1613
- const baseRef = worktreeBaseRef || 'HEAD';
1614
- console.log(`[IsolationManager] Worktree base ref: ${baseRef}`);
1615
- console.log(`[IsolationManager] Worktree path: ${worktreePath}`);
1665
+ // Ensure parent directory exists
1666
+ const parentDir = path.dirname(worktreePath);
1667
+ if (!fs.existsSync(parentDir)) {
1668
+ fs.mkdirSync(parentDir, { recursive: true });
1669
+ }
1616
1670
 
1617
- // Create worktree with new branch based on baseRef (retry on branch collision/in-use)
1618
- for (let attempt = 0; attempt < 10; attempt++) {
1619
- // Best-effort delete if branch exists and is not in use by another worktree.
1671
+ // Best-effort cleanup of stale worktree metadata and directory.
1672
+ // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1673
+ // git may keep the branch "checked out" and block deletion/reuse.
1620
1674
  try {
1621
- runSync('git', ['branch', '-D', branchName], {
1675
+ runSync('git', ['worktree', 'remove', '--force', worktreePath], {
1622
1676
  cwd: repoRoot,
1623
1677
  encoding: 'utf8',
1624
1678
  stdio: 'pipe',
@@ -1626,38 +1680,72 @@ class IsolationManager {
1626
1680
  } catch {
1627
1681
  // ignore
1628
1682
  }
1629
-
1630
1683
  try {
1631
- runSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseRef], {
1684
+ runSync('git', ['worktree', 'prune'], {
1632
1685
  cwd: repoRoot,
1633
1686
  encoding: 'utf8',
1634
1687
  stdio: 'pipe',
1635
1688
  });
1636
- console.log(`[IsolationManager] Worktree setup phase: created branch ${branchName}`);
1637
- break;
1638
- } catch (err) {
1639
- const stderr = (
1640
- err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1641
- ).toLowerCase();
1642
- const isBranchCollision =
1643
- stderr.includes('already exists') ||
1644
- stderr.includes('cannot delete branch') ||
1645
- stderr.includes('checked out');
1646
-
1647
- if (attempt < 9 && isBranchCollision) {
1648
- branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1649
- try {
1650
- runSync('git', ['worktree', 'prune'], {
1651
- cwd: repoRoot,
1652
- encoding: 'utf8',
1653
- stdio: 'pipe',
1654
- });
1655
- } catch {
1656
- // ignore
1689
+ } catch {
1690
+ // ignore
1691
+ }
1692
+ try {
1693
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1694
+ } catch {
1695
+ // ignore
1696
+ }
1697
+
1698
+ console.log(`[IsolationManager] Worktree path: ${worktreePath}`);
1699
+
1700
+ // Create worktree with new branch based on baseRef (retry on branch collision/in-use)
1701
+ for (let attempt = 0; attempt < 10; attempt++) {
1702
+ // Best-effort delete if branch exists and is not in use by another worktree.
1703
+ try {
1704
+ runSync('git', ['branch', '-D', branchName], {
1705
+ cwd: repoRoot,
1706
+ encoding: 'utf8',
1707
+ stdio: 'pipe',
1708
+ });
1709
+ } catch {
1710
+ // ignore
1711
+ }
1712
+
1713
+ try {
1714
+ runSync('git', ['worktree', 'add', '-b', branchName, worktreePath, baseSha], {
1715
+ cwd: repoRoot,
1716
+ encoding: 'utf8',
1717
+ stdio: 'pipe',
1718
+ });
1719
+ console.log(`[IsolationManager] Worktree setup phase: created branch ${branchName}`);
1720
+ break;
1721
+ } catch (err) {
1722
+ const stderr = (
1723
+ err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1724
+ ).toLowerCase();
1725
+ const isBranchCollision =
1726
+ stderr.includes('already exists') ||
1727
+ stderr.includes('cannot delete branch') ||
1728
+ stderr.includes('checked out');
1729
+
1730
+ if (attempt < 9 && isBranchCollision) {
1731
+ branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1732
+ try {
1733
+ runSync('git', ['worktree', 'prune'], {
1734
+ cwd: repoRoot,
1735
+ encoding: 'utf8',
1736
+ stdio: 'pipe',
1737
+ });
1738
+ } catch {
1739
+ // ignore
1740
+ }
1741
+ continue;
1657
1742
  }
1658
- continue;
1743
+ throw err;
1659
1744
  }
1660
- throw err;
1745
+ }
1746
+ } finally {
1747
+ if (temporaryBaseRef) {
1748
+ deleteTemporaryBaseRef(repoRoot, temporaryBaseRef);
1661
1749
  }
1662
1750
  }
1663
1751
 
@@ -1684,6 +1772,8 @@ class IsolationManager {
1684
1772
  path: worktreePath,
1685
1773
  branch: branchName,
1686
1774
  repoRoot,
1775
+ baseRef,
1776
+ baseSha,
1687
1777
  };
1688
1778
  }
1689
1779