@the-open-engine/zeroshot 6.2.0 → 6.4.0

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