@the-open-engine/zeroshot 6.7.1 → 6.7.2

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.
@@ -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
 
@@ -224,6 +224,7 @@ function buildPrOptions(options, requiredQualityGates) {
224
224
  prBase: options.prBase || null,
225
225
  mergeQueue: options.mergeQueue || false,
226
226
  closeIssue: options.closeIssue || null,
227
+ gitRemote: options.gitRemote || null,
227
228
  autoMerge,
228
229
  ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
229
230
  cwd: options.cwd || process.cwd(),
@@ -1266,6 +1267,8 @@ class Orchestrator {
1266
1267
  const { detectGitContext } = require('../lib/git-remote-utils');
1267
1268
  const gitContext = detectGitContext(options.cwd);
1268
1269
  cluster.gitPlatform = gitContext?.provider || null;
1270
+ options.gitRemote = gitContext?.remote || options.gitRemote || null;
1271
+ cluster.prOptions = buildPrOptions(options, requiredQualityGates);
1269
1272
 
1270
1273
  if (cluster.gitPlatform) {
1271
1274
  this._log(`[Orchestrator] Git platform detected: ${cluster.gitPlatform.toUpperCase()}`);
@@ -1796,18 +1799,30 @@ class Orchestrator {
1796
1799
  const workDir = options.cwd || process.cwd();
1797
1800
 
1798
1801
  isolationManager = new IsolationManager({});
1799
- // `prBase` is the PR target branch. Use that same local ref as the
1800
- // worktree base so repo-local integration commits are not skipped.
1802
+ const { detectGitContext } = require('../lib/git-remote-utils');
1803
+ const gitContext = detectGitContext(workDir);
1804
+ if (gitContext?.remote) {
1805
+ options.gitRemote = gitContext.remote;
1806
+ }
1807
+
1808
+ // `prBase` is the PR target branch. Base isolated work on its refreshed
1809
+ // remote-tracking ref so a stale local branch cannot change the plan.
1801
1810
  const worktreeOptions = {};
1802
1811
  if (options.prBase) {
1803
- worktreeOptions.baseRef = options.prBase;
1804
- this._log(`[Orchestrator] Using worktree base ref: ${options.prBase}`);
1812
+ const remoteName = options.gitRemote || 'origin';
1813
+ worktreeOptions.baseRef = `${remoteName}/${options.prBase}`;
1814
+ if (remoteName !== 'origin') {
1815
+ worktreeOptions.remoteName = remoteName;
1816
+ }
1817
+ worktreeOptions.requireFreshBase = true;
1818
+ this._log(`[Orchestrator] Using worktree base ref: ${worktreeOptions.baseRef}`);
1805
1819
  }
1806
1820
  worktreeInfo = isolationManager.createWorktreeIsolation(clusterId, workDir, worktreeOptions);
1807
1821
 
1808
1822
  this._log(`[Orchestrator] Starting cluster in worktree isolation mode`);
1809
1823
  this._log(`[Orchestrator] Worktree: ${worktreeInfo.path}`);
1810
1824
  this._log(`[Orchestrator] Branch: ${worktreeInfo.branch}`);
1825
+ this._log(`[Orchestrator] Worktree base commit: ${worktreeInfo.baseSha}`);
1811
1826
  }
1812
1827
 
1813
1828
  return { isolationManager, containerId, worktreeInfo, image: isolationImage };
@@ -1870,16 +1885,13 @@ class Orchestrator {
1870
1885
  closeIssue: options.closeIssue,
1871
1886
  requiredQualityGates: options.requiredQualityGates,
1872
1887
  autoMerge: resolveRunPlan(options).autoMerge,
1888
+ gitRemote: gitContext?.remote || options.gitRemote,
1889
+ issueNumber: inputData.number,
1890
+ issueTitle: inputData.title,
1891
+ includeIssueReference: !skipCloseIssue,
1873
1892
  cwd: options.cwd,
1874
1893
  });
1875
1894
 
1876
- // Template replacement for issue context
1877
- const issueRef = skipCloseIssue ? '' : `Closes #${inputData.number || 'unknown'}`;
1878
- gitPusherConfig.prompt = gitPusherConfig.prompt
1879
- .replace(/\{\{issue_number\}\}/g, inputData.number || 'unknown')
1880
- .replace(/\{\{issue_title\}\}/g, inputData.title || 'Implementation')
1881
- .replace(/Closes #\{\{issue_number\}\}/g, issueRef);
1882
-
1883
1895
  config.agents.push(gitPusherConfig);
1884
1896
  this._log(
1885
1897
  `[Orchestrator] Injected ${platform}-git-pusher agent (issue: ${inputData.number || 'N/A'}, PR platform: ${platform.toUpperCase()})`
@@ -2649,31 +2661,68 @@ class Orchestrator {
2649
2661
  }
2650
2662
 
2651
2663
  _resolveFailureInfo(cluster, clusterId) {
2652
- if (cluster.failureInfo) {
2653
- return cluster.failureInfo.agentId ? cluster.failureInfo : null;
2664
+ const persistedFailure = cluster.failureInfo?.agentId ? cluster.failureInfo : null;
2665
+ if (persistedFailure) {
2666
+ if (!this._hasDurableProgressAfterFailure(cluster, clusterId, persistedFailure)) {
2667
+ return persistedFailure;
2668
+ }
2669
+ this._log(
2670
+ `[Orchestrator] Ignoring recovered registry failure from ${persistedFailure.agentId}; durable task progress followed it`
2671
+ );
2654
2672
  }
2655
2673
 
2656
2674
  const errors = cluster.messageBus.query({
2657
2675
  cluster_id: clusterId,
2658
2676
  topic: 'AGENT_ERROR',
2659
- limit: 10,
2660
2677
  order: 'desc',
2661
2678
  });
2662
2679
 
2663
- if (errors.length === 0) {
2664
- return null;
2680
+ for (const error of errors) {
2681
+ const failureInfo = {
2682
+ agentId: error.sender,
2683
+ taskId: error.content?.data?.taskId || null,
2684
+ iteration: error.content?.data?.iteration || 0,
2685
+ error: error.content?.data?.error || error.content?.text,
2686
+ timestamp: error.timestamp,
2687
+ };
2688
+ if (this._hasDurableProgressAfterFailure(cluster, clusterId, failureInfo)) {
2689
+ this._log(
2690
+ `[Orchestrator] Ignoring recovered ledger failure from ${error.sender}; durable task progress followed it`
2691
+ );
2692
+ continue;
2693
+ }
2694
+
2695
+ this._log(`[Orchestrator] Found failure from ledger: ${failureInfo.agentId}`);
2696
+ return failureInfo;
2665
2697
  }
2666
2698
 
2667
- const firstError = errors[0];
2668
- const failureInfo = {
2669
- agentId: firstError.sender,
2670
- taskId: firstError.content?.data?.taskId || null,
2671
- iteration: firstError.content?.data?.iteration || 0,
2672
- error: firstError.content?.data?.error || firstError.content?.text,
2673
- timestamp: firstError.timestamp,
2674
- };
2675
- this._log(`[Orchestrator] Found failure from ledger: ${failureInfo.agentId}`);
2676
- return failureInfo;
2699
+ return null;
2700
+ }
2701
+
2702
+ _hasDurableProgressAfterFailure(cluster, clusterId, failureInfo) {
2703
+ const failureIteration = failureInfo.iteration;
2704
+ const failureTimestamp = failureInfo.timestamp;
2705
+ return cluster.messageBus
2706
+ .query({
2707
+ cluster_id: clusterId,
2708
+ topic: 'AGENT_LIFECYCLE',
2709
+ sender: failureInfo.agentId,
2710
+ since: failureTimestamp,
2711
+ })
2712
+ .some((message) => {
2713
+ const event = message.content?.data?.event;
2714
+ if (!['TASK_STARTED', 'TASK_COMPLETED'].includes(event)) {
2715
+ return false;
2716
+ }
2717
+
2718
+ const iteration = message.content?.data?.iteration;
2719
+ return (
2720
+ (Number.isFinite(failureTimestamp) && message.timestamp > failureTimestamp) ||
2721
+ (Number.isInteger(failureIteration) &&
2722
+ Number.isInteger(iteration) &&
2723
+ iteration > failureIteration)
2724
+ );
2725
+ });
2677
2726
  }
2678
2727
 
2679
2728
  _requireFailedAgent(clusterId, cluster, failureInfo) {
@@ -3996,7 +4045,6 @@ Continue from where you left off. Review your previous output to understand what
3996
4045
  }
3997
4046
  }
3998
4047
 
3999
- // Generate platform-specific git-pusher agent from template
4000
4048
  const {
4001
4049
  generateGitPusherAgent,
4002
4050
  isPlatformSupported,
@@ -4008,18 +4056,19 @@ Continue from where you left off. Review your previous output to understand what
4008
4056
  );
4009
4057
  }
4010
4058
 
4011
- // Use persisted PR options from cluster state (or empty for repo settings fallback)
4012
- const gitPusherConfig = generateGitPusherAgent(platform, cluster.prOptions || {});
4013
-
4014
4059
  // Get issue context from ledger
4015
4060
  const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' });
4016
4061
  const issueNumber = issueMsg?.content?.data?.number || 'unknown';
4017
4062
  const issueTitle = issueMsg?.content?.data?.title || 'Implementation';
4018
4063
 
4019
- // Inject issue context into prompt
4020
- gitPusherConfig.prompt = gitPusherConfig.prompt
4021
- .replace(/\{\{issue_number\}\}/g, issueNumber)
4022
- .replace(/\{\{issue_title\}\}/g, issueTitle);
4064
+ // Generate the final prompt in one typed assembly pass. Issue values are
4065
+ // resolved before the shell-quoted remote is inserted, so placeholder-like
4066
+ // remote names cannot be rewritten by issue context.
4067
+ const gitPusherConfig = generateGitPusherAgent(platform, {
4068
+ ...(cluster.prOptions || {}),
4069
+ issueNumber,
4070
+ issueTitle,
4071
+ });
4023
4072
 
4024
4073
  await this._opAddAgents(cluster, { agents: [gitPusherConfig] }, context);
4025
4074
  this._log(` [--pr mode] Injected ${platform}-git-pusher agent`);
@@ -5,10 +5,8 @@
5
5
  * Runs detached from parent, provides Unix socket for attach clients.
6
6
  */
7
7
 
8
- import { appendFileSync, existsSync, mkdirSync, unlinkSync } from 'fs';
8
+ import { appendFileSync, unlinkSync } from 'fs';
9
9
  import { unlink } from 'fs/promises';
10
- import { join } from 'path';
11
- import { homedir } from 'os';
12
10
  import { updateTask } from './store.js';
13
11
  import {
14
12
  detectProviderFatalError,
@@ -72,6 +70,7 @@ process.on('unhandledRejection', (reason) => {
72
70
 
73
71
  const require = createRequire(import.meta.url);
74
72
  const { AttachServer } = require('../src/attach');
73
+ const { getTaskSocketPath } = require('../src/attach/socket-paths');
75
74
  const { normalizeProviderName } = require('../lib/provider-names');
76
75
 
77
76
  const taskId = taskIdArg;
@@ -88,12 +87,7 @@ const commandSpec = config.commandSpec || {
88
87
  commandSpecCleanup = commandSpec.cleanup || [];
89
88
  let server = null;
90
89
 
91
- const SOCKET_DIR = join(homedir(), '.zeroshot', 'sockets');
92
- const socketPath = join(SOCKET_DIR, `${taskId}.sock`);
93
-
94
- if (!existsSync(SOCKET_DIR)) {
95
- mkdirSync(SOCKET_DIR, { recursive: true });
96
- }
90
+ const socketPath = getTaskSocketPath(taskId);
97
91
 
98
92
  function log(msg) {
99
93
  appendFileSync(logFile, msg);
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { spawnTask } from '../runner.js';
2
+ import { shouldUseAttachableWatcher, spawnTask } from '../runner.js';
3
3
 
4
4
  export async function runTask(prompt, options = {}) {
5
5
  if (!prompt || prompt.trim().length === 0) {
@@ -46,8 +46,23 @@ export async function runTask(prompt, options = {}) {
46
46
  console.log(chalk.dim(` Log: ${task.logFile}`));
47
47
  console.log(chalk.dim(` CWD: ${task.cwd}`));
48
48
 
49
+ const attachSupported = shouldUseAttachableWatcher(
50
+ {
51
+ jsonSchema: outputFormat === 'json' ? jsonSchema : null,
52
+ },
53
+ task.provider
54
+ );
55
+
49
56
  console.log(chalk.dim('\nCommands:'));
50
- console.log(chalk.dim(` zeroshot attach ${task.id} # Attach to task (Ctrl+B d to detach)`));
57
+ if (attachSupported) {
58
+ console.log(chalk.dim(` zeroshot attach ${task.id} # Attach to task (Ctrl+B d to detach)`));
59
+ } else {
60
+ console.log(
61
+ chalk.dim(
62
+ ` Attach unavailable: ${task.provider} strict structured output uses a non-PTY watcher`
63
+ )
64
+ );
65
+ }
51
66
  console.log(chalk.dim(` zeroshot logs ${task.id} # View output`));
52
67
  console.log(chalk.dim(` zeroshot logs -f ${task.id} # Follow output`));
53
68
  console.log(chalk.dim(` zeroshot status ${task.id} # Check status`));
@@ -53,7 +53,13 @@ export function spawnTask(prompt, options = {}) {
53
53
  providerName,
54
54
  commandSpec
55
55
  );
56
- const watcherScript = resolveWatcherScript(options);
56
+ const watcherScript = resolveWatcherScript(
57
+ {
58
+ attachable: options.attachable,
59
+ jsonSchema,
60
+ },
61
+ providerName
62
+ );
57
63
  spawnWatcher({
58
64
  watcherScript,
59
65
  id,
@@ -165,8 +171,20 @@ function buildWatcherCommandSpec(commandSpec) {
165
171
  return watcherCommandSpec;
166
172
  }
167
173
 
168
- function resolveWatcherScript(options) {
169
- const useAttachable = options.attachable !== false && !options.jsonSchema;
174
+ export function shouldUseAttachableWatcher(options, providerName) {
175
+ if (options.attachable === false) {
176
+ return false;
177
+ }
178
+
179
+ // Claude strict structured output still needs the non-PTY watcher. Claude
180
+ // can treat PTY notifications as streaming commands and reject the run.
181
+ // Other providers, including Codex, support their structured-output mode in
182
+ // the attachable PTY watcher and must not lose the advertised attach socket.
183
+ return !(providerName === 'claude' && options.jsonSchema);
184
+ }
185
+
186
+ function resolveWatcherScript(options, providerName) {
187
+ const useAttachable = shouldUseAttachableWatcher(options, providerName);
170
188
  return useAttachable ? join(__dirname, 'attachable-watcher.js') : join(__dirname, 'watcher.js');
171
189
  }
172
190