@the-open-engine/zeroshot 6.2.0 → 6.3.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 (41) hide show
  1. package/README.md +141 -474
  2. package/cli/event-copy.js +12 -0
  3. package/cli/index.js +311 -96
  4. package/cli/message-formatters-normal.js +14 -2
  5. package/cli/message-formatters-watch.js +9 -2
  6. package/cluster-hooks/block-ask-user-question.py +53 -0
  7. package/cluster-hooks/block-dangerous-git.py +145 -0
  8. package/lib/clusters-registry.js +48 -0
  9. package/lib/compose-utils.js +101 -0
  10. package/lib/detached-startup.js +9 -0
  11. package/lib/id-detector.js +8 -9
  12. package/lib/path-check.js +63 -0
  13. package/lib/run-mode.js +22 -0
  14. package/lib/start-cluster.js +39 -23
  15. package/package.json +3 -4
  16. package/scripts/check-path.js +19 -0
  17. package/scripts/validate-templates.js +11 -29
  18. package/src/agent/agent-hook-executor.js +1 -1
  19. package/src/agent/agent-task-executor.js +4 -3
  20. package/src/agent/pr-verification.js +27 -1
  21. package/src/agents/git-pusher-template.js +121 -4
  22. package/src/attach/socket-discovery.js +2 -3
  23. package/src/claude-credentials.js +75 -0
  24. package/src/claude-task-runner.js +1 -0
  25. package/src/isolation-manager.js +12 -9
  26. package/src/issue-providers/README.md +45 -15
  27. package/src/issue-providers/index.js +2 -0
  28. package/src/issue-providers/jira-provider.js +8 -1
  29. package/src/issue-providers/linear-provider.js +405 -0
  30. package/src/ledger.js +42 -3
  31. package/src/lib/gc.js +2 -3
  32. package/src/message-bus.js +10 -0
  33. package/src/orchestrator.js +264 -144
  34. package/src/preflight.js +12 -16
  35. package/src/providers/base-provider.js +7 -6
  36. package/src/status-footer.js +13 -1
  37. package/src/template-validation/index.js +3 -0
  38. package/src/template-validation/report-formatter.js +55 -0
  39. package/src/worktree-claude-config.js +2 -13
  40. package/task-lib/runner.js +1 -0
  41. package/task-lib/watcher.js +1 -0
package/cli/index.js CHANGED
@@ -22,6 +22,7 @@ const { URL } = require('url');
22
22
  const chalk = require('chalk');
23
23
  const Orchestrator = require('../src/orchestrator');
24
24
  const { setupCompletion } = require('../lib/completion');
25
+ const { resolveRunMode, describeRunMode } = require('../lib/run-mode');
25
26
  const { formatWatchMode } = require('./message-formatters-watch');
26
27
  const {
27
28
  formatAgentLifecycle,
@@ -48,10 +49,16 @@ const {
48
49
  } = require('../lib/settings');
49
50
  const { normalizeProviderName } = require('../lib/provider-names');
50
51
  const { getProvider, parseProviderChunk } = require('../src/providers');
52
+ const { readClustersFileSync } = require('../lib/clusters-registry');
51
53
  const { MOUNT_PRESETS, resolveEnvs } = require('../lib/docker-config');
52
54
  const {
53
55
  detectGitRepoRoot,
54
56
  detectRunInput,
57
+ isStdinInput,
58
+ readStdinText,
59
+ encodeStdinEnv,
60
+ decodeStdinEnv,
61
+ buildTextInput,
55
62
  loadClusterConfig,
56
63
  resolveConfigPath,
57
64
  resolveProviderOverride,
@@ -66,10 +73,13 @@ const { runCmdproof } = require('./commands/cmdproof');
66
73
  const {
67
74
  markDetachedSetupFailed,
68
75
  registerDetachedSetupCluster,
76
+ removeDetachedSetupCluster,
69
77
  } = require('../lib/detached-startup');
70
78
  // Setup wizard removed - use: zeroshot settings set <key> <value>
71
79
  const { checkForUpdates, printLegacyDistroNotice } = require('./lib/update-checker');
80
+ const { checkBinDirOnPath, printPathWarning } = require('../lib/path-check');
72
81
  const { StatusFooter, AGENT_STATE, ACTIVE_STATES } = require('../src/status-footer');
82
+ const { EVENT_COPY, formatMergeStatus } = require('./event-copy');
73
83
 
74
84
  // =============================================================================
75
85
  // GLOBAL ERROR HANDLERS - Prevent silent process death
@@ -179,9 +189,10 @@ function normalizeRunOptions(options) {
179
189
  if (options.docker) {
180
190
  options.worktree = false;
181
191
  }
192
+ options.autoMerge = Boolean(options.ship);
182
193
  }
183
194
 
184
- function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) {
195
+ async function runClusterPreflight({ input, options, providerOverride, settings, forceProvider }) {
185
196
  // Detect which issue provider tool is needed
186
197
  let issueProvider = null;
187
198
  let targetHost = null;
@@ -205,7 +216,7 @@ function runClusterPreflight({ input, options, providerOverride, settings, force
205
216
  }
206
217
  }
207
218
 
208
- requirePreflight({
219
+ await requirePreflight({
209
220
  requireGh: issueProvider === 'github', // gh CLI required for GitHub
210
221
  requireDocker: options.docker,
211
222
  requireGit: options.worktree,
@@ -221,11 +232,8 @@ function shouldRunDetached(options) {
221
232
  }
222
233
 
223
234
  function printDetachedClusterStart(options, clusterId, logPath) {
224
- if (options.docker) {
225
- console.log(`Started ${clusterId} (docker)`);
226
- } else {
227
- console.log(`Started ${clusterId}`);
228
- }
235
+ const runMode = resolveRunMode(options);
236
+ console.log(runMode ? `Started ${clusterId} (${runMode})` : `Started ${clusterId}`);
229
237
  if (logPath) {
230
238
  console.log(`Setup log: ${logPath}`);
231
239
  }
@@ -256,7 +264,7 @@ function resolveMergeQueueEnv(value) {
256
264
  return '';
257
265
  }
258
266
 
259
- function buildDaemonEnv(options, clusterId, targetCwd) {
267
+ function buildDaemonEnv(options, clusterId, targetCwd, stdinText) {
260
268
  const mergeQueueEnv = resolveMergeQueueEnv(options.mergeQueue);
261
269
  const runOptionsEnv = serializeRunOptions(options);
262
270
  return {
@@ -275,10 +283,11 @@ function buildDaemonEnv(options, clusterId, targetCwd) {
275
283
  ZEROSHOT_MERGE_QUEUE: mergeQueueEnv,
276
284
  ZEROSHOT_CLOSE_ISSUE: options.closeIssue || '',
277
285
  ZEROSHOT_CWD: targetCwd,
286
+ ZEROSHOT_STDIN_TASK: stdinText !== undefined ? encodeStdinEnv(stdinText) : '',
278
287
  };
279
288
  }
280
289
 
281
- async function spawnDetachedCluster(options, clusterId) {
290
+ async function spawnDetachedCluster(options, clusterId, stdinText) {
282
291
  const { spawn } = require('child_process');
283
292
  const logFd = createDaemonLogFile(clusterId);
284
293
  const targetCwd = detectGitRepoRoot();
@@ -287,7 +296,7 @@ async function spawnDetachedCluster(options, clusterId) {
287
296
  detached: true,
288
297
  stdio: ['ignore', logFd, logFd],
289
298
  cwd: targetCwd,
290
- env: buildDaemonEnv(options, clusterId, targetCwd),
299
+ env: buildDaemonEnv(options, clusterId, targetCwd, stdinText),
291
300
  });
292
301
  await registerDetachedSetupCluster({
293
302
  clusterId,
@@ -321,13 +330,8 @@ function printForegroundStartInfo(options, clusterId, configName) {
321
330
  if (process.env.ZEROSHOT_DAEMON) {
322
331
  return;
323
332
  }
324
- if (options.docker) {
325
- console.log(`Starting ${clusterId} (docker)`);
326
- } else if (options.worktree) {
327
- console.log(`Starting ${clusterId} (worktree)`);
328
- } else {
329
- console.log(`Starting ${clusterId}`);
330
- }
333
+ const runMode = resolveRunMode(options);
334
+ console.log(runMode ? `Starting ${clusterId} (${runMode})` : `Starting ${clusterId}`);
331
335
  console.log(chalk.dim(`Config: ${configName}`));
332
336
  console.log(chalk.dim('Ctrl+C to stop following (cluster keeps running)\n'));
333
337
  }
@@ -389,13 +393,14 @@ function applyModelOverrideToConfig(config, modelOverride, providerOverride, set
389
393
  console.log(chalk.dim(`Model override: ${modelOverride} (all agents)`));
390
394
  }
391
395
 
392
- function createStatusFooter(clusterId, messageBus) {
396
+ function createStatusFooter(clusterId, messageBus, runMode) {
393
397
  const statusFooter = new StatusFooter({
394
398
  refreshInterval: 1000,
395
399
  enabled: process.stdout.isTTY,
396
400
  });
397
401
  statusFooter.setCluster(clusterId);
398
402
  statusFooter.setClusterState('running');
403
+ statusFooter.setRunMode(runMode);
399
404
  statusFooter.setMessageBus(messageBus);
400
405
  activeStatusFooter = statusFooter;
401
406
  return statusFooter;
@@ -571,11 +576,11 @@ function waitForClusterCompletion(orchestrator, clusterId, cleanup) {
571
576
  });
572
577
  }
573
578
 
574
- async function streamClusterInForeground(cluster, orchestrator, clusterId) {
579
+ async function streamClusterInForeground(cluster, orchestrator, clusterId, options) {
575
580
  const sendersWithOutput = new Set();
576
581
  const processedMessageIds = new Set();
577
582
 
578
- const statusFooter = createStatusFooter(clusterId, cluster.messageBus);
583
+ const statusFooter = createStatusFooter(clusterId, cluster.messageBus, resolveRunMode(options));
579
584
  const handleLifecycleMessage = createLifecycleHandler(statusFooter);
580
585
  const lifecycleUnsubscribe = cluster.messageBus.subscribeTopic(
581
586
  'AGENT_LIFECYCLE',
@@ -629,22 +634,28 @@ function setupDaemonCleanup(orchestrator, clusterId) {
629
634
  }
630
635
 
631
636
  function readClusterTokenTotals(orchestrator, clusterId) {
632
- let totalTokens = 0;
633
- let totalCostUsd = 0;
634
637
  try {
635
638
  const clusterObj = orchestrator.getCluster(clusterId);
636
- if (clusterObj?.messageBus) {
637
- const tokensByRole = clusterObj.messageBus.getTokensByRole(clusterId);
638
- if (tokensByRole?._total?.count > 0) {
639
- const total = tokensByRole._total;
640
- totalTokens = (total.inputTokens || 0) + (total.outputTokens || 0);
641
- totalCostUsd = total.totalCostUsd || 0;
642
- }
639
+ if (!clusterObj?.messageBus) {
640
+ return { totalTokens: 0, totalCostUsd: 0 };
643
641
  }
644
- } catch {
645
- /* Token tracking not available */
642
+ const { tokensByRole } = clusterObj.messageBus.readSnapshot(clusterId);
643
+ const total = tokensByRole?._total;
644
+ if (!total || total.count === 0) {
645
+ return { totalTokens: 0, totalCostUsd: 0 };
646
+ }
647
+ return {
648
+ totalTokens: (total.inputTokens || 0) + (total.outputTokens || 0),
649
+ totalCostUsd: total.totalCostUsd || 0,
650
+ };
651
+ } catch (error) {
652
+ // A confirmed-empty ledger (0) and a failed read must stay distinguishable -
653
+ // fabricating 0 here is what produced phantom "msgs=0 $0" rows in `list --json`.
654
+ console.error(
655
+ `[cli] Failed to read token totals for cluster ${clusterId}: ${error.message || String(error)}`
656
+ );
657
+ return { totalTokens: null, totalCostUsd: null };
646
658
  }
647
- return { totalTokens, totalCostUsd };
648
659
  }
649
660
 
650
661
  function enrichClustersWithTokens(clusters, orchestrator) {
@@ -672,8 +683,9 @@ function formatClusterRow(cluster) {
672
683
  const stateDisplay =
673
684
  cluster.state === 'zombie' ? chalk.red(cluster.state.padEnd(12)) : cluster.state.padEnd(12);
674
685
  const rowColor = cluster.state === 'zombie' ? chalk.red : (text) => text;
686
+ const modeDisplay = (cluster.runMode || '-').padEnd(10);
675
687
 
676
- return `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${cluster.agentCount
688
+ return `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${modeDisplay} ${cluster.agentCount
677
689
  .toString()
678
690
  .padEnd(8)} ${tokenDisplay.padEnd(12)} ${costDisplay.padEnd(8)} ${created}`;
679
691
  }
@@ -687,11 +699,11 @@ function printClusterTable(enrichedClusters) {
687
699
 
688
700
  console.log(chalk.bold('\n=== Clusters ==='));
689
701
  console.log(
690
- `${'ID'.padEnd(25)} ${'State'.padEnd(12)} ${'Agents'.padEnd(8)} ${'Tokens'.padEnd(
691
- 12
692
- )} ${'Cost'.padEnd(8)} Created`
702
+ `${'ID'.padEnd(25)} ${'State'.padEnd(12)} ${'Mode'.padEnd(10)} ${'Agents'.padEnd(
703
+ 8
704
+ )} ${'Tokens'.padEnd(12)} ${'Cost'.padEnd(8)} Created`
693
705
  );
694
- console.log('-'.repeat(100));
706
+ console.log('-'.repeat(110));
695
707
  for (const cluster of enrichedClusters) {
696
708
  console.log(formatClusterRow(cluster));
697
709
  }
@@ -734,13 +746,16 @@ function reportMissingId(id, options) {
734
746
  function getClusterTokensByRole(orchestrator, clusterId) {
735
747
  try {
736
748
  const cluster = orchestrator.getCluster(clusterId);
737
- if (cluster?.messageBus) {
738
- return cluster.messageBus.getTokensByRole(clusterId);
749
+ if (!cluster?.messageBus) {
750
+ return null;
739
751
  }
740
- } catch {
741
- /* Token tracking not available */
752
+ return cluster.messageBus.readSnapshot(clusterId).tokensByRole;
753
+ } catch (error) {
754
+ console.error(
755
+ `[cli] Failed to read tokens by role for cluster ${clusterId}: ${error.message || String(error)}`
756
+ );
757
+ return null;
742
758
  }
743
- return null;
744
759
  }
745
760
 
746
761
  function printClusterStatusJson(status, tokensByRole) {
@@ -774,6 +789,9 @@ function printClusterStatusHeader(status, clusterId) {
774
789
  } else {
775
790
  console.log(`State: ${status.state}`);
776
791
  }
792
+ if (status.runMode) {
793
+ console.log(`Mode: ${describeRunMode(status.runMode)}`);
794
+ }
777
795
  if (status.pid) {
778
796
  console.log(`PID: ${status.pid}`);
779
797
  }
@@ -1216,9 +1234,26 @@ function followSetupLog(logPath) {
1216
1234
  }
1217
1235
 
1218
1236
  function printRecentMessages(messages, limit, isActive, options) {
1219
- const recentMessages = messages.slice(-limit);
1220
- for (const msg of recentMessages) {
1221
- printMessage(msg, true, options.watch, isActive);
1237
+ if (options.watch) {
1238
+ const recentMessages = selectRecentPrintableMessages(messages, limit);
1239
+ for (const msg of recentMessages) {
1240
+ printMessage(msg, true, true, isActive);
1241
+ }
1242
+ if (recentMessages.length === 0 && messages.length > 0) {
1243
+ console.log(chalk.dim(`No printable history in ${messages.length} stored messages.`));
1244
+ }
1245
+ return;
1246
+ }
1247
+
1248
+ const output = renderRecentMessagesToTerminal(messages, limit);
1249
+
1250
+ if (output) {
1251
+ console.log(output);
1252
+ return;
1253
+ }
1254
+
1255
+ if (messages.length > 0) {
1256
+ console.log(chalk.dim(`No printable history in ${messages.length} stored messages.`));
1222
1257
  }
1223
1258
  }
1224
1259
 
@@ -1461,9 +1496,8 @@ async function printAttachableClusters(clusters, socketDiscovery) {
1461
1496
  }
1462
1497
 
1463
1498
  function readClusterFromDisk(id) {
1464
- const clustersFile = path.join(os.homedir(), '.zeroshot', 'clusters.json');
1465
1499
  try {
1466
- const clusters = JSON.parse(fs.readFileSync(clustersFile, 'utf8'));
1500
+ const clusters = readClustersFileSync(path.join(os.homedir(), '.zeroshot'));
1467
1501
  return clusters[id] || null;
1468
1502
  } catch {
1469
1503
  return null;
@@ -1554,7 +1588,7 @@ async function resolveClusterSocketPath(id, options, socketDiscovery) {
1554
1588
  const cluster = readClusterFromDisk(id);
1555
1589
  ensureClusterRunning(cluster, id);
1556
1590
 
1557
- const orchestrator = await Orchestrator.create({ quiet: true });
1591
+ const orchestrator = await Orchestrator.create({ quiet: true, readonly: true });
1558
1592
  try {
1559
1593
  const status = orchestrator.getStatus(id);
1560
1594
  const activeAgents = getActiveAgents(status);
@@ -2028,6 +2062,31 @@ function getOrchestrator() {
2028
2062
  return _orchestratorPromise;
2029
2063
  }
2030
2064
 
2065
+ // Separate lazy-loaded read-only orchestrator for commands that only ever read
2066
+ // cluster state (list/status/logs). Read-only ledgers can never contend with a
2067
+ // live daemon's writer connection or mutate clusters.json as a side effect.
2068
+ /** @type {import('../src/orchestrator') | null} */
2069
+ let _readonlyOrchestrator = null;
2070
+ /** @type {Promise<import('../src/orchestrator')> | null} */
2071
+ let _readonlyOrchestratorPromise = null;
2072
+ /**
2073
+ * @returns {Promise<import('../src/orchestrator')>}
2074
+ */
2075
+ function getReadonlyOrchestrator() {
2076
+ if (_readonlyOrchestrator) {
2077
+ return Promise.resolve(_readonlyOrchestrator);
2078
+ }
2079
+ if (!_readonlyOrchestratorPromise) {
2080
+ _readonlyOrchestratorPromise = Orchestrator.create({ quiet: true, readonly: true }).then(
2081
+ (orch) => {
2082
+ _readonlyOrchestrator = orch;
2083
+ return orch;
2084
+ }
2085
+ );
2086
+ }
2087
+ return _readonlyOrchestratorPromise;
2088
+ }
2089
+
2031
2090
  /**
2032
2091
  * @typedef {Object} TaskLogMessage
2033
2092
  * @property {string} topic
@@ -2323,7 +2382,9 @@ Shell completion:
2323
2382
  // Run command - CLUSTER with auto-detection
2324
2383
  program
2325
2384
  .command('run <input>')
2326
- .description('Start a multi-agent cluster (GitHub issue, markdown file, or plain text)')
2385
+ .description(
2386
+ 'Start a multi-agent cluster (GitHub issue, markdown file, plain text, or "-" for stdin)'
2387
+ )
2327
2388
  .option('--config <file>', 'Path to cluster config JSON (default: conductor-bootstrap)')
2328
2389
  .option('--docker', 'Run cluster inside Docker container (full isolation)')
2329
2390
  .option('--worktree', 'Use git worktree for isolation (lightweight, no Docker required)')
@@ -2337,7 +2398,7 @@ program
2337
2398
  )
2338
2399
  .option(
2339
2400
  '--pr',
2340
- 'Create PR for human review (uses worktree isolation by default, use --docker for Docker)'
2401
+ 'Create PR for human review (uses worktree isolation by default, use --docker for Docker). Never auto-merges itself; a repo-side branch-protection auto-merge rule or merge queue may still merge the PR independently of zeroshot.'
2341
2402
  )
2342
2403
  .option(
2343
2404
  '--ship',
@@ -2364,6 +2425,7 @@ program
2364
2425
  .option('-L, --gitlab', 'Force GitLab as issue source')
2365
2426
  .option('-J, --jira', 'Force Jira as issue source')
2366
2427
  .option('-D, --devops', 'Force Azure DevOps as issue source')
2428
+ .option('-N, --linear', 'Force Linear as issue source')
2367
2429
  .option('-d, --detach', 'Run in background (default: attach to first agent)')
2368
2430
  .option('--mount <spec...>', 'Add Docker mount (host:container[:ro]). Repeatable.')
2369
2431
  .option('--no-mounts', 'Disable all Docker credential mounts')
@@ -2391,11 +2453,20 @@ Input formats:
2391
2453
  Azure DevOps:
2392
2454
  https://dev.azure.com/org/proj/_workitems/edit/123
2393
2455
 
2456
+ Linear:
2457
+ ENG-42 Issue key
2458
+ https://linear.app/<workspace>/issue/ENG-42/...
2459
+
2394
2460
  File/Text:
2395
2461
  feature.md Markdown file
2396
2462
  "Implement feature X" Plain text task
2397
2463
 
2398
- Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps)
2464
+ Stdin:
2465
+ - Read task body from stdin (pipe/redirect)
2466
+ pbpaste | zeroshot run -
2467
+ zeroshot run - < issue.md
2468
+
2469
+ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps), -N (Linear)
2399
2470
  `
2400
2471
  )
2401
2472
  .action(async (inputArg, options) => {
@@ -2409,14 +2480,41 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps)
2409
2480
  else if (options.gitlab) forceProvider = 'gitlab';
2410
2481
  else if (options.jira) forceProvider = 'jira';
2411
2482
  else if (options.devops) forceProvider = 'azure-devops';
2483
+ else if (options.linear) forceProvider = 'linear';
2484
+
2485
+ // Stdin input ('-'): read task body from stdin to avoid shell-quoting breakage
2486
+ let stdinText;
2487
+ if (isStdinInput(inputArg)) {
2488
+ if (process.env.ZEROSHOT_DAEMON === '1') {
2489
+ const encoded = process.env.ZEROSHOT_STDIN_TASK;
2490
+ if (!encoded) {
2491
+ throw new Error('zeroshot run -: daemon mode missing forwarded stdin content');
2492
+ }
2493
+ stdinText = decodeStdinEnv(encoded);
2494
+ } else if (process.stdin.isTTY) {
2495
+ throw new Error(
2496
+ 'zeroshot run -: no piped input detected. Pipe or redirect a task body, e.g.\n' +
2497
+ ' pbpaste | zeroshot run -\n' +
2498
+ ' zeroshot run - < issue.md'
2499
+ );
2500
+ } else {
2501
+ stdinText = await readStdinText();
2502
+ }
2503
+ if (!stdinText) {
2504
+ throw new Error('zeroshot run -: stdin was empty, no task content received');
2505
+ }
2506
+ }
2412
2507
 
2413
2508
  // Auto-detect input type
2414
- const input = detectRunInput(inputArg);
2415
2509
  const settings = loadSettings();
2510
+ const input =
2511
+ stdinText !== undefined
2512
+ ? buildTextInput(stdinText)
2513
+ : detectRunInput(inputArg, settings, forceProvider);
2416
2514
  const providerOverride = resolveProviderOverride(options);
2417
2515
 
2418
2516
  // Preflight checks
2419
- runClusterPreflight({ input, options, providerOverride, settings, forceProvider });
2517
+ await runClusterPreflight({ input, options, providerOverride, settings, forceProvider });
2420
2518
 
2421
2519
  // Secondary preflight: token-free template simulation/validation
2422
2520
  const simMode = String(options.sim || 'fast').toLowerCase();
@@ -2446,7 +2544,7 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps)
2446
2544
 
2447
2545
  if (shouldRunDetached(options)) {
2448
2546
  const clusterId = generateName('cluster');
2449
- await spawnDetachedCluster(options, clusterId);
2547
+ await spawnDetachedCluster(options, clusterId, stdinText);
2450
2548
  return;
2451
2549
  }
2452
2550
 
@@ -2511,11 +2609,28 @@ Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps)
2511
2609
  }
2512
2610
 
2513
2611
  if (!process.env.ZEROSHOT_DAEMON) {
2514
- await streamClusterInForeground(cluster, orchestrator, clusterId);
2612
+ await streamClusterInForeground(cluster, orchestrator, clusterId, options);
2515
2613
  }
2516
2614
 
2517
2615
  setupDaemonCleanup(orchestrator, clusterId);
2518
2616
  } catch (error) {
2617
+ if (error.code === 'DUPLICATE_CLUSTER') {
2618
+ // Benign guard rejection, not a crash: nothing was allocated, so there is
2619
+ // nothing to roll back. No stack trace, no "Error:" framing.
2620
+ if (process.env.ZEROSHOT_DAEMON && process.env.ZEROSHOT_CLUSTER_ID) {
2621
+ try {
2622
+ await removeDetachedSetupCluster({
2623
+ clusterId: process.env.ZEROSHOT_CLUSTER_ID,
2624
+ storageDir: path.join(os.homedir(), '.zeroshot'),
2625
+ });
2626
+ } catch (removeError) {
2627
+ console.error('Failed to remove provisional setup cluster:', removeError.message);
2628
+ }
2629
+ }
2630
+ console.log(chalk.yellow(error.message));
2631
+ process.exit(1);
2632
+ }
2633
+
2519
2634
  if (process.env.ZEROSHOT_DAEMON && process.env.ZEROSHOT_CLUSTER_ID) {
2520
2635
  try {
2521
2636
  await markDetachedSetupFailed({
@@ -2585,7 +2700,7 @@ taskCmd
2585
2700
  const providerOverride = normalizeProviderName(
2586
2701
  options.provider || process.env.ZEROSHOT_PROVIDER || settings.defaultProvider
2587
2702
  );
2588
- requirePreflight({
2703
+ await requirePreflight({
2589
2704
  requireGh: false, // gh not needed for plain tasks
2590
2705
  requireDocker: false, // Docker not needed for plain tasks
2591
2706
  quiet: false,
@@ -2629,7 +2744,7 @@ program
2629
2744
  .option('--json', 'Output as JSON')
2630
2745
  .action(async (options) => {
2631
2746
  try {
2632
- const orchestrator = await getOrchestrator();
2747
+ const orchestrator = await getReadonlyOrchestrator();
2633
2748
  const clusters = filterClustersByStatus(orchestrator.listClusters(), options.status);
2634
2749
  const enrichedClusters = enrichClustersWithTokens(clusters, orchestrator);
2635
2750
 
@@ -2667,7 +2782,7 @@ program
2667
2782
  }
2668
2783
 
2669
2784
  if (type === 'cluster') {
2670
- const orchestrator = await getOrchestrator();
2785
+ const orchestrator = await getReadonlyOrchestrator();
2671
2786
  const status = orchestrator.getStatus(id);
2672
2787
  const tokensByRole = getClusterTokensByRole(orchestrator, id);
2673
2788
  if (options.json) {
@@ -2725,7 +2840,7 @@ program
2725
2840
  }
2726
2841
 
2727
2842
  const limit = parseLogLimit(options);
2728
- const quietOrchestrator = await Orchestrator.create({ quiet: true });
2843
+ const quietOrchestrator = await Orchestrator.create({ quiet: true, readonly: true });
2729
2844
 
2730
2845
  if (!id) {
2731
2846
  showAllClusterLogs(quietOrchestrator, options, limit);
@@ -2949,9 +3064,9 @@ program
2949
3064
  program
2950
3065
  .command('export <cluster-id>')
2951
3066
  .description('Export cluster conversation')
2952
- .option('-f, --format <format>', 'Export format: json, markdown, pdf', 'pdf')
2953
- .option('-o, --output <file>', 'Output file (auto-generated for pdf)')
2954
- .action(async (clusterId, options) => {
3067
+ .option('-f, --format <format>', 'Export format: json, markdown, html', 'html')
3068
+ .option('-o, --output <file>', 'Output file (auto-generated for html)')
3069
+ .action((clusterId, options) => {
2955
3070
  try {
2956
3071
  // Get messages from DB
2957
3072
  const Ledger = require('../src/ledger');
@@ -2993,10 +3108,12 @@ program
2993
3108
  return;
2994
3109
  }
2995
3110
 
2996
- // PDF export - convert ANSI to HTML, then render it to PDF
2997
- const outputFile = options.output || `${clusterId}.pdf`;
3111
+ // HTML export - convert ANSI to a self-contained, print-ready HTML file.
3112
+ // Open it in any browser and use Print -> Save as PDF for a PDF copy.
3113
+ // `pdf` is accepted as a migration alias for the previous puppeteer-based export.
3114
+ const isLegacyPdfFormat = options.format === 'pdf';
3115
+ const outputFile = options.output || `${clusterId}.html`;
2998
3116
  const AnsiToHtml = require('ansi-to-html');
2999
- const { default: puppeteer } = await import('puppeteer');
3000
3117
 
3001
3118
  const ansiConverter = new AnsiToHtml({
3002
3119
  fg: '#d4d4d4',
@@ -3042,27 +3159,13 @@ program
3042
3159
  </body>
3043
3160
  </html>`;
3044
3161
 
3045
- const browser = await puppeteer.launch();
3046
- try {
3047
- const page = await browser.newPage();
3048
- await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
3049
- const pdf = await page.pdf({
3050
- format: 'A4',
3051
- landscape: true,
3052
- margin: {
3053
- top: '10mm',
3054
- right: '10mm',
3055
- bottom: '10mm',
3056
- left: '10mm',
3057
- },
3058
- printBackground: true,
3059
- });
3060
-
3061
- require('fs').writeFileSync(outputFile, pdf);
3062
- } finally {
3063
- await browser.close();
3064
- }
3162
+ require('fs').writeFileSync(outputFile, fullHtml, 'utf8');
3065
3163
  console.log(`Exported to ${outputFile}`);
3164
+ if (isLegacyPdfFormat) {
3165
+ console.log(
3166
+ 'Note: PDF export now produces print-ready HTML. Open it in a browser and use Print -> Save as PDF.'
3167
+ );
3168
+ }
3066
3169
  } catch (error) {
3067
3170
  console.error('Error exporting cluster:', error.message);
3068
3171
  process.exit(1);
@@ -3096,7 +3199,7 @@ program
3096
3199
  cluster.config?.defaultProvider ||
3097
3200
  settings.defaultProvider;
3098
3201
 
3099
- requirePreflight({
3202
+ await requirePreflight({
3100
3203
  requireGh: false, // Resume doesn't fetch new issues
3101
3204
  requireDocker: requiresDocker,
3102
3205
  quiet: false,
@@ -3228,7 +3331,7 @@ program
3228
3331
  // If task store is unavailable, fall back to default provider
3229
3332
  }
3230
3333
 
3231
- requirePreflight({
3334
+ await requirePreflight({
3232
3335
  requireGh: false,
3233
3336
  requireDocker: false,
3234
3337
  quiet: false,
@@ -3334,6 +3437,23 @@ async function runGc(dryRun) {
3334
3437
  }
3335
3438
  }
3336
3439
 
3440
+ async function detectAndReportCorruptedClusters(dryRun) {
3441
+ if (dryRun) {
3442
+ return;
3443
+ }
3444
+ try {
3445
+ const orchestrator = await getOrchestrator();
3446
+ const corrupted = await orchestrator.detectCorruptedClusters();
3447
+ if (corrupted.length > 0) {
3448
+ console.log(chalk.yellow(`\nFound ${corrupted.length} corrupted cluster(s):`));
3449
+ corrupted.forEach((id) => console.log(chalk.yellow(` ${id}`)));
3450
+ console.log(chalk.dim(`Run 'zeroshot kill <id>' to remove them.`));
3451
+ }
3452
+ } catch (error) {
3453
+ console.error(`Error detecting corrupted clusters: ${error.message}`);
3454
+ }
3455
+ }
3456
+
3337
3457
  program
3338
3458
  .command('gc')
3339
3459
  .description('Clean up orphaned worktree directories and stale database files')
@@ -3341,6 +3461,7 @@ program
3341
3461
  .action(async (options) => {
3342
3462
  try {
3343
3463
  printGcResult(await runGc(options.dryRun), options.dryRun);
3464
+ await detectAndReportCorruptedClusters(options.dryRun);
3344
3465
  } catch (error) {
3345
3466
  console.error('Error during garbage collection:', error.message);
3346
3467
  process.exit(1);
@@ -3514,9 +3635,11 @@ function printNonDockerSettings(settings) {
3514
3635
  if (dockerKeys.has(key)) {
3515
3636
  continue;
3516
3637
  }
3638
+ const displayValue =
3639
+ key === 'linearApiKey' && value ? `${String(value).slice(0, 7)}***` : value;
3517
3640
  const isDefault = JSON.stringify(DEFAULT_SETTINGS[key]) === JSON.stringify(value);
3518
3641
  const label = isDefault ? chalk.dim(key) : chalk.cyan(key);
3519
- const val = isDefault ? chalk.dim(String(value)) : chalk.white(String(value));
3642
+ const val = isDefault ? chalk.dim(String(displayValue)) : chalk.white(String(displayValue));
3520
3643
  console.log(` ${label.padEnd(30)} ${val}`);
3521
3644
  }
3522
3645
  }
@@ -4788,7 +4911,9 @@ function handleIssueOpenedRender({ msg, prefix, timestamp, lines }) {
4788
4911
  }
4789
4912
 
4790
4913
  function handleImplementationReadyRender({ prefix, timestamp, lines }) {
4791
- lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('✅ IMPLEMENTATION READY')}`);
4914
+ lines.push(
4915
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow(`✅ ${EVENT_COPY.IMPLEMENTATION_READY.toUpperCase()}`)}`
4916
+ );
4792
4917
  }
4793
4918
 
4794
4919
  function normalizeIssueList(rawIssues) {
@@ -4857,12 +4982,17 @@ function handleValidationResultRender({ msg, prefix, timestamp, lines }) {
4857
4982
  function handlePrCreatedRender({ msg, prefix, timestamp, lines }) {
4858
4983
  lines.push('');
4859
4984
  lines.push(chalk.bold.green('─'.repeat(80)));
4860
- lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('🔗 PR CREATED')}`);
4985
+ lines.push(
4986
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green(`🔗 ${EVENT_COPY.PR_CREATED.toUpperCase()}`)}`
4987
+ );
4861
4988
  if (msg.content?.data?.pr_url) {
4862
4989
  lines.push(`${prefix} ${chalk.cyan(msg.content.data.pr_url)}`);
4863
4990
  }
4864
- if (msg.content?.data?.merged) {
4865
- lines.push(`${prefix} ${chalk.bold.cyan('✓ MERGED')}`);
4991
+ const mergeStatus = formatMergeStatus(msg.content?.data?.merged);
4992
+ if (mergeStatus) {
4993
+ lines.push(
4994
+ `${prefix} ${chalk.gray('Merge:')} ${mergeStatus === 'merged' ? chalk.bold.cyan(mergeStatus) : chalk.yellow(mergeStatus)}`
4995
+ );
4866
4996
  }
4867
4997
  lines.push(chalk.bold.green('─'.repeat(80)));
4868
4998
  }
@@ -4945,7 +5075,15 @@ function handleAgentOutputRender({ msg, prefix, lines, buffers, toolCalls }) {
4945
5075
  }
4946
5076
  if (event.type === 'tool_result') {
4947
5077
  appendAgentToolResultEvent(lines, msg.sender, prefix, toolCalls, event);
5078
+ continue;
4948
5079
  }
5080
+ if (event.type === 'result') {
5081
+ appendAgentResultEvent(lines, prefix, event);
5082
+ }
5083
+ }
5084
+
5085
+ if (events.length === 0) {
5086
+ appendRawAgentOutputLines(lines, prefix, content);
4949
5087
  }
4950
5088
  }
4951
5089
 
@@ -4960,6 +5098,63 @@ const RENDER_TOPIC_HANDLERS = {
4960
5098
  AGENT_OUTPUT: handleAgentOutputRender,
4961
5099
  };
4962
5100
 
5101
+ const HISTORY_NOISE_TOPICS = new Set(['STATE_SNAPSHOT', 'TOKEN_USAGE']);
5102
+
5103
+ function appendAgentResultEvent(lines, prefix, event) {
5104
+ if (!event.success) {
5105
+ lines.push(`${prefix} ${chalk.bold.red('✗ Error:')} ${event.error || 'Task failed'}`);
5106
+ return;
5107
+ }
5108
+
5109
+ const usage = [];
5110
+ if (Number.isFinite(event.inputTokens)) usage.push(`${event.inputTokens} in`);
5111
+ if (Number.isFinite(event.outputTokens)) usage.push(`${event.outputTokens} out`);
5112
+
5113
+ const suffix = usage.length > 0 ? ` (${usage.join(', ')})` : '';
5114
+ lines.push(`${prefix} ${chalk.green('✓')} completed${suffix}`);
5115
+ }
5116
+
5117
+ function appendRawAgentOutputLines(lines, prefix, content) {
5118
+ for (const line of String(content).split('\n')) {
5119
+ const trimmed = line.trim();
5120
+ if (shouldSkipAgentOutputLine(trimmed)) continue;
5121
+ lines.push(`${prefix} ${line}`);
5122
+ }
5123
+ }
5124
+
5125
+ function agentOutputHasPrintableHistory(msg) {
5126
+ const content = msg.content?.data?.line || msg.content?.data?.chunk || msg.content?.text;
5127
+ if (!content || !content.trim()) return false;
5128
+
5129
+ const provider = normalizeProviderName(
5130
+ msg.content?.data?.provider || msg.sender_provider || 'claude'
5131
+ );
5132
+ const events = parseProviderChunk(provider, content);
5133
+ if (events.length === 0) {
5134
+ return String(content)
5135
+ .split('\n')
5136
+ .some((line) => !shouldSkipAgentOutputLine(line.trim()));
5137
+ }
5138
+
5139
+ return events.some((event) =>
5140
+ ['text', 'thinking', 'thinking_start', 'tool_call', 'tool_result', 'result'].includes(
5141
+ event.type
5142
+ )
5143
+ );
5144
+ }
5145
+
5146
+ function isPrintableHistoryMessage(msg) {
5147
+ if (!msg?.topic || HISTORY_NOISE_TOPICS.has(msg.topic)) return false;
5148
+ if (msg.topic === 'AGENT_OUTPUT') return agentOutputHasPrintableHistory(msg);
5149
+ return true;
5150
+ }
5151
+
5152
+ function selectRecentPrintableMessages(messages, limit) {
5153
+ const printableMessages = messages.filter(isPrintableHistoryMessage);
5154
+ if (!Number.isFinite(limit) || limit <= 0) return printableMessages;
5155
+ return printableMessages.slice(-limit);
5156
+ }
5157
+
4963
5158
  /**
4964
5159
  * Render messages to terminal-style output with ANSI colors (same as zeroshot logs)
4965
5160
  */
@@ -4987,6 +5182,13 @@ function renderMessagesToTerminal(clusterId, messages) {
4987
5182
  return lines.join('\n');
4988
5183
  }
4989
5184
 
5185
+ function renderRecentMessagesToTerminal(messages, limit) {
5186
+ const selectedMessages = selectRecentPrintableMessages(messages, limit);
5187
+ if (selectedMessages.length === 0) return '';
5188
+
5189
+ return renderMessagesToTerminal(null, selectedMessages);
5190
+ }
5191
+
4990
5192
  // Get terminal width for word wrapping
4991
5193
  function getTerminalWidth() {
4992
5194
  return process.stdout.columns || 100;
@@ -5412,6 +5614,15 @@ async function main() {
5412
5614
 
5413
5615
  printLegacyDistroNotice();
5414
5616
 
5617
+ try {
5618
+ const { onPath, binDir } = checkBinDirOnPath();
5619
+ if (!onPath && binDir) {
5620
+ printPathWarning(binDir);
5621
+ }
5622
+ } catch {
5623
+ // Never block CLI startup on a PATH check failure
5624
+ }
5625
+
5415
5626
  // Check for updates (non-blocking if offline)
5416
5627
  if (!isTest) {
5417
5628
  await checkForUpdates({ quiet: isQuiet });
@@ -5447,7 +5658,11 @@ async function main() {
5447
5658
  }
5448
5659
 
5449
5660
  // Run main
5450
- main().catch((err) => {
5451
- console.error('Fatal error:', err.message);
5452
- process.exit(1);
5453
- });
5661
+ if (require.main === module) {
5662
+ main().catch((err) => {
5663
+ console.error('Fatal error:', err.message);
5664
+ process.exit(1);
5665
+ });
5666
+ }
5667
+
5668
+ module.exports = { renderRecentMessagesToTerminal, resolveRunMode };