@covibes/zeroshot 1.0.1

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 (57) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/LICENSE +21 -0
  3. package/README.md +364 -0
  4. package/cli/index.js +3990 -0
  5. package/cluster-templates/base-templates/debug-workflow.json +181 -0
  6. package/cluster-templates/base-templates/full-workflow.json +455 -0
  7. package/cluster-templates/base-templates/single-worker.json +48 -0
  8. package/cluster-templates/base-templates/worker-validator.json +131 -0
  9. package/cluster-templates/conductor-bootstrap.json +122 -0
  10. package/cluster-templates/conductor-junior-bootstrap.json +69 -0
  11. package/docker/zeroshot-cluster/Dockerfile +132 -0
  12. package/lib/completion.js +174 -0
  13. package/lib/id-detector.js +53 -0
  14. package/lib/settings.js +97 -0
  15. package/lib/stream-json-parser.js +236 -0
  16. package/package.json +121 -0
  17. package/src/agent/agent-config.js +121 -0
  18. package/src/agent/agent-context-builder.js +241 -0
  19. package/src/agent/agent-hook-executor.js +329 -0
  20. package/src/agent/agent-lifecycle.js +555 -0
  21. package/src/agent/agent-stuck-detector.js +256 -0
  22. package/src/agent/agent-task-executor.js +1034 -0
  23. package/src/agent/agent-trigger-evaluator.js +67 -0
  24. package/src/agent-wrapper.js +459 -0
  25. package/src/agents/git-pusher-agent.json +20 -0
  26. package/src/attach/attach-client.js +438 -0
  27. package/src/attach/attach-server.js +543 -0
  28. package/src/attach/index.js +35 -0
  29. package/src/attach/protocol.js +220 -0
  30. package/src/attach/ring-buffer.js +121 -0
  31. package/src/attach/socket-discovery.js +242 -0
  32. package/src/claude-task-runner.js +468 -0
  33. package/src/config-router.js +80 -0
  34. package/src/config-validator.js +598 -0
  35. package/src/github.js +103 -0
  36. package/src/isolation-manager.js +1042 -0
  37. package/src/ledger.js +429 -0
  38. package/src/logic-engine.js +223 -0
  39. package/src/message-bus-bridge.js +139 -0
  40. package/src/message-bus.js +202 -0
  41. package/src/name-generator.js +232 -0
  42. package/src/orchestrator.js +1938 -0
  43. package/src/schemas/sub-cluster.js +156 -0
  44. package/src/sub-cluster-wrapper.js +545 -0
  45. package/src/task-runner.js +28 -0
  46. package/src/template-resolver.js +347 -0
  47. package/src/tui/CHANGES.txt +133 -0
  48. package/src/tui/LAYOUT.md +261 -0
  49. package/src/tui/README.txt +192 -0
  50. package/src/tui/TWO-LEVEL-NAVIGATION.md +186 -0
  51. package/src/tui/data-poller.js +325 -0
  52. package/src/tui/demo.js +208 -0
  53. package/src/tui/formatters.js +123 -0
  54. package/src/tui/index.js +193 -0
  55. package/src/tui/keybindings.js +383 -0
  56. package/src/tui/layout.js +317 -0
  57. package/src/tui/renderer.js +194 -0
package/cli/index.js ADDED
@@ -0,0 +1,3990 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * zeroshot CLI
5
+ *
6
+ * Commands:
7
+ * - run: Start a multi-agent cluster
8
+ * - list: List all clusters and tasks
9
+ * - status: Get cluster/task status
10
+ * - logs: View cluster/task logs
11
+ * - stop: Stop a cluster gracefully
12
+ * - kill: Force kill a task or cluster
13
+ * - kill-all: Kill all running tasks and clusters
14
+ * - export: Export cluster conversation
15
+ */
16
+
17
+ const { Command } = require('commander');
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const chalk = require('chalk');
22
+ const Orchestrator = require('../src/orchestrator');
23
+ const { setupCompletion } = require('../lib/completion');
24
+ const { parseChunk } = require('../lib/stream-json-parser');
25
+ const {
26
+ loadSettings,
27
+ saveSettings,
28
+ validateSetting,
29
+ coerceValue,
30
+ DEFAULT_SETTINGS,
31
+ } = require('../lib/settings');
32
+
33
+ const program = new Command();
34
+
35
+ // =============================================================================
36
+ // GLOBAL ERROR HANDLERS - Prevent silent process death
37
+ // =============================================================================
38
+ // Track active cluster ID for cleanup on crash
39
+ /** @type {string | null} */
40
+ let activeClusterId = null;
41
+ /** @type {import('../src/orchestrator') | null} */
42
+ let orchestratorInstance = null;
43
+
44
+ /**
45
+ * Handle fatal errors: log, cleanup cluster state, exit
46
+ * @param {string} type - 'uncaughtException' or 'unhandledRejection'
47
+ * @param {Error|unknown} error - The error that caused the crash
48
+ */
49
+ function handleFatalError(type, error) {
50
+ const errorMessage = error instanceof Error ? error.message : String(error);
51
+ const errorStack = error instanceof Error ? error.stack : '';
52
+
53
+ console.error(chalk.red(`\n${'='.repeat(80)}`));
54
+ console.error(chalk.red.bold(`🔴 FATAL: ${type}`));
55
+ console.error(chalk.red(`${'='.repeat(80)}`));
56
+ console.error(chalk.red(`Error: ${errorMessage}`));
57
+ if (errorStack) {
58
+ console.error(chalk.dim(errorStack));
59
+ }
60
+ console.error(chalk.red(`${'='.repeat(80)}\n`));
61
+
62
+ // Try to update cluster state to 'failed' before exiting
63
+ if (activeClusterId && orchestratorInstance) {
64
+ try {
65
+ console.error(chalk.yellow(`Attempting to mark cluster ${activeClusterId} as failed...`));
66
+ const cluster = orchestratorInstance.clusters.get(activeClusterId);
67
+ if (cluster) {
68
+ cluster.state = 'failed';
69
+ cluster.pid = null;
70
+ cluster.failureInfo = {
71
+ type,
72
+ error: errorMessage,
73
+ timestamp: Date.now(),
74
+ };
75
+ orchestratorInstance._saveClusters();
76
+ console.error(chalk.yellow(`Cluster ${activeClusterId} marked as failed.`));
77
+ }
78
+ } catch (cleanupErr) {
79
+ const errMsg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
80
+ console.error(chalk.red(`Failed to update cluster state: ${errMsg}`));
81
+ }
82
+ }
83
+
84
+ process.exit(1);
85
+ }
86
+
87
+ process.on('uncaughtException', (error) => {
88
+ handleFatalError('Uncaught Exception', error);
89
+ });
90
+
91
+ process.on('unhandledRejection', (reason) => {
92
+ handleFatalError('Unhandled Promise Rejection', reason);
93
+ });
94
+ // =============================================================================
95
+
96
+ // Package root directory (for resolving default config paths)
97
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
98
+
99
+ /**
100
+ * Detect git repository root from current directory
101
+ * Critical for CWD propagation - agents must work in the target repo, not where CLI was invoked
102
+ * @returns {string} Git repo root, or process.cwd() if not in a git repo
103
+ */
104
+ function detectGitRepoRoot() {
105
+ const { execSync } = require('child_process');
106
+ try {
107
+ const root = execSync('git rev-parse --show-toplevel', {
108
+ encoding: 'utf8',
109
+ stdio: ['pipe', 'pipe', 'pipe'],
110
+ }).trim();
111
+ return root;
112
+ } catch {
113
+ // Not in a git repo - use current directory
114
+ return process.cwd();
115
+ }
116
+ }
117
+
118
+ // Lazy-loaded orchestrator (quiet by default) - created on first use
119
+ /** @type {import('../src/orchestrator') | null} */
120
+ let _orchestrator = null;
121
+ /**
122
+ * @returns {import('../src/orchestrator')}
123
+ */
124
+ function getOrchestrator() {
125
+ if (!_orchestrator) {
126
+ _orchestrator = new Orchestrator({ quiet: true });
127
+ }
128
+ return _orchestrator;
129
+ }
130
+
131
+ /**
132
+ * @typedef {Object} TaskLogMessage
133
+ * @property {string} topic
134
+ * @property {string} sender
135
+ * @property {Object} content
136
+ * @property {number} timestamp
137
+ */
138
+
139
+ /**
140
+ * Read task logs from zeroshot task log files for agents in a cluster
141
+ * Returns messages in cluster message format (topic, sender, content, timestamp)
142
+ * @param {Object} cluster - Cluster object from orchestrator
143
+ * @returns {TaskLogMessage[]} Messages from task logs
144
+ */
145
+ function readAgentTaskLogs(cluster) {
146
+ /** @type {TaskLogMessage[]} */
147
+ const messages = [];
148
+ const zeroshotLogsDir = path.join(os.homedir(), '.claude-zeroshot', 'logs');
149
+
150
+ if (!fs.existsSync(zeroshotLogsDir)) {
151
+ return messages;
152
+ }
153
+
154
+ // Strategy 1: Find task IDs from AGENT_LIFECYCLE messages
155
+ const lifecycleMessages = cluster.messageBus.query({
156
+ cluster_id: cluster.id,
157
+ topic: 'AGENT_LIFECYCLE',
158
+ });
159
+
160
+ const taskIds = new Set(); // All task IDs we've found
161
+ for (const msg of lifecycleMessages) {
162
+ const taskId = msg.content?.data?.taskId;
163
+ if (taskId) {
164
+ taskIds.add(taskId);
165
+ }
166
+ }
167
+
168
+ // Strategy 2: Find task IDs from current agent state
169
+ for (const agent of cluster.agents) {
170
+ const state = agent.getState();
171
+ if (state.currentTaskId) {
172
+ taskIds.add(state.currentTaskId);
173
+ }
174
+ }
175
+
176
+ // Strategy 3: Scan for log files matching cluster start time (catch orphaned tasks)
177
+ // This handles the case where TASK_ID_ASSIGNED wasn't published to cluster DB
178
+ const clusterStartTime = cluster.createdAt;
179
+ const logFiles = fs.readdirSync(zeroshotLogsDir);
180
+
181
+ for (const logFile of logFiles) {
182
+ if (!logFile.endsWith('.log')) continue;
183
+ const taskId = logFile.replace(/\.log$/, '');
184
+
185
+ // Check file modification time - only include logs modified after cluster started
186
+ const logPath = path.join(zeroshotLogsDir, logFile);
187
+ try {
188
+ const stats = fs.statSync(logPath);
189
+ if (stats.mtimeMs >= clusterStartTime) {
190
+ taskIds.add(taskId);
191
+ }
192
+ } catch {
193
+ // Skip files we can't stat
194
+ }
195
+ }
196
+
197
+ // Read logs for all discovered tasks
198
+ for (const taskId of taskIds) {
199
+ const logPath = path.join(zeroshotLogsDir, `${taskId}.log`);
200
+ if (!fs.existsSync(logPath)) {
201
+ continue;
202
+ }
203
+
204
+ try {
205
+ const content = fs.readFileSync(logPath, 'utf8');
206
+ const lines = content.split('\n').filter((line) => line.trim());
207
+
208
+ // Try to match task to agent (best effort, may not find a match for orphaned tasks)
209
+ let matchedAgent = null;
210
+ for (const agent of cluster.agents) {
211
+ const state = agent.getState();
212
+ if (state.currentTaskId === taskId) {
213
+ matchedAgent = agent;
214
+ break;
215
+ }
216
+ }
217
+
218
+ // If no agent match, try to infer from lifecycle messages
219
+ if (!matchedAgent) {
220
+ for (const msg of lifecycleMessages) {
221
+ if (msg.content?.data?.taskId === taskId) {
222
+ const agentId = msg.content?.data?.agent || msg.sender;
223
+ matchedAgent = cluster.agents.find((a) => a.id === agentId);
224
+ break;
225
+ }
226
+ }
227
+ }
228
+
229
+ // Default to first agent if no match found (best effort for orphaned tasks)
230
+ const agent = matchedAgent || cluster.agents[0];
231
+ if (!agent) {
232
+ continue;
233
+ }
234
+
235
+ const state = agent.getState();
236
+
237
+ for (const line of lines) {
238
+ // Lines are prefixed with [timestamp] - parse that first
239
+ const trimmed = line.trim();
240
+ if (!trimmed.startsWith('[')) {
241
+ continue;
242
+ }
243
+
244
+ try {
245
+ // Parse timestamp-prefixed line: [1733301234567]{json...} or [1733301234567][SYSTEM]...
246
+ let timestamp = Date.now();
247
+ let jsonContent = trimmed;
248
+
249
+ const timestampMatch = jsonContent.match(/^\[(\d{13})\](.*)$/);
250
+ if (timestampMatch) {
251
+ timestamp = parseInt(timestampMatch[1], 10);
252
+ jsonContent = timestampMatch[2];
253
+ }
254
+
255
+ // Skip non-JSON (e.g., [SYSTEM] lines)
256
+ if (!jsonContent.startsWith('{')) {
257
+ continue;
258
+ }
259
+
260
+ // Parse JSON
261
+ const parsed = JSON.parse(jsonContent);
262
+
263
+ // Skip system init messages
264
+ if (parsed.type === 'system' && parsed.subtype === 'init') {
265
+ continue;
266
+ }
267
+
268
+ // Convert to cluster message format
269
+ messages.push({
270
+ id: `task-${taskId}-${timestamp}`,
271
+ timestamp,
272
+ topic: 'AGENT_OUTPUT',
273
+ sender: agent.id,
274
+ receiver: 'broadcast',
275
+ cluster_id: cluster.id,
276
+ content: {
277
+ text: jsonContent,
278
+ data: {
279
+ type: 'stdout',
280
+ line: jsonContent,
281
+ agent: agent.id,
282
+ role: agent.role,
283
+ iteration: state.iteration,
284
+ fromTaskLog: true, // Mark as coming from task log
285
+ },
286
+ },
287
+ });
288
+ } catch {
289
+ // Skip invalid JSON
290
+ }
291
+ }
292
+ } catch (err) {
293
+ // Log file read error - skip this task
294
+ console.warn(`Warning: Could not read log for ${taskId}: ${err.message}`);
295
+ }
296
+ }
297
+
298
+ return messages;
299
+ }
300
+
301
+ // Setup shell completion
302
+ setupCompletion();
303
+
304
+ // Banner disabled
305
+ function showBanner() {
306
+ // Banner removed for cleaner output
307
+ }
308
+
309
+ // Show banner on startup (but not for completion, help, or daemon child)
310
+ const shouldShowBanner =
311
+ !process.env.CREW_DAEMON &&
312
+ !process.argv.includes('--completion') &&
313
+ !process.argv.includes('-h') &&
314
+ !process.argv.includes('--help') &&
315
+ process.argv.length > 2;
316
+ if (shouldShowBanner) {
317
+ showBanner();
318
+ }
319
+
320
+ // Color palette for agents (avoid green/red - reserved for APPROVED/REJECTED)
321
+ const COLORS = [chalk.cyan, chalk.yellow, chalk.magenta, chalk.blue, chalk.white, chalk.gray];
322
+
323
+ // Map agent IDs to colors
324
+ const agentColors = new Map();
325
+
326
+ program
327
+ .name('zeroshot')
328
+ .description('Multi-agent orchestration and task management for Claude')
329
+ .version('1.0.0')
330
+ .addHelpText(
331
+ 'after',
332
+ `
333
+ Examples:
334
+ ${chalk.cyan('zeroshot auto 123')} Full automation: isolated + auto-merge PR
335
+ ${chalk.cyan('zeroshot run 123')} Run cluster and attach to first agent
336
+ ${chalk.cyan('zeroshot run 123 -d')} Run cluster in background (detached)
337
+ ${chalk.cyan('zeroshot run "Implement feature X"')} Run cluster on plain text task
338
+ ${chalk.cyan('zeroshot run 123 --isolation')} Run in Docker container (safe for e2e tests)
339
+ ${chalk.cyan('zeroshot task run "Fix the bug"')} Run single-agent background task
340
+ ${chalk.cyan('zeroshot list')} List all tasks and clusters
341
+ ${chalk.cyan('zeroshot task list')} List tasks only
342
+ ${chalk.cyan('zeroshot task watch')} Interactive TUI - navigate tasks, view logs
343
+ ${chalk.cyan('zeroshot attach <id>')} Attach to running task (Ctrl+B d to detach)
344
+ ${chalk.cyan('zeroshot logs -f')} Stream logs in real-time (like tail -f)
345
+ ${chalk.cyan('zeroshot logs -w')} Interactive watch mode (for tasks)
346
+ ${chalk.cyan('zeroshot logs <id> -f')} Stream logs for specific cluster/task
347
+ ${chalk.cyan('zeroshot status <id>')} Detailed status of task or cluster
348
+ ${chalk.cyan('zeroshot finish <id>')} Convert cluster to completion task (creates and merges PR)
349
+ ${chalk.cyan('zeroshot kill <id>')} Kill a running task or cluster
350
+ ${chalk.cyan('zeroshot clear')} Kill all processes and delete all data (with confirmation)
351
+ ${chalk.cyan('zeroshot clear -y')} Clear everything without confirmation
352
+ ${chalk.cyan('zeroshot settings')} Show/manage zeroshot settings (default model, config, etc.)
353
+ ${chalk.cyan('zeroshot settings set <key> <val>')} Set a setting (e.g., defaultModel haiku)
354
+ ${chalk.cyan('zeroshot config list')} List available cluster configs
355
+ ${chalk.cyan('zeroshot config show <name>')} Visualize a cluster config (agents, triggers, flow)
356
+ ${chalk.cyan('zeroshot export <id>')} Export cluster conversation to file
357
+
358
+ Cluster vs Task:
359
+ ${chalk.yellow('zeroshot auto')} → Full automation (isolated + auto-merge PR)
360
+ ${chalk.yellow('zeroshot run')} → Multi-agent cluster (auto-attaches, Ctrl+B d to detach)
361
+ ${chalk.yellow('zeroshot run -d')} → Multi-agent cluster (background/detached)
362
+ ${chalk.yellow('zeroshot task run')} → Single-agent background task (simpler, faster)
363
+
364
+ Shell completion:
365
+ ${chalk.dim('zeroshot --completion >> ~/.bashrc && source ~/.bashrc')}
366
+ `
367
+ );
368
+
369
+ // Run command - CLUSTER with auto-detection
370
+ program
371
+ .command('run <input>')
372
+ .description('Start a multi-agent cluster (auto-detects GitHub issue or plain text)')
373
+ .option('--config <file>', 'Path to cluster config JSON (default: conductor-bootstrap)')
374
+ .option('-m, --model <model>', 'Model for all agents: opus, sonnet, haiku (default: from config)')
375
+ .option('--isolation', 'Run cluster inside Docker container (for e2e testing)')
376
+ .option(
377
+ '--isolation-image <image>',
378
+ 'Docker image for isolation (default: zeroshot-cluster-base)'
379
+ )
380
+ .option(
381
+ '--strict-schema',
382
+ 'Enforce JSON schema via CLI (no live streaming). Default: live streaming with local validation'
383
+ )
384
+ .option('--pr', 'Create PR and merge on successful completion (requires --isolation)')
385
+ .option('--full', 'Shorthand for --isolation --pr (full automation)')
386
+ .option('--workers <n>', 'Max sub-agents for worker to spawn in parallel', parseInt)
387
+ .option('-d, --detach', 'Run in background (default: attach to first agent)')
388
+ .addHelpText(
389
+ 'after',
390
+ `
391
+ Input formats:
392
+ 123 GitHub issue number (uses current repo)
393
+ org/repo#123 GitHub issue with explicit repo
394
+ https://github.com/.../issues/1 Full GitHub issue URL
395
+ "Implement feature X" Plain text task description
396
+ `
397
+ )
398
+ .action(async (inputArg, options) => {
399
+ try {
400
+ // Expand --full to --isolation + --pr
401
+ if (options.full) {
402
+ options.isolation = true;
403
+ options.pr = true;
404
+ }
405
+
406
+ // Auto-detect input type
407
+ let input = {};
408
+
409
+ // Check if it's a GitHub issue URL
410
+ if (inputArg.match(/^https?:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/)) {
411
+ input.issue = inputArg;
412
+ }
413
+ // Check if it's a GitHub issue number (just digits)
414
+ else if (/^\d+$/.test(inputArg)) {
415
+ input.issue = inputArg;
416
+ }
417
+ // Check if it's org/repo#123 format
418
+ else if (inputArg.match(/^[\w-]+\/[\w-]+#\d+$/)) {
419
+ input.issue = inputArg;
420
+ }
421
+ // Otherwise, treat as plain text
422
+ else {
423
+ input.text = inputArg;
424
+ }
425
+
426
+ // === CLUSTER MODE ===
427
+ // Validate --pr requires --isolation
428
+ if (options.pr && !options.isolation) {
429
+ console.error(chalk.red('Error: --pr requires --isolation flag for safety'));
430
+ console.error(chalk.dim(' Usage: zeroshot run 123 --isolation --pr'));
431
+ process.exit(1);
432
+ }
433
+
434
+ const { generateName } = require('../src/name-generator');
435
+
436
+ // === DETACHED MODE (-d flag) ===
437
+ // Spawn daemon and exit immediately
438
+ if (options.detach && !process.env.CREW_DAEMON) {
439
+ const { spawn } = require('child_process');
440
+
441
+ // Generate cluster ID in parent so we can display it
442
+ const clusterId = generateName('cluster');
443
+
444
+ // Output cluster ID and help
445
+ if (options.isolation) {
446
+ console.log(`Started ${clusterId} (isolated)`);
447
+ } else {
448
+ console.log(`Started ${clusterId}`);
449
+ }
450
+ console.log(`Monitor: zeroshot logs ${clusterId} -f`);
451
+ console.log(`Attach: zeroshot attach ${clusterId}`);
452
+
453
+ // Create log file for daemon output (captures startup errors)
454
+ const osModule = require('os');
455
+ const storageDir = path.join(osModule.homedir(), '.zeroshot');
456
+ if (!fs.existsSync(storageDir)) {
457
+ fs.mkdirSync(storageDir, { recursive: true });
458
+ }
459
+ const logPath = path.join(storageDir, `${clusterId}-daemon.log`);
460
+ const logFd = fs.openSync(logPath, 'w');
461
+
462
+ // Detect git repo root for CWD propagation
463
+ // CRITICAL: Agents must work in the target repo, not where CLI was invoked
464
+ const targetCwd = detectGitRepoRoot();
465
+
466
+ // Spawn ourselves as daemon (detached, logs to file)
467
+ const daemon = spawn(process.execPath, process.argv.slice(1), {
468
+ detached: true,
469
+ stdio: ['ignore', logFd, logFd], // stdout + stderr go to log file
470
+ cwd: targetCwd, // Daemon inherits correct working directory
471
+ env: {
472
+ ...process.env,
473
+ CREW_DAEMON: '1',
474
+ CREW_CLUSTER_ID: clusterId,
475
+ CREW_MODEL: options.model || '',
476
+ CREW_ISOLATION: options.isolation ? '1' : '',
477
+ CREW_ISOLATION_IMAGE: options.isolationImage || '',
478
+ CREW_PR: options.pr ? '1' : '',
479
+ CREW_WORKERS: options.workers?.toString() || '',
480
+ CREW_CWD: targetCwd, // Explicit CWD for orchestrator
481
+ },
482
+ });
483
+
484
+ daemon.unref();
485
+ fs.closeSync(logFd);
486
+ process.exit(0);
487
+ }
488
+
489
+ // === FOREGROUND MODE (default) or DAEMON CHILD ===
490
+ // Load user settings
491
+ const settings = loadSettings();
492
+
493
+ // Use cluster ID from env (daemon mode) or generate new one (foreground mode)
494
+ // IMPORTANT: Set env var so orchestrator picks it up
495
+ const clusterId = process.env.CREW_CLUSTER_ID || generateName('cluster');
496
+ process.env.CREW_CLUSTER_ID = clusterId;
497
+
498
+ // === LOAD CONFIG ===
499
+ // Priority: CLI --config > settings.defaultConfig
500
+ let config;
501
+ const configName = options.config || settings.defaultConfig;
502
+
503
+ // Resolve config path (check examples/ directory if not absolute/relative path)
504
+ let configPath;
505
+ if (
506
+ path.isAbsolute(configName) ||
507
+ configName.startsWith('./') ||
508
+ configName.startsWith('../')
509
+ ) {
510
+ configPath = path.resolve(process.cwd(), configName);
511
+ } else if (configName.endsWith('.json')) {
512
+ // If it has .json extension, check examples/ directory
513
+ configPath = path.join(PACKAGE_ROOT, 'cluster-templates', configName);
514
+ } else {
515
+ // Otherwise assume it's a template name (add .json)
516
+ configPath = path.join(PACKAGE_ROOT, 'cluster-templates', `${configName}.json`);
517
+ }
518
+
519
+ // Create orchestrator with clusterId override for foreground mode
520
+ const orchestrator = getOrchestrator();
521
+ config = orchestrator.loadConfig(configPath);
522
+
523
+ // Track for global error handler cleanup
524
+ activeClusterId = clusterId;
525
+ orchestratorInstance = orchestrator;
526
+
527
+ // In foreground mode, show startup info
528
+ if (!process.env.CREW_DAEMON) {
529
+ if (options.isolation) {
530
+ console.log(`Starting ${clusterId} (isolated)`);
531
+ } else {
532
+ console.log(`Starting ${clusterId}`);
533
+ }
534
+ console.log(chalk.dim(`Config: ${configName}`));
535
+ console.log(chalk.dim('Ctrl+C to stop following (cluster keeps running)\n'));
536
+ }
537
+
538
+ // Override model (CLI > settings > config)
539
+ const modelOverride = process.env.CREW_MODEL || options.model || settings.defaultModel;
540
+ if (modelOverride) {
541
+ for (const agent of config.agents) {
542
+ // Only override if agent doesn't already specify a model
543
+ if (!agent.model || modelOverride) {
544
+ agent.model = modelOverride;
545
+ }
546
+ }
547
+ }
548
+
549
+ // Apply strictSchema setting to all agents (CLI > env > settings)
550
+ const strictSchema =
551
+ options.strictSchema || process.env.CREW_STRICT_SCHEMA === '1' || settings.strictSchema;
552
+ if (strictSchema) {
553
+ for (const agent of config.agents) {
554
+ agent.strictSchema = true;
555
+ }
556
+ }
557
+
558
+ // Build start options (CLI flags > env vars > settings)
559
+ // In foreground mode, use CLI options directly; in daemon mode, use env vars
560
+ // CRITICAL: cwd must be passed to orchestrator for agent CWD propagation
561
+ const targetCwd = process.env.CREW_CWD || detectGitRepoRoot();
562
+ const startOptions = {
563
+ cwd: targetCwd, // Target working directory for agents
564
+ isolation:
565
+ options.isolation || process.env.CREW_ISOLATION === '1' || settings.defaultIsolation,
566
+ isolationImage: options.isolationImage || process.env.CREW_ISOLATION_IMAGE || undefined,
567
+ autoPr: options.pr || process.env.CREW_PR === '1',
568
+ autoMerge: process.env.CREW_MERGE === '1',
569
+ autoPush: process.env.CREW_PUSH === '1',
570
+ };
571
+
572
+ // Start cluster
573
+ const cluster = await orchestrator.start(config, input, startOptions);
574
+
575
+ // === FOREGROUND MODE: Stream logs in real-time ===
576
+ // Subscribe to message bus directly (same process) for instant output
577
+ if (!process.env.CREW_DAEMON) {
578
+ // Track senders that have output (for periodic flushing)
579
+ const sendersWithOutput = new Set();
580
+ // Track messages we've already processed (to avoid duplicates between history and subscription)
581
+ const processedMessageIds = new Set();
582
+
583
+ // Message handler - processes messages, deduplicates by ID
584
+ const handleMessage = (msg) => {
585
+ if (msg.cluster_id !== clusterId) return;
586
+ if (processedMessageIds.has(msg.id)) return;
587
+ processedMessageIds.add(msg.id);
588
+
589
+ if (msg.topic === 'AGENT_OUTPUT' && msg.sender) {
590
+ sendersWithOutput.add(msg.sender);
591
+ }
592
+ printMessage(msg, false, false, true);
593
+ };
594
+
595
+ // Subscribe to NEW messages
596
+ const unsubscribe = cluster.messageBus.subscribe(handleMessage);
597
+
598
+ // CRITICAL: Replay historical messages that may have been published BEFORE we subscribed
599
+ // This fixes the race condition where fast-completing clusters miss output
600
+ const historicalMessages = cluster.messageBus.getAll(clusterId);
601
+ for (const msg of historicalMessages) {
602
+ handleMessage(msg);
603
+ }
604
+
605
+ // Periodic flush of text buffers (streaming text may not have newlines)
606
+ const flushInterval = setInterval(() => {
607
+ for (const sender of sendersWithOutput) {
608
+ const prefix = getColorForSender(sender)(`${sender.padEnd(15)} |`);
609
+ flushLineBuffer(prefix, sender);
610
+ }
611
+ }, 250);
612
+
613
+ // Wait for cluster to complete
614
+ await new Promise((resolve) => {
615
+ const checkInterval = setInterval(() => {
616
+ try {
617
+ const status = orchestrator.getStatus(clusterId);
618
+ if (status.state !== 'running') {
619
+ clearInterval(checkInterval);
620
+ clearInterval(flushInterval);
621
+ // Final flush
622
+ for (const sender of sendersWithOutput) {
623
+ const prefix = getColorForSender(sender)(`${sender.padEnd(15)} |`);
624
+ flushLineBuffer(prefix, sender);
625
+ }
626
+ unsubscribe();
627
+ resolve();
628
+ }
629
+ } catch {
630
+ // Cluster may have been removed
631
+ clearInterval(checkInterval);
632
+ clearInterval(flushInterval);
633
+ unsubscribe();
634
+ resolve();
635
+ }
636
+ }, 500);
637
+
638
+ // Handle Ctrl+C: Stop cluster since foreground mode has no daemon
639
+ // CRITICAL: In foreground mode, the cluster runs IN this process.
640
+ // If we exit without stopping, the cluster becomes a zombie (state=running but no process).
641
+ process.on('SIGINT', async () => {
642
+ console.log(chalk.dim('\n\n--- Interrupted ---'));
643
+ clearInterval(checkInterval);
644
+ clearInterval(flushInterval);
645
+ unsubscribe();
646
+
647
+ // Stop the cluster properly so state is updated
648
+ try {
649
+ console.log(chalk.dim(`Stopping cluster ${clusterId}...`));
650
+ await orchestrator.stop(clusterId);
651
+ console.log(chalk.dim(`Cluster ${clusterId} stopped.`));
652
+ } catch (stopErr) {
653
+ console.error(chalk.red(`Failed to stop cluster: ${stopErr.message}`));
654
+ }
655
+
656
+ process.exit(0);
657
+ });
658
+ });
659
+
660
+ console.log(chalk.dim(`\nCluster ${clusterId} completed.`));
661
+ }
662
+
663
+ // Daemon mode: cluster runs in background, stay alive via orchestrator's setInterval
664
+ } catch (error) {
665
+ console.error('Error:', error.message);
666
+ process.exit(1);
667
+ }
668
+ });
669
+
670
+ // Auto command - full automation (isolation + PR)
671
+ program
672
+ .command('auto <input>')
673
+ .description('Full automation: isolated + auto-merge PR (shorthand for run --isolation --pr)')
674
+ .option('--config <file>', 'Path to cluster config JSON (default: conductor-bootstrap)')
675
+ .option('-m, --model <model>', 'Model for all agents: opus, sonnet, haiku (default: from config)')
676
+ .option(
677
+ '--isolation-image <image>',
678
+ 'Docker image for isolation (default: zeroshot-cluster-base)'
679
+ )
680
+ .option(
681
+ '--strict-schema',
682
+ 'Enforce JSON schema via CLI (no live streaming). Default: live streaming with local validation'
683
+ )
684
+ .option('--workers <n>', 'Max sub-agents for worker to spawn in parallel', parseInt)
685
+ .option('-d, --detach', 'Run in background (default: attach to first agent)')
686
+ .addHelpText(
687
+ 'after',
688
+ `
689
+ Input formats:
690
+ 123 GitHub issue number (uses current repo)
691
+ org/repo#123 GitHub issue with explicit repo
692
+ https://github.com/.../issues/1 Full GitHub issue URL
693
+ "Implement feature X" Plain text task description
694
+
695
+ Examples:
696
+ ${chalk.cyan('zeroshot auto 123')} Auto-resolve issue (isolated + PR)
697
+ ${chalk.cyan('zeroshot auto 123 -d')} Same, but detached/background
698
+ `
699
+ )
700
+ .action((inputArg, options) => {
701
+ // Auto command is shorthand for: zeroshot run <input> --isolation --pr [options]
702
+ // Re-invoke CLI with the correct flags to avoid Commander.js internal API issues
703
+ const { spawn } = require('child_process');
704
+
705
+ const args = ['run', inputArg, '--isolation', '--pr'];
706
+
707
+ // Forward other options
708
+ if (options.config) args.push('--config', options.config);
709
+ if (options.model) args.push('--model', options.model);
710
+ if (options.isolationImage) args.push('--isolation-image', options.isolationImage);
711
+ if (options.strictSchema) args.push('--strict-schema');
712
+ if (options.workers) args.push('--workers', String(options.workers));
713
+ if (options.detach) args.push('--detach');
714
+
715
+ // Spawn zeroshot run with inherited stdio
716
+ const proc = spawn(process.execPath, [process.argv[1], ...args], {
717
+ stdio: 'inherit',
718
+ cwd: process.cwd(),
719
+ env: process.env,
720
+ });
721
+
722
+ proc.on('close', (code) => {
723
+ process.exit(code || 0);
724
+ });
725
+
726
+ proc.on('error', (err) => {
727
+ console.error(chalk.red(`Error: ${err.message}`));
728
+ process.exit(1);
729
+ });
730
+ });
731
+
732
+ // === TASK COMMANDS ===
733
+ // Task run - single-agent background task
734
+ const taskCmd = program.command('task').description('Single-agent task management');
735
+
736
+ taskCmd
737
+ .command('run <prompt>')
738
+ .description('Run a single-agent background task')
739
+ .option('-C, --cwd <path>', 'Working directory for task')
740
+ .option(
741
+ '-m, --model <model>',
742
+ 'Model to use: opus, sonnet, haiku (default: sonnet or ANTHROPIC_MODEL env)'
743
+ )
744
+ .option('-r, --resume <sessionId>', 'Resume a specific Claude session')
745
+ .option('-c, --continue', 'Continue the most recent session')
746
+ .option(
747
+ '-o, --output-format <format>',
748
+ 'Output format: stream-json (default), text, json',
749
+ 'stream-json'
750
+ )
751
+ .option('--json-schema <schema>', 'JSON schema for structured output')
752
+ .option('--silent-json-output', 'Log ONLY final structured output')
753
+ .action(async (prompt, options) => {
754
+ try {
755
+ // Dynamically import task command (ESM module)
756
+ const { runTask } = await import('../task-lib/commands/run.js');
757
+ await runTask(prompt, options);
758
+ } catch (error) {
759
+ console.error('Error:', error.message);
760
+ process.exit(1);
761
+ }
762
+ });
763
+
764
+ taskCmd
765
+ .command('list')
766
+ .alias('ls')
767
+ .description('List all tasks (use "zeroshot list" to see both tasks and clusters)')
768
+ .option('-s, --status <status>', 'Filter tasks by status (running, completed, failed)')
769
+ .option('-n, --limit <n>', 'Limit number of results', parseInt)
770
+ .option('-v, --verbose', 'Show detailed information (default: table view)')
771
+ .action(async (options) => {
772
+ try {
773
+ // Get tasks only (dynamic import)
774
+ const { listTasks } = await import('../task-lib/commands/list.js');
775
+ await listTasks(options);
776
+ } catch (error) {
777
+ console.error('Error listing tasks:', error.message);
778
+ process.exit(1);
779
+ }
780
+ });
781
+
782
+ taskCmd
783
+ .command('watch')
784
+ .description('Interactive TUI for tasks (navigate and view logs)')
785
+ .option('--refresh-rate <ms>', 'Refresh interval in milliseconds', '1000')
786
+ .action(async (options) => {
787
+ try {
788
+ const TaskTUI = (await import('../task-lib/tui.js')).default;
789
+ const tui = new TaskTUI({
790
+ refreshRate: parseInt(options.refreshRate, 10),
791
+ });
792
+ await tui.start();
793
+ } catch (error) {
794
+ console.error('Error starting task TUI:', error.message);
795
+ console.error(error.stack);
796
+ process.exit(1);
797
+ }
798
+ });
799
+
800
+ // List command - unified (shows both tasks and clusters)
801
+ program
802
+ .command('list')
803
+ .alias('ls')
804
+ .description('List all tasks and clusters')
805
+ .option('-s, --status <status>', 'Filter tasks by status (running, completed, failed)')
806
+ .option('-n, --limit <n>', 'Limit number of results', parseInt)
807
+ .action(async (options) => {
808
+ try {
809
+ // Get clusters
810
+ const clusters = getOrchestrator().listClusters();
811
+
812
+ // Get tasks (dynamic import)
813
+ const { listTasks } = await import('../task-lib/commands/list.js');
814
+
815
+ // Capture task output (listTasks prints directly, we need to capture)
816
+ // For now, let's list them separately
817
+
818
+ // Print clusters
819
+ if (clusters.length > 0) {
820
+ console.log(chalk.bold('\n=== Clusters ==='));
821
+ console.log(
822
+ `${'ID'.padEnd(25)} ${'State'.padEnd(15)} ${'Agents'.padEnd(10)} ${'Msgs'.padEnd(8)} Created`
823
+ );
824
+ console.log('-'.repeat(100));
825
+
826
+ for (const cluster of clusters) {
827
+ const created = new Date(cluster.createdAt).toLocaleString();
828
+
829
+ // Highlight zombie clusters in red
830
+ const stateDisplay =
831
+ cluster.state === 'zombie'
832
+ ? chalk.red(cluster.state.padEnd(15))
833
+ : cluster.state.padEnd(15);
834
+
835
+ const rowColor = cluster.state === 'zombie' ? chalk.red : (s) => s;
836
+
837
+ console.log(
838
+ `${rowColor(cluster.id.padEnd(25))} ${stateDisplay} ${cluster.agentCount.toString().padEnd(10)} ${cluster.messageCount.toString().padEnd(8)} ${created}`
839
+ );
840
+ }
841
+ } else {
842
+ console.log(chalk.dim('\n=== Clusters ==='));
843
+ console.log('No active clusters');
844
+ }
845
+
846
+ // Print tasks
847
+ console.log(chalk.bold('\n=== Tasks ==='));
848
+ await listTasks(options);
849
+ } catch (error) {
850
+ console.error('Error listing:', error.message);
851
+ process.exit(1);
852
+ }
853
+ });
854
+
855
+ // Status command - smart (works for both tasks and clusters)
856
+ program
857
+ .command('status <id>')
858
+ .description('Get detailed status of a task or cluster')
859
+ .action(async (id) => {
860
+ try {
861
+ const { detectIdType } = require('../lib/id-detector');
862
+ const type = detectIdType(id);
863
+
864
+ if (!type) {
865
+ console.error(`ID not found: ${id}`);
866
+ console.error('Not found in tasks or clusters');
867
+ process.exit(1);
868
+ }
869
+
870
+ if (type === 'cluster') {
871
+ // Show cluster status
872
+ const status = getOrchestrator().getStatus(id);
873
+
874
+ console.log(`\nCluster: ${status.id}`);
875
+ if (status.isZombie) {
876
+ console.log(
877
+ chalk.red(
878
+ `State: ${status.state} (process ${status.pid} died, cluster has no backing process)`
879
+ )
880
+ );
881
+ console.log(
882
+ chalk.yellow(
883
+ ` → Run 'zeroshot kill ${id}' to clean up, or 'zeroshot resume ${id}' to restart`
884
+ )
885
+ );
886
+ } else {
887
+ console.log(`State: ${status.state}`);
888
+ }
889
+ if (status.pid) {
890
+ console.log(`PID: ${status.pid}`);
891
+ }
892
+ console.log(`Created: ${new Date(status.createdAt).toLocaleString()}`);
893
+ console.log(`Messages: ${status.messageCount}`);
894
+ console.log(`\nAgents:`);
895
+
896
+ for (const agent of status.agents) {
897
+ // Check if subcluster
898
+ if (agent.type === 'subcluster') {
899
+ console.log(` - ${agent.id} (${agent.role}) [SubCluster]`);
900
+ console.log(` State: ${agent.state}`);
901
+ console.log(` Iteration: ${agent.iteration}`);
902
+ console.log(` Child Cluster: ${agent.childClusterId || 'none'}`);
903
+ console.log(` Child Running: ${agent.childRunning ? 'Yes' : 'No'}`);
904
+ } else {
905
+ const modelLabel = agent.model ? ` [${agent.model}]` : '';
906
+ console.log(` - ${agent.id} (${agent.role})${modelLabel}`);
907
+ console.log(` State: ${agent.state}`);
908
+ console.log(` Iteration: ${agent.iteration}`);
909
+ console.log(` Running task: ${agent.currentTask ? 'Yes' : 'No'}`);
910
+ }
911
+ }
912
+
913
+ console.log('');
914
+ } else {
915
+ // Show task status
916
+ const { showStatus } = await import('../task-lib/commands/status.js');
917
+ await showStatus(id);
918
+ }
919
+ } catch (error) {
920
+ console.error('Error getting status:', error.message);
921
+ process.exit(1);
922
+ }
923
+ });
924
+
925
+ // Logs command - smart (works for both tasks and clusters)
926
+ program
927
+ .command('logs [id]')
928
+ .description('View logs (omit ID for all clusters)')
929
+ .option('-f, --follow', 'Follow logs in real-time (stream output like tail -f)')
930
+ .option('-n, --limit <number>', 'Number of recent messages to show (default: 50)', '50')
931
+ .option('--lines <number>', 'Number of lines to show (task mode)', parseInt)
932
+ .option('-w, --watch', 'Watch mode: interactive TUI for tasks, high-level events for clusters')
933
+ .action(async (id, options) => {
934
+ try {
935
+ // If ID provided, detect type
936
+ if (id) {
937
+ const { detectIdType } = require('../lib/id-detector');
938
+ const type = detectIdType(id);
939
+
940
+ if (!type) {
941
+ console.error(`ID not found: ${id}`);
942
+ process.exit(1);
943
+ }
944
+
945
+ if (type === 'task') {
946
+ // Show task logs
947
+ const { showLogs } = await import('../task-lib/commands/logs.js');
948
+ await showLogs(id, options);
949
+ return;
950
+ }
951
+ // Fall through to cluster logs below
952
+ }
953
+
954
+ // === CLUSTER LOGS ===
955
+ const limit = parseInt(options.limit);
956
+ const quietOrchestrator = new Orchestrator({ quiet: true });
957
+
958
+ // No ID: show/follow ALL clusters
959
+ if (!id) {
960
+ const allClusters = quietOrchestrator.listClusters();
961
+ const activeClusters = allClusters.filter((c) => c.state === 'running');
962
+
963
+ if (allClusters.length === 0) {
964
+ if (options.follow) {
965
+ console.log('No clusters found. Waiting for new clusters...\n');
966
+ console.log(chalk.dim('--- Waiting for clusters (Ctrl+C to stop) ---\n'));
967
+ } else {
968
+ console.log('No clusters found');
969
+ return;
970
+ }
971
+ }
972
+
973
+ // Track if multiple clusters
974
+ const multiCluster = allClusters.length > 1;
975
+
976
+ // Follow mode: show header
977
+ if (options.follow && allClusters.length > 0) {
978
+ if (activeClusters.length === 0) {
979
+ console.log(
980
+ chalk.dim(
981
+ `--- Showing history from ${allClusters.length} cluster(s), waiting for new activity (Ctrl+C to stop) ---\n`
982
+ )
983
+ );
984
+ } else if (activeClusters.length === 1) {
985
+ console.log(chalk.dim(`--- Following ${activeClusters[0].id} (Ctrl+C to stop) ---\n`));
986
+ } else {
987
+ console.log(
988
+ chalk.dim(
989
+ `--- Following ${activeClusters.length} active clusters (Ctrl+C to stop) ---`
990
+ )
991
+ );
992
+ for (const c of activeClusters) {
993
+ console.log(chalk.dim(` • ${c.id} [${c.state}]`));
994
+ }
995
+ console.log('');
996
+ }
997
+ }
998
+
999
+ // Show recent messages from ALL clusters (history)
1000
+ // In follow mode, poll will handle new messages - this shows initial history
1001
+ for (const clusterInfo of allClusters) {
1002
+ const cluster = quietOrchestrator.getCluster(clusterInfo.id);
1003
+ if (cluster) {
1004
+ const messages = cluster.messageBus.getAll(clusterInfo.id);
1005
+ const recentMessages = messages.slice(-limit);
1006
+ const isActive = clusterInfo.state === 'running';
1007
+ for (const msg of recentMessages) {
1008
+ printMessage(msg, clusterInfo.id, options.watch, isActive);
1009
+ }
1010
+ }
1011
+ }
1012
+
1013
+ // Follow mode: poll SQLite for new messages (cross-process support)
1014
+ if (options.follow) {
1015
+ // Set terminal title based on task(s)
1016
+ const taskTitles = [];
1017
+ for (const clusterInfo of allClusters) {
1018
+ const cluster = quietOrchestrator.getCluster(clusterInfo.id);
1019
+ if (cluster) {
1020
+ const messages = cluster.messageBus.getAll(clusterInfo.id);
1021
+ const issueOpened = messages.find((m) => m.topic === 'ISSUE_OPENED');
1022
+ if (issueOpened) {
1023
+ taskTitles.push({
1024
+ id: clusterInfo.id,
1025
+ summary: formatTaskSummary(issueOpened, 30),
1026
+ });
1027
+ }
1028
+ }
1029
+ }
1030
+ if (taskTitles.length === 1) {
1031
+ setTerminalTitle(`zeroshot [${taskTitles[0].id}]: ${taskTitles[0].summary}`);
1032
+ } else if (taskTitles.length > 1) {
1033
+ setTerminalTitle(`zeroshot: ${taskTitles.length} clusters`);
1034
+ } else {
1035
+ setTerminalTitle('zeroshot: waiting...');
1036
+ }
1037
+
1038
+ // In watch mode, show the initial task for each cluster (after history)
1039
+ if (options.watch) {
1040
+ for (const clusterInfo of allClusters) {
1041
+ const cluster = quietOrchestrator.getCluster(clusterInfo.id);
1042
+ if (cluster) {
1043
+ const messages = cluster.messageBus.getAll(clusterInfo.id);
1044
+ const issueOpened = messages.find((m) => m.topic === 'ISSUE_OPENED');
1045
+ if (issueOpened) {
1046
+ const clusterLabel = multiCluster ? `[${clusterInfo.id}] ` : '';
1047
+ const taskSummary = formatTaskSummary(issueOpened);
1048
+ console.log(chalk.cyan(`${clusterLabel}Task: ${chalk.bold(taskSummary)}\n`));
1049
+ }
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ const stopPollers = [];
1055
+ const messageBuffer = [];
1056
+
1057
+ // Track cluster states (for dim coloring of inactive clusters)
1058
+ const clusterStates = new Map(); // cluster_id -> state
1059
+ for (const c of allClusters) {
1060
+ clusterStates.set(c.id, c.state);
1061
+ }
1062
+
1063
+ // Track agent states from AGENT_LIFECYCLE messages (cross-process compatible)
1064
+ const agentStates = new Map(); // agent -> { state, timestamp }
1065
+
1066
+ // Track if status line is currently displayed (to clear before printing logs)
1067
+ let statusLineShown = false;
1068
+
1069
+ // Buffered message handler - collects messages and sorts by timestamp
1070
+ const flushMessages = () => {
1071
+ if (messageBuffer.length === 0) return;
1072
+ // Sort by timestamp
1073
+ messageBuffer.sort((a, b) => a.timestamp - b.timestamp);
1074
+
1075
+ // Track senders with pending output
1076
+ const sendersWithOutput = new Set();
1077
+ for (const msg of messageBuffer) {
1078
+ if (msg.topic === 'AGENT_OUTPUT' && msg.sender) {
1079
+ sendersWithOutput.add(msg.sender);
1080
+ }
1081
+ // Track agent state from AGENT_LIFECYCLE messages
1082
+ if (msg.topic === 'AGENT_LIFECYCLE' && msg.sender && msg.content?.data?.state) {
1083
+ agentStates.set(msg.sender, {
1084
+ state: msg.content.data.state,
1085
+ model: msg.sender_model, // sender_model is always set by agent-wrapper._publish
1086
+ timestamp: msg.timestamp || Date.now(),
1087
+ });
1088
+ }
1089
+
1090
+ // Clear status line before printing message
1091
+ if (statusLineShown) {
1092
+ process.stdout.write('\r' + ' '.repeat(120) + '\r');
1093
+ statusLineShown = false;
1094
+ }
1095
+
1096
+ const isActive = clusterStates.get(msg.cluster_id) === 'running';
1097
+ printMessage(msg, true, options.watch, isActive);
1098
+ }
1099
+
1100
+ // Save cluster ID before clearing buffer
1101
+ const firstClusterId = messageBuffer[0]?.cluster_id;
1102
+ messageBuffer.length = 0;
1103
+
1104
+ // Flush pending line buffers for all senders that had output
1105
+ // This ensures streaming text without newlines gets displayed
1106
+ for (const sender of sendersWithOutput) {
1107
+ const senderLabel = `${firstClusterId || ''}/${sender}`;
1108
+ const prefix = getColorForSender(sender)(`${senderLabel.padEnd(25)} |`);
1109
+ flushLineBuffer(prefix, sender);
1110
+ }
1111
+ };
1112
+
1113
+ // Flush buffer every 250ms
1114
+ const flushInterval = setInterval(flushMessages, 250);
1115
+
1116
+ // Blinking status indicator (follow/watch mode) - uses AGENT_LIFECYCLE state
1117
+ let blinkState = false;
1118
+ let statusInterval = null;
1119
+ if (options.follow || options.watch) {
1120
+ statusInterval = setInterval(() => {
1121
+ blinkState = !blinkState;
1122
+
1123
+ // Get active agents from tracked states
1124
+ const activeList = [];
1125
+ for (const [agentId, info] of agentStates.entries()) {
1126
+ // Agent is active if not idle and not stopped
1127
+ if (info.state !== 'idle' && info.state !== 'stopped') {
1128
+ activeList.push({
1129
+ id: agentId,
1130
+ state: info.state,
1131
+ model: info.model,
1132
+ });
1133
+ }
1134
+ }
1135
+
1136
+ // Build status line - only show when agents are actively working
1137
+ if (activeList.length > 0) {
1138
+ const indicator = blinkState ? chalk.yellow('●') : chalk.dim('○');
1139
+ const agents = activeList
1140
+ .map((a) => {
1141
+ // Show state only for non-standard states (error, etc.)
1142
+ const showState = a.state === 'error';
1143
+ const stateLabel = showState ? chalk.red(` (${a.state})`) : '';
1144
+ // Always show model
1145
+ const modelLabel = a.model ? chalk.dim(` [${a.model}]`) : '';
1146
+ return getColorForSender(a.id)(a.id) + modelLabel + stateLabel;
1147
+ })
1148
+ .join(', ');
1149
+ process.stdout.write(`\r${indicator} Active: ${agents}` + ' '.repeat(20));
1150
+ statusLineShown = true;
1151
+ } else {
1152
+ // Clear status line when no agents actively working
1153
+ if (statusLineShown) {
1154
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
1155
+ statusLineShown = false;
1156
+ }
1157
+ }
1158
+ }, 500);
1159
+ }
1160
+
1161
+ for (const clusterInfo of allClusters) {
1162
+ const cluster = quietOrchestrator.getCluster(clusterInfo.id);
1163
+ if (cluster) {
1164
+ // Use polling for cross-process message detection
1165
+ const stopPoll = cluster.ledger.pollForMessages(
1166
+ clusterInfo.id,
1167
+ (msg) => {
1168
+ messageBuffer.push(msg);
1169
+ },
1170
+ 300
1171
+ );
1172
+ stopPollers.push(stopPoll);
1173
+ }
1174
+ }
1175
+
1176
+ const stopWatching = quietOrchestrator.watchForNewClusters((newCluster) => {
1177
+ console.log(chalk.green(`\n✓ New cluster detected: ${newCluster.id}\n`));
1178
+ // Track new cluster as active
1179
+ clusterStates.set(newCluster.id, 'running');
1180
+ // Poll new cluster's ledger
1181
+ const stopPoll = newCluster.ledger.pollForMessages(
1182
+ newCluster.id,
1183
+ (msg) => {
1184
+ messageBuffer.push(msg);
1185
+ },
1186
+ 300
1187
+ );
1188
+ stopPollers.push(stopPoll);
1189
+ });
1190
+
1191
+ keepProcessAlive(() => {
1192
+ clearInterval(flushInterval);
1193
+ if (statusInterval) clearInterval(statusInterval);
1194
+ flushMessages();
1195
+ stopPollers.forEach((stop) => stop());
1196
+ stopWatching();
1197
+ // Clear status line on exit
1198
+ if (statusLineShown) {
1199
+ process.stdout.write('\r' + ' '.repeat(120) + '\r');
1200
+ }
1201
+ // Restore terminal title
1202
+ restoreTerminalTitle();
1203
+ });
1204
+ }
1205
+ return;
1206
+ }
1207
+
1208
+ // Specific cluster ID provided
1209
+ const cluster = quietOrchestrator.getCluster(id);
1210
+ if (!cluster) {
1211
+ console.error(`Cluster ${id} not found`);
1212
+ process.exit(1);
1213
+ }
1214
+
1215
+ // Check if cluster is active
1216
+ const allClustersList = quietOrchestrator.listClusters();
1217
+ const clusterInfo = allClustersList.find((c) => c.id === id);
1218
+ const isActive = clusterInfo?.state === 'running';
1219
+
1220
+ // Get messages from cluster database
1221
+ const dbMessages = cluster.messageBus.getAll(id);
1222
+
1223
+ // Get messages from agent task logs
1224
+ const taskLogMessages = readAgentTaskLogs(cluster);
1225
+
1226
+ // Merge and sort by timestamp
1227
+ const allMessages = [...dbMessages, ...taskLogMessages].sort(
1228
+ (a, b) => a.timestamp - b.timestamp
1229
+ );
1230
+ const recentMessages = allMessages.slice(-limit);
1231
+
1232
+ // Print messages
1233
+ for (const msg of recentMessages) {
1234
+ printMessage(msg, true, options.watch, isActive);
1235
+ }
1236
+
1237
+ // Follow mode for specific cluster (poll SQLite AND task logs)
1238
+ if (options.follow) {
1239
+ // Set terminal title based on task
1240
+ const issueOpened = dbMessages.find((m) => m.topic === 'ISSUE_OPENED');
1241
+ if (issueOpened) {
1242
+ setTerminalTitle(`zeroshot [${id}]: ${formatTaskSummary(issueOpened, 30)}`);
1243
+ } else {
1244
+ setTerminalTitle(`zeroshot [${id}]`);
1245
+ }
1246
+
1247
+ console.log('\n--- Following logs (Ctrl+C to stop) ---\n');
1248
+
1249
+ // Poll cluster database for new messages
1250
+ const stopDbPoll = cluster.ledger.pollForMessages(
1251
+ id,
1252
+ (msg) => {
1253
+ printMessage(msg, true, options.watch, isActive);
1254
+
1255
+ // Flush pending line buffer for streaming text without newlines
1256
+ if (msg.topic === 'AGENT_OUTPUT' && msg.sender) {
1257
+ const senderLabel = `${msg.cluster_id || ''}/${msg.sender}`;
1258
+ const prefix = getColorForSender(msg.sender)(`${senderLabel.padEnd(25)} |`);
1259
+ flushLineBuffer(prefix, msg.sender);
1260
+ }
1261
+ },
1262
+ 500
1263
+ );
1264
+
1265
+ // Poll agent task logs for new output
1266
+ const taskLogSizes = new Map(); // taskId -> last size
1267
+ const pollTaskLogs = () => {
1268
+ for (const agent of cluster.agents) {
1269
+ const state = agent.getState();
1270
+ const taskId = state.currentTaskId;
1271
+ if (!taskId) continue;
1272
+
1273
+ const logPath = path.join(os.homedir(), '.claude-zeroshot', 'logs', `${taskId}.log`);
1274
+ if (!fs.existsSync(logPath)) continue;
1275
+
1276
+ try {
1277
+ const stats = fs.statSync(logPath);
1278
+ const currentSize = stats.size;
1279
+ const lastSize = taskLogSizes.get(taskId) || 0;
1280
+
1281
+ if (currentSize > lastSize) {
1282
+ // Read new content
1283
+ const fd = fs.openSync(logPath, 'r');
1284
+ const buffer = Buffer.alloc(currentSize - lastSize);
1285
+ fs.readSync(fd, buffer, 0, buffer.length, lastSize);
1286
+ fs.closeSync(fd);
1287
+
1288
+ const newContent = buffer.toString('utf-8');
1289
+ const lines = newContent.split('\n').filter((line) => line.trim());
1290
+
1291
+ for (const line of lines) {
1292
+ if (!line.trim().startsWith('{')) continue;
1293
+
1294
+ try {
1295
+ // Parse timestamp-prefixed line
1296
+ let timestamp = Date.now();
1297
+ let jsonContent = line.trim();
1298
+
1299
+ const timestampMatch = jsonContent.match(/^\[(\d{13})\](.*)$/);
1300
+ if (timestampMatch) {
1301
+ timestamp = parseInt(timestampMatch[1], 10);
1302
+ jsonContent = timestampMatch[2];
1303
+ }
1304
+
1305
+ if (!jsonContent.startsWith('{')) continue;
1306
+
1307
+ // Parse and validate JSON
1308
+ const parsed = JSON.parse(jsonContent);
1309
+ if (parsed.type === 'system' && parsed.subtype === 'init') continue;
1310
+
1311
+ // Create message and print immediately
1312
+ const msg = {
1313
+ id: `task-${taskId}-${timestamp}`,
1314
+ timestamp,
1315
+ topic: 'AGENT_OUTPUT',
1316
+ sender: agent.id,
1317
+ receiver: 'broadcast',
1318
+ cluster_id: cluster.id,
1319
+ content: {
1320
+ text: jsonContent,
1321
+ data: {
1322
+ type: 'stdout',
1323
+ line: jsonContent,
1324
+ agent: agent.id,
1325
+ role: agent.role,
1326
+ iteration: state.iteration,
1327
+ fromTaskLog: true,
1328
+ },
1329
+ },
1330
+ };
1331
+
1332
+ printMessage(msg, true, options.watch, isActive);
1333
+
1334
+ // Flush line buffer
1335
+ const senderLabel = `${cluster.id}/${agent.id}`;
1336
+ const prefix = getColorForSender(agent.id)(`${senderLabel.padEnd(25)} |`);
1337
+ flushLineBuffer(prefix, agent.id);
1338
+ } catch {
1339
+ // Skip invalid JSON
1340
+ }
1341
+ }
1342
+
1343
+ taskLogSizes.set(taskId, currentSize);
1344
+ }
1345
+ } catch {
1346
+ // File read error - skip
1347
+ }
1348
+ }
1349
+ };
1350
+
1351
+ // Poll task logs every 300ms (same as agent-wrapper)
1352
+ const taskLogInterval = setInterval(pollTaskLogs, 300);
1353
+
1354
+ keepProcessAlive(() => {
1355
+ stopDbPoll();
1356
+ clearInterval(taskLogInterval);
1357
+ restoreTerminalTitle();
1358
+ });
1359
+ }
1360
+ } catch (error) {
1361
+ console.error('Error viewing logs:', error.message);
1362
+ process.exit(1);
1363
+ }
1364
+ });
1365
+
1366
+ // Stop command (cluster-only)
1367
+ program
1368
+ .command('stop <cluster-id>')
1369
+ .description('Stop a cluster gracefully')
1370
+ .action(async (clusterId) => {
1371
+ try {
1372
+ console.log(`Stopping cluster ${clusterId}...`);
1373
+ await getOrchestrator().stop(clusterId);
1374
+ console.log('Cluster stopped successfully');
1375
+ } catch (error) {
1376
+ console.error('Error stopping cluster:', error.message);
1377
+ process.exit(1);
1378
+ }
1379
+ });
1380
+
1381
+ // Kill command - smart (works for both tasks and clusters)
1382
+ program
1383
+ .command('kill <id>')
1384
+ .description('Kill a task or cluster')
1385
+ .action(async (id) => {
1386
+ try {
1387
+ const { detectIdType } = require('../lib/id-detector');
1388
+ const type = detectIdType(id);
1389
+
1390
+ if (!type) {
1391
+ console.error(`ID not found: ${id}`);
1392
+ process.exit(1);
1393
+ }
1394
+
1395
+ if (type === 'cluster') {
1396
+ console.log(`Killing cluster ${id}...`);
1397
+ await getOrchestrator().kill(id);
1398
+ console.log('Cluster killed successfully');
1399
+ } else {
1400
+ // Kill task
1401
+ const { killTaskCommand } = await import('../task-lib/commands/kill.js');
1402
+ await killTaskCommand(id);
1403
+ }
1404
+ } catch (error) {
1405
+ console.error('Error killing:', error.message);
1406
+ process.exit(1);
1407
+ }
1408
+ });
1409
+
1410
+ // Attach command - tmux-style attach to running task or cluster agent
1411
+ program
1412
+ .command('attach [id]')
1413
+ .description('Attach to a running task or cluster agent (Ctrl+C to detach, task keeps running)')
1414
+ .option('-a, --agent <name>', 'Attach to specific agent in cluster (required for clusters)')
1415
+ .addHelpText(
1416
+ 'after',
1417
+ `
1418
+ Examples:
1419
+ ${chalk.cyan('zeroshot attach')} List attachable tasks/clusters
1420
+ ${chalk.cyan('zeroshot attach task-xxx')} Attach to task
1421
+ ${chalk.cyan('zeroshot attach cluster-xxx --agent worker')} Attach to specific agent in cluster
1422
+
1423
+ Key bindings:
1424
+ ${chalk.yellow('Ctrl+C')} Detach (task continues running)
1425
+ ${chalk.yellow('Ctrl+B d')} Also detach (for tmux muscle memory)
1426
+ ${chalk.yellow('Ctrl+B ?')} Show help
1427
+ ${chalk.yellow('Ctrl+B c')} Interrupt agent (sends SIGINT) - USE WITH CAUTION
1428
+ `
1429
+ )
1430
+ .action(async (id, options) => {
1431
+ try {
1432
+ const { AttachClient, socketDiscovery } = require('../src/attach');
1433
+
1434
+ // If no ID provided, list attachable processes
1435
+ if (!id) {
1436
+ const tasks = await socketDiscovery.listAttachableTasks();
1437
+ const clusters = await socketDiscovery.listAttachableClusters();
1438
+
1439
+ if (tasks.length === 0 && clusters.length === 0) {
1440
+ console.log(chalk.dim('No attachable tasks or clusters found.'));
1441
+ console.log(chalk.dim('Start a task with: zeroshot task run "prompt"'));
1442
+ return;
1443
+ }
1444
+
1445
+ console.log(chalk.bold('\nAttachable processes:\n'));
1446
+
1447
+ if (tasks.length > 0) {
1448
+ console.log(chalk.cyan('Tasks:'));
1449
+ for (const taskId of tasks) {
1450
+ console.log(` ${taskId}`);
1451
+ }
1452
+ }
1453
+
1454
+ if (clusters.length > 0) {
1455
+ console.log(chalk.yellow('\nClusters:'));
1456
+ const OrchestratorModule = require('../src/orchestrator');
1457
+ for (const clusterId of clusters) {
1458
+ const agents = await socketDiscovery.listAttachableAgents(clusterId);
1459
+ console.log(` ${clusterId}`);
1460
+ // Get agent models from orchestrator (if available)
1461
+ let agentModels = {};
1462
+ try {
1463
+ const orchestrator = OrchestratorModule.getInstance();
1464
+ const status = orchestrator.getStatus(clusterId);
1465
+ for (const a of status.agents) {
1466
+ agentModels[a.id] = a.model;
1467
+ }
1468
+ } catch {
1469
+ /* orchestrator not running - models unavailable */
1470
+ }
1471
+ for (const agent of agents) {
1472
+ const modelLabel = agentModels[agent] ? chalk.dim(` [${agentModels[agent]}]`) : '';
1473
+ console.log(chalk.dim(` --agent ${agent}`) + modelLabel);
1474
+ }
1475
+ }
1476
+ }
1477
+
1478
+ console.log(chalk.dim('\nUsage: zeroshot attach <id> [--agent <name>]'));
1479
+ return;
1480
+ }
1481
+
1482
+ // Determine socket path
1483
+ let socketPath;
1484
+
1485
+ if (id.startsWith('task-')) {
1486
+ socketPath = socketDiscovery.getTaskSocketPath(id);
1487
+ } else if (id.startsWith('cluster-')) {
1488
+ // Clusters use the task system - each agent spawns a task with its own socket
1489
+ // Get cluster status to find which task each agent is running
1490
+ const store = require('../lib/store');
1491
+ const cluster = store.getCluster(id);
1492
+
1493
+ if (!cluster) {
1494
+ console.error(chalk.red(`Cluster ${id} not found`));
1495
+ process.exit(1);
1496
+ }
1497
+
1498
+ if (cluster.state !== 'running') {
1499
+ console.error(chalk.red(`Cluster ${id} is not running (state: ${cluster.state})`));
1500
+ console.error(chalk.dim('Only running clusters have attachable agents.'));
1501
+ process.exit(1);
1502
+ }
1503
+
1504
+ // Get orchestrator instance to query agent states
1505
+ const OrchestratorModule = require('../src/orchestrator');
1506
+ const orchestrator = OrchestratorModule.getInstance();
1507
+
1508
+ try {
1509
+ const status = orchestrator.getStatus(id);
1510
+ const activeAgents = status.agents.filter(
1511
+ (a) => a.currentTaskId && a.state === 'executing_task'
1512
+ );
1513
+
1514
+ if (activeAgents.length === 0) {
1515
+ console.error(chalk.yellow(`No agents currently executing tasks in cluster ${id}`));
1516
+ console.log(chalk.dim('\nAgent states:'));
1517
+ for (const agent of status.agents) {
1518
+ const modelLabel = agent.model ? chalk.dim(` [${agent.model}]`) : '';
1519
+ console.log(
1520
+ chalk.dim(
1521
+ ` ${agent.id}${modelLabel}: ${agent.state}${agent.currentTaskId ? ` (last task: ${agent.currentTaskId})` : ''}`
1522
+ )
1523
+ );
1524
+ }
1525
+ return;
1526
+ }
1527
+
1528
+ if (!options.agent) {
1529
+ // Show list of agents and their task IDs
1530
+ console.log(chalk.yellow(`\nCluster ${id} - attachable agents:\n`));
1531
+ for (const agent of activeAgents) {
1532
+ const modelLabel = agent.model ? chalk.dim(` [${agent.model}]`) : '';
1533
+ console.log(
1534
+ ` ${chalk.cyan(agent.id)}${modelLabel} → task ${chalk.green(agent.currentTaskId)}`
1535
+ );
1536
+ console.log(chalk.dim(` zeroshot attach ${agent.currentTaskId}`));
1537
+ }
1538
+ console.log(chalk.dim('\nAttach to an agent by running: zeroshot attach <taskId>'));
1539
+ return;
1540
+ }
1541
+
1542
+ // Find the specified agent
1543
+ const agent = status.agents.find((a) => a.id === options.agent);
1544
+ if (!agent) {
1545
+ console.error(chalk.red(`Agent '${options.agent}' not found in cluster ${id}`));
1546
+ console.log(
1547
+ chalk.dim('Available agents: ' + status.agents.map((a) => a.id).join(', '))
1548
+ );
1549
+ process.exit(1);
1550
+ }
1551
+
1552
+ if (!agent.currentTaskId) {
1553
+ console.error(chalk.yellow(`Agent '${options.agent}' is not currently running a task`));
1554
+ console.log(chalk.dim(`State: ${agent.state}`));
1555
+ return;
1556
+ }
1557
+
1558
+ // Use the agent's task socket
1559
+ socketPath = socketDiscovery.getTaskSocketPath(agent.currentTaskId);
1560
+ console.log(
1561
+ chalk.dim(`Attaching to agent ${options.agent} via task ${agent.currentTaskId}...`)
1562
+ );
1563
+ } catch (err) {
1564
+ // Orchestrator not running or cluster not loaded - fall back to socket discovery
1565
+ console.error(chalk.yellow(`Could not get cluster status: ${err.message}`));
1566
+ console.log(
1567
+ chalk.dim('Try attaching directly to a task ID instead: zeroshot attach <taskId>')
1568
+ );
1569
+
1570
+ // Try to find any task sockets that might belong to this cluster
1571
+ const tasks = await socketDiscovery.listAttachableTasks();
1572
+ if (tasks.length > 0) {
1573
+ console.log(chalk.dim('\nAttachable tasks:'));
1574
+ for (const taskId of tasks) {
1575
+ console.log(chalk.dim(` zeroshot attach ${taskId}`));
1576
+ }
1577
+ }
1578
+ return;
1579
+ }
1580
+ } else {
1581
+ // Try to auto-detect
1582
+ socketPath = socketDiscovery.getSocketPath(id, options.agent);
1583
+ }
1584
+
1585
+ // Check if socket exists
1586
+ const socketAlive = await socketDiscovery.isSocketAlive(socketPath);
1587
+ if (!socketAlive) {
1588
+ console.error(chalk.red(`Cannot attach to ${id}`));
1589
+
1590
+ // Check if it's an old task without attach support
1591
+ const { detectIdType } = require('../lib/id-detector');
1592
+ const type = detectIdType(id);
1593
+
1594
+ if (type === 'task') {
1595
+ console.error(chalk.dim('Task may have been spawned before attach support was added.'));
1596
+ console.error(chalk.dim(`Try: zeroshot logs ${id} -f`));
1597
+ } else if (type === 'cluster') {
1598
+ console.error(chalk.dim('Cluster may not be running or agent may not exist.'));
1599
+ console.error(chalk.dim(`Check status: zeroshot status ${id}`));
1600
+ } else {
1601
+ console.error(chalk.dim('Process not found or not attachable.'));
1602
+ }
1603
+ process.exit(1);
1604
+ }
1605
+
1606
+ // Connect
1607
+ console.log(
1608
+ chalk.dim(`Attaching to ${id}${options.agent ? ` (agent: ${options.agent})` : ''}...`)
1609
+ );
1610
+ console.log(chalk.dim('Press Ctrl+B ? for help, Ctrl+B d to detach\n'));
1611
+
1612
+ const client = new AttachClient({ socketPath });
1613
+
1614
+ client.on('state', (_state) => {
1615
+ // Could show status bar here in future
1616
+ });
1617
+
1618
+ client.on('exit', ({ code, signal }) => {
1619
+ console.log(chalk.dim(`\n\nProcess exited (code: ${code}, signal: ${signal})`));
1620
+ process.exit(code || 0);
1621
+ });
1622
+
1623
+ client.on('error', (err) => {
1624
+ console.error(chalk.red(`\nConnection error: ${err.message}`));
1625
+ process.exit(1);
1626
+ });
1627
+
1628
+ client.on('detach', () => {
1629
+ console.log(chalk.dim('\n\nDetached. Task continues running.'));
1630
+ console.log(
1631
+ chalk.dim(
1632
+ `Re-attach: zeroshot attach ${id}${options.agent ? ` --agent ${options.agent}` : ''}`
1633
+ )
1634
+ );
1635
+ process.exit(0);
1636
+ });
1637
+
1638
+ client.on('close', () => {
1639
+ console.log(chalk.dim('\n\nConnection closed.'));
1640
+ process.exit(0);
1641
+ });
1642
+
1643
+ await client.connect();
1644
+ } catch (error) {
1645
+ console.error(chalk.red(`Error attaching: ${error.message}`));
1646
+ process.exit(1);
1647
+ }
1648
+ });
1649
+
1650
+ // Kill-all command - kills all running tasks and clusters
1651
+ program
1652
+ .command('kill-all')
1653
+ .description('Kill all running tasks and clusters')
1654
+ .option('-y, --yes', 'Skip confirmation')
1655
+ .action(async (options) => {
1656
+ try {
1657
+ // Get counts first
1658
+ const orchestrator = getOrchestrator();
1659
+ const clusters = orchestrator.listClusters();
1660
+ const runningClusters = clusters.filter(
1661
+ (c) => c.state === 'running' || c.state === 'initializing'
1662
+ );
1663
+
1664
+ const { loadTasks } = await import('../task-lib/store.js');
1665
+ const { isProcessRunning } = await import('../task-lib/runner.js');
1666
+ const tasks = loadTasks();
1667
+ const runningTasks = Object.values(tasks).filter(
1668
+ (t) => t.status === 'running' && isProcessRunning(t.pid)
1669
+ );
1670
+
1671
+ const totalCount = runningClusters.length + runningTasks.length;
1672
+
1673
+ if (totalCount === 0) {
1674
+ console.log(chalk.dim('No running tasks or clusters to kill.'));
1675
+ return;
1676
+ }
1677
+
1678
+ // Show what will be killed
1679
+ console.log(chalk.bold(`\nWill kill:`));
1680
+ if (runningClusters.length > 0) {
1681
+ console.log(chalk.cyan(` ${runningClusters.length} cluster(s)`));
1682
+ for (const c of runningClusters) {
1683
+ console.log(chalk.dim(` - ${c.id}`));
1684
+ }
1685
+ }
1686
+ if (runningTasks.length > 0) {
1687
+ console.log(chalk.yellow(` ${runningTasks.length} task(s)`));
1688
+ for (const t of runningTasks) {
1689
+ console.log(chalk.dim(` - ${t.id}`));
1690
+ }
1691
+ }
1692
+
1693
+ // Confirm unless -y flag
1694
+ if (!options.yes) {
1695
+ const readline = require('readline');
1696
+ const rl = readline.createInterface({
1697
+ input: process.stdin,
1698
+ output: process.stdout,
1699
+ });
1700
+
1701
+ const answer = await new Promise((resolve) => {
1702
+ rl.question(chalk.bold('\nProceed? [y/N] '), resolve);
1703
+ });
1704
+ rl.close();
1705
+
1706
+ if (answer.toLowerCase() !== 'y') {
1707
+ console.log('Aborted.');
1708
+ return;
1709
+ }
1710
+ }
1711
+
1712
+ console.log('');
1713
+
1714
+ // Kill clusters
1715
+ if (runningClusters.length > 0) {
1716
+ const clusterResults = await orchestrator.killAll();
1717
+ for (const id of clusterResults.killed) {
1718
+ console.log(chalk.green(`✓ Killed cluster: ${id}`));
1719
+ }
1720
+ for (const err of clusterResults.errors) {
1721
+ console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
1722
+ }
1723
+ }
1724
+
1725
+ // Kill tasks
1726
+ if (runningTasks.length > 0) {
1727
+ const { killTask, isProcessRunning: checkPid } = await import('../task-lib/runner.js');
1728
+ const { updateTask } = await import('../task-lib/store.js');
1729
+
1730
+ for (const task of runningTasks) {
1731
+ if (!checkPid(task.pid)) {
1732
+ updateTask(task.id, {
1733
+ status: 'stale',
1734
+ error: 'Process died unexpectedly',
1735
+ });
1736
+ console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
1737
+ continue;
1738
+ }
1739
+
1740
+ const killed = killTask(task.pid);
1741
+ if (killed) {
1742
+ updateTask(task.id, {
1743
+ status: 'killed',
1744
+ error: 'Killed by kill-all',
1745
+ });
1746
+ console.log(chalk.green(`✓ Killed task: ${task.id}`));
1747
+ } else {
1748
+ console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
1749
+ }
1750
+ }
1751
+ }
1752
+
1753
+ console.log(chalk.bold.green(`\nDone.`));
1754
+ } catch (error) {
1755
+ console.error('Error:', error.message);
1756
+ process.exit(1);
1757
+ }
1758
+ });
1759
+
1760
+ // Export command (cluster-only)
1761
+ program
1762
+ .command('export <cluster-id>')
1763
+ .description('Export cluster conversation')
1764
+ .option('-f, --format <format>', 'Export format: json, markdown, pdf', 'pdf')
1765
+ .option('-o, --output <file>', 'Output file (auto-generated for pdf)')
1766
+ .action(async (clusterId, options) => {
1767
+ try {
1768
+ // Get messages from DB
1769
+ const Ledger = require('../src/ledger');
1770
+ const homeDir = require('os').homedir();
1771
+ const dbPath = path.join(homeDir, '.zeroshot', `${clusterId}.db`);
1772
+
1773
+ if (!require('fs').existsSync(dbPath)) {
1774
+ throw new Error(`Cluster ${clusterId} not found (no DB file)`);
1775
+ }
1776
+
1777
+ const ledger = new Ledger(dbPath);
1778
+ const messages = ledger.getAll(clusterId);
1779
+ ledger.close();
1780
+
1781
+ // JSON export
1782
+ if (options.format === 'json') {
1783
+ const data = JSON.stringify({ cluster_id: clusterId, messages }, null, 2);
1784
+ if (options.output) {
1785
+ require('fs').writeFileSync(options.output, data, 'utf8');
1786
+ console.log(`Exported to ${options.output}`);
1787
+ } else {
1788
+ console.log(data);
1789
+ }
1790
+ return;
1791
+ }
1792
+
1793
+ // Terminal-style export (for markdown and pdf)
1794
+ const terminalOutput = renderMessagesToTerminal(clusterId, messages);
1795
+
1796
+ if (options.format === 'markdown') {
1797
+ // Strip ANSI codes for markdown
1798
+ const plainText = terminalOutput.replace(/\\x1b\[[0-9;]*m/g, '');
1799
+ if (options.output) {
1800
+ require('fs').writeFileSync(options.output, plainText, 'utf8');
1801
+ console.log(`Exported to ${options.output}`);
1802
+ } else {
1803
+ console.log(plainText);
1804
+ }
1805
+ return;
1806
+ }
1807
+
1808
+ // PDF export - convert ANSI to HTML, then to PDF
1809
+ const outputFile = options.output || `${clusterId}.pdf`;
1810
+ const AnsiToHtml = require('ansi-to-html');
1811
+ const { mdToPdf } = await import('md-to-pdf');
1812
+
1813
+ const ansiConverter = new AnsiToHtml({
1814
+ fg: '#d4d4d4',
1815
+ bg: '#1e1e1e',
1816
+ colors: {
1817
+ 0: '#1e1e1e',
1818
+ 1: '#f44747',
1819
+ 2: '#6a9955',
1820
+ 3: '#dcdcaa',
1821
+ 4: '#569cd6',
1822
+ 5: '#c586c0',
1823
+ 6: '#4ec9b0',
1824
+ 7: '#d4d4d4',
1825
+ 8: '#808080',
1826
+ 9: '#f44747',
1827
+ 10: '#6a9955',
1828
+ 11: '#dcdcaa',
1829
+ 12: '#569cd6',
1830
+ 13: '#c586c0',
1831
+ 14: '#4ec9b0',
1832
+ 15: '#ffffff',
1833
+ },
1834
+ });
1835
+
1836
+ const htmlContent = ansiConverter.toHtml(terminalOutput);
1837
+ const fullHtml = `<pre style="margin:0;padding:0;white-space:pre-wrap;word-wrap:break-word;">${htmlContent}</pre>`;
1838
+
1839
+ const pdf = await mdToPdf(
1840
+ { content: fullHtml },
1841
+ {
1842
+ pdf_options: {
1843
+ format: 'A4',
1844
+ margin: {
1845
+ top: '10mm',
1846
+ right: '10mm',
1847
+ bottom: '10mm',
1848
+ left: '10mm',
1849
+ },
1850
+ printBackground: true,
1851
+ },
1852
+ css: `
1853
+ @page { size: A4 landscape; }
1854
+ body {
1855
+ margin: 0; padding: 16px;
1856
+ background: #1e1e1e; color: #d4d4d4;
1857
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
1858
+ font-size: 9pt; line-height: 1.4;
1859
+ }
1860
+ pre { margin: 0; font-family: inherit; }
1861
+ `,
1862
+ }
1863
+ );
1864
+
1865
+ require('fs').writeFileSync(outputFile, pdf.content);
1866
+ console.log(`Exported to ${outputFile}`);
1867
+ } catch (error) {
1868
+ console.error('Error exporting cluster:', error.message);
1869
+ process.exit(1);
1870
+ }
1871
+ });
1872
+
1873
+ // === TASK-SPECIFIC COMMANDS ===
1874
+
1875
+ // Resume task or cluster
1876
+ program
1877
+ .command('resume <id> [prompt]')
1878
+ .description('Resume a failed task or cluster')
1879
+ .option('-d, --detach', 'Resume in background (daemon mode)')
1880
+ .action(async (id, prompt, options) => {
1881
+ try {
1882
+ // Try cluster first, then task (both use same ID format: "adjective-noun-number")
1883
+ const OrchestratorModule = require('../src/orchestrator');
1884
+ const orchestrator = new OrchestratorModule();
1885
+
1886
+ // Check if cluster exists
1887
+ const cluster = orchestrator.getCluster(id);
1888
+
1889
+ if (cluster) {
1890
+ // Resume cluster
1891
+ console.log(chalk.cyan(`Resuming cluster ${id}...`));
1892
+ const result = await orchestrator.resume(id, prompt);
1893
+
1894
+ console.log(chalk.green(`✓ Cluster resumed`));
1895
+ if (result.resumeType === 'failure') {
1896
+ console.log(` Resume type: ${chalk.yellow('From failure')}`);
1897
+ console.log(` Resumed agent: ${result.resumedAgent}`);
1898
+ console.log(` Previous error: ${result.previousError}`);
1899
+ } else {
1900
+ console.log(` Resume type: ${chalk.cyan('Clean continuation')}`);
1901
+ if (result.resumedAgents && result.resumedAgents.length > 0) {
1902
+ console.log(` Resumed agents: ${result.resumedAgents.join(', ')}`);
1903
+ } else {
1904
+ console.log(` Published CLUSTER_RESUMED to trigger workflow`);
1905
+ }
1906
+ }
1907
+
1908
+ // === DAEMON MODE: Exit and let cluster run in background ===
1909
+ if (options.detach) {
1910
+ console.log('');
1911
+ console.log(chalk.dim(`Follow logs with: zeroshot logs ${id} -f`));
1912
+ return;
1913
+ }
1914
+
1915
+ // === FOREGROUND MODE: Stream logs in real-time (same as 'run' command) ===
1916
+ console.log('');
1917
+ console.log(chalk.dim('Streaming logs... (Ctrl+C to stop cluster)'));
1918
+ console.log('');
1919
+
1920
+ // Get the cluster's message bus for streaming
1921
+ const resumedCluster = orchestrator.getCluster(id);
1922
+ if (!resumedCluster || !resumedCluster.messageBus) {
1923
+ console.error(chalk.red('Failed to get message bus for resumed cluster'));
1924
+ process.exit(1);
1925
+ }
1926
+
1927
+ // Track senders that have output (for periodic flushing)
1928
+ const sendersWithOutput = new Set();
1929
+ // Track messages we've already processed (to avoid duplicates between history and subscription)
1930
+ const processedMessageIds = new Set();
1931
+
1932
+ // Message handler - processes messages, deduplicates by ID
1933
+ const handleMessage = (msg) => {
1934
+ if (msg.cluster_id !== id) return;
1935
+ if (processedMessageIds.has(msg.id)) return;
1936
+ processedMessageIds.add(msg.id);
1937
+
1938
+ if (msg.topic === 'AGENT_OUTPUT' && msg.sender) {
1939
+ sendersWithOutput.add(msg.sender);
1940
+ }
1941
+ printMessage(msg, false, false, true);
1942
+ };
1943
+
1944
+ // Subscribe to NEW messages
1945
+ const unsubscribe = resumedCluster.messageBus.subscribe(handleMessage);
1946
+
1947
+ // Periodic flush of text buffers (streaming text may not have newlines)
1948
+ const flushInterval = setInterval(() => {
1949
+ for (const sender of sendersWithOutput) {
1950
+ const prefix = getColorForSender(sender)(`${sender.padEnd(15)} |`);
1951
+ flushLineBuffer(prefix, sender);
1952
+ }
1953
+ }, 250);
1954
+
1955
+ // Wait for cluster to complete
1956
+ await new Promise((resolve) => {
1957
+ const checkInterval = setInterval(() => {
1958
+ try {
1959
+ const status = orchestrator.getStatus(id);
1960
+ if (status.state !== 'running') {
1961
+ clearInterval(checkInterval);
1962
+ clearInterval(flushInterval);
1963
+ // Final flush
1964
+ for (const sender of sendersWithOutput) {
1965
+ const prefix = getColorForSender(sender)(`${sender.padEnd(15)} |`);
1966
+ flushLineBuffer(prefix, sender);
1967
+ }
1968
+ unsubscribe();
1969
+ resolve();
1970
+ }
1971
+ } catch {
1972
+ // Cluster may have been removed
1973
+ clearInterval(checkInterval);
1974
+ clearInterval(flushInterval);
1975
+ unsubscribe();
1976
+ resolve();
1977
+ }
1978
+ }, 500);
1979
+
1980
+ // Handle Ctrl+C: Stop cluster since foreground mode has no daemon
1981
+ // CRITICAL: In foreground mode, the cluster runs IN this process.
1982
+ // If we exit without stopping, the cluster becomes a zombie (state=running but no process).
1983
+ process.on('SIGINT', async () => {
1984
+ console.log(chalk.dim('\n\n--- Interrupted ---'));
1985
+ clearInterval(checkInterval);
1986
+ clearInterval(flushInterval);
1987
+ unsubscribe();
1988
+
1989
+ // Stop the cluster properly so state is updated
1990
+ try {
1991
+ console.log(chalk.dim(`Stopping cluster ${id}...`));
1992
+ await orchestrator.stop(id);
1993
+ console.log(chalk.dim(`Cluster ${id} stopped.`));
1994
+ } catch (stopErr) {
1995
+ console.error(chalk.red(`Failed to stop cluster: ${stopErr.message}`));
1996
+ }
1997
+
1998
+ process.exit(0);
1999
+ });
2000
+ });
2001
+
2002
+ console.log(chalk.dim(`\nCluster ${id} completed.`));
2003
+ } else {
2004
+ // Try resuming as task
2005
+ const { resumeTask } = await import('../task-lib/commands/resume.js');
2006
+ await resumeTask(id, prompt);
2007
+ }
2008
+ } catch (error) {
2009
+ console.error(chalk.red('Error resuming:'), error.message);
2010
+ process.exit(1);
2011
+ }
2012
+ });
2013
+
2014
+ // Finish cluster - convert to single-agent completion task
2015
+ program
2016
+ .command('finish <id>')
2017
+ .description('Take existing cluster and create completion-focused task (creates PR and merges)')
2018
+ .option('-y, --yes', 'Skip confirmation if cluster is running')
2019
+ .action(async (id, options) => {
2020
+ try {
2021
+ const OrchestratorModule = require('../src/orchestrator');
2022
+ const orchestrator = new OrchestratorModule();
2023
+
2024
+ // Check if cluster exists
2025
+ const cluster = orchestrator.getCluster(id);
2026
+
2027
+ if (!cluster) {
2028
+ console.error(chalk.red(`Error: Cluster ${id} not found`));
2029
+ console.error(chalk.dim('Use "zeroshot list" to see available clusters'));
2030
+ process.exit(1);
2031
+ }
2032
+
2033
+ // Stop cluster if it's running (with confirmation unless -y)
2034
+ if (cluster.state === 'running') {
2035
+ if (!options.y && !options.yes) {
2036
+ console.log(chalk.yellow(`Cluster ${id} is still running.`));
2037
+ console.log(chalk.dim('Stopping it before converting to completion task...'));
2038
+ console.log('');
2039
+
2040
+ // Simple confirmation prompt
2041
+ const readline = require('readline');
2042
+ const rl = readline.createInterface({
2043
+ input: process.stdin,
2044
+ output: process.stdout,
2045
+ });
2046
+
2047
+ const answer = await new Promise((resolve) => {
2048
+ rl.question(chalk.yellow('Continue? (y/N) '), resolve);
2049
+ });
2050
+ rl.close();
2051
+
2052
+ if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
2053
+ console.log(chalk.red('Aborted'));
2054
+ process.exit(0);
2055
+ }
2056
+ }
2057
+
2058
+ console.log(chalk.cyan('Stopping cluster...'));
2059
+ await orchestrator.stop(id);
2060
+ console.log(chalk.green('✓ Cluster stopped'));
2061
+ console.log('');
2062
+ }
2063
+
2064
+ console.log(chalk.cyan(`Converting cluster ${id} to completion task...`));
2065
+ console.log('');
2066
+
2067
+ // Extract cluster context from ledger
2068
+ const messages = cluster.messageBus.getAll(id);
2069
+
2070
+ // Find original task
2071
+ const issueOpened = messages.find((m) => m.topic === 'ISSUE_OPENED');
2072
+ const taskText = issueOpened?.content?.text || 'Unknown task';
2073
+ const issueNumber = issueOpened?.content?.data?.issue_number;
2074
+ const issueTitle = issueOpened?.content?.data?.title || 'Implementation';
2075
+
2076
+ // Find what's been done
2077
+ const agentOutputs = messages.filter((m) => m.topic === 'AGENT_OUTPUT');
2078
+ const validations = messages.filter((m) => m.topic === 'VALIDATION_RESULT');
2079
+
2080
+ // Build context summary
2081
+ let contextSummary = `# Original Task\n\n${taskText}\n\n`;
2082
+
2083
+ if (issueNumber) {
2084
+ contextSummary += `Issue: #${issueNumber} - ${issueTitle}\n\n`;
2085
+ }
2086
+
2087
+ contextSummary += `# Progress So Far\n\n`;
2088
+ contextSummary += `- ${agentOutputs.length} agent outputs\n`;
2089
+ contextSummary += `- ${validations.length} validation results\n`;
2090
+
2091
+ const approvedValidations = validations.filter(
2092
+ (v) => v.content?.data?.approved === true || v.content?.data?.approved === 'true'
2093
+ );
2094
+ contextSummary += `- ${approvedValidations.length} approvals\n\n`;
2095
+
2096
+ // Add recent validation summaries
2097
+ if (validations.length > 0) {
2098
+ contextSummary += `## Recent Validations\n\n`;
2099
+ for (const v of validations.slice(-3)) {
2100
+ const approved =
2101
+ v.content?.data?.approved === true || v.content?.data?.approved === 'true';
2102
+ const icon = approved ? '✅' : '❌';
2103
+ contextSummary += `${icon} **${v.sender}**: ${v.content?.data?.summary || 'No summary'}\n`;
2104
+ }
2105
+ contextSummary += '\n';
2106
+ }
2107
+
2108
+ // Build ultra-aggressive completion prompt (always merges)
2109
+ const mergeGoal = 'CREATE PR AND MERGE IT';
2110
+ const mergeStep = `
2111
+ 8. MERGE THE PR - THIS IS MANDATORY:
2112
+ \`\`\`bash
2113
+ gh pr merge --merge --auto
2114
+ \`\`\`
2115
+
2116
+ IF MERGE FAILS DUE TO CONFLICTS:
2117
+ a) Pull and rebase: \`git fetch origin main && git rebase origin/main\`
2118
+ b) Resolve conflicts manually (read files, make decisions, edit)
2119
+ c) \`git add <resolved-files> && git rebase --continue\`
2120
+ d) Force push: \`git push --force-with-lease\`
2121
+ e) Retry merge: \`gh pr merge --merge\`
2122
+
2123
+ REPEAT UNTIL MERGED. DO NOT GIVE UP.`;
2124
+
2125
+ const completionPrompt = `# YOUR MISSION: ${mergeGoal}
2126
+
2127
+ ${contextSummary}
2128
+
2129
+ # WHAT YOU MUST DO NOW
2130
+
2131
+ You are the FINISHER. Your ONLY job is to take this cluster's work and push it across the finish line.
2132
+
2133
+ **FAILURE IS NOT AN OPTION. YOU MUST COMPLETE THIS TASK.**
2134
+
2135
+ ## Steps (MUST complete ALL):
2136
+
2137
+ 1. REVIEW CURRENT STATE - Check git status, see what's changed:
2138
+ \`\`\`bash
2139
+ git status
2140
+ git diff
2141
+ \`\`\`
2142
+
2143
+ 2. COMMIT ALL CHANGES - Stage and commit everything:
2144
+ \`\`\`bash
2145
+ git add .
2146
+ git commit -m "${issueTitle || 'feat: implement task'}"
2147
+ \`\`\`
2148
+
2149
+ 3. CREATE BRANCH - Use issue number if available:
2150
+ \`\`\`bash
2151
+ ${issueNumber ? `git checkout -b issue-${issueNumber}` : 'git checkout -b feature/implementation'}
2152
+ \`\`\`
2153
+
2154
+ 4. PUSH TO REMOTE:
2155
+ \`\`\`bash
2156
+ git push -u origin HEAD
2157
+ \`\`\`
2158
+
2159
+ 5. CREATE PULL REQUEST:
2160
+ \`\`\`bash
2161
+ gh pr create --title "${issueTitle || 'Implementation'}" --body "Closes #${issueNumber || 'N/A'}
2162
+
2163
+ ## Summary
2164
+ ${taskText.slice(0, 200)}...
2165
+
2166
+ ## Changes
2167
+ - Implementation complete
2168
+ - All validations addressed
2169
+
2170
+ 🤖 Generated with zeroshot finish"
2171
+ \`\`\`
2172
+
2173
+ 6. GET PR URL:
2174
+ \`\`\`bash
2175
+ gh pr view --json url -q .url
2176
+ \`\`\`
2177
+
2178
+ 7. OUTPUT THE PR URL - Print it clearly so user can see it
2179
+ ${mergeStep}
2180
+
2181
+ ## RULES
2182
+
2183
+ - NO EXCUSES: If something fails, FIX IT and retry
2184
+ - NO SHORTCUTS: Follow ALL steps above
2185
+ - NO PARTIAL WORK: Must reach PR creation and merge
2186
+ - IF TESTS FAIL: Fix them until they pass
2187
+ - IF CI FAILS: Wait for it, fix issues, retry
2188
+ - IF CONFLICTS: Resolve them intelligently
2189
+
2190
+ **DO NOT STOP UNTIL YOU HAVE A MERGED PR.**`;
2191
+
2192
+ // Show preview
2193
+ console.log(chalk.dim('='.repeat(80)));
2194
+ console.log(chalk.dim('Task prompt preview:'));
2195
+ console.log(chalk.dim('='.repeat(80)));
2196
+ console.log(completionPrompt.split('\n').slice(0, 20).join('\n'));
2197
+ console.log(chalk.dim('... (truncated) ...\n'));
2198
+ console.log(chalk.dim('='.repeat(80)));
2199
+ console.log('');
2200
+
2201
+ // Launch as task (preserve isolation if cluster was isolated)
2202
+ console.log(chalk.cyan('Launching completion task...'));
2203
+ const { runTask } = await import('../task-lib/commands/run.js');
2204
+
2205
+ const taskOptions = {
2206
+ cwd: process.cwd(),
2207
+ };
2208
+
2209
+ // If cluster was in isolation mode, pass container info to task
2210
+ if (cluster.isolation?.enabled && cluster.isolation?.containerId) {
2211
+ console.log(chalk.dim(`Using isolation container: ${cluster.isolation.containerId}`));
2212
+ taskOptions.isolation = {
2213
+ containerId: cluster.isolation.containerId,
2214
+ workDir: '/workspace', // Standard workspace mount point in isolation containers
2215
+ };
2216
+ }
2217
+
2218
+ await runTask(completionPrompt, taskOptions);
2219
+
2220
+ console.log('');
2221
+ console.log(chalk.green(`✓ Completion task started`));
2222
+ if (cluster.isolation?.enabled) {
2223
+ console.log(chalk.dim('Running in isolation container (same as cluster)'));
2224
+ }
2225
+ console.log(chalk.dim('Monitor with: zeroshot list'));
2226
+ } catch (error) {
2227
+ console.error(chalk.red('Error:'), error.message);
2228
+ process.exit(1);
2229
+ }
2230
+ });
2231
+
2232
+ // Clean tasks
2233
+ program
2234
+ .command('clean')
2235
+ .description('Remove old task records and logs')
2236
+ .option('-a, --all', 'Remove all tasks')
2237
+ .option('-c, --completed', 'Remove completed tasks')
2238
+ .option('-f, --failed', 'Remove failed/stale/killed tasks')
2239
+ .action(async (options) => {
2240
+ try {
2241
+ const { cleanTasks } = await import('../task-lib/commands/clean.js');
2242
+ await cleanTasks(options);
2243
+ } catch (error) {
2244
+ console.error('Error cleaning tasks:', error.message);
2245
+ process.exit(1);
2246
+ }
2247
+ });
2248
+
2249
+ // Clear all runs (clusters + tasks)
2250
+ program
2251
+ .command('clear')
2252
+ .description('Kill all running processes and delete all data')
2253
+ .option('-y, --yes', 'Skip confirmation')
2254
+ .action(async (options) => {
2255
+ try {
2256
+ const orchestrator = getOrchestrator();
2257
+
2258
+ // Get counts first
2259
+ const clusters = orchestrator.listClusters();
2260
+ const runningClusters = clusters.filter(
2261
+ (c) => c.state === 'running' || c.state === 'initializing'
2262
+ );
2263
+
2264
+ const { loadTasks } = await import('../task-lib/store.js');
2265
+ const { isProcessRunning } = await import('../task-lib/runner.js');
2266
+ const tasks = Object.values(loadTasks());
2267
+ const runningTasks = tasks.filter((t) => t.status === 'running' && isProcessRunning(t.pid));
2268
+
2269
+ // Check if there's anything to clear
2270
+ if (clusters.length === 0 && tasks.length === 0) {
2271
+ console.log(chalk.dim('No clusters or tasks to clear.'));
2272
+ return;
2273
+ }
2274
+
2275
+ // Show what will be cleared
2276
+ console.log(chalk.bold('\nWill kill and delete:'));
2277
+ if (clusters.length > 0) {
2278
+ console.log(chalk.cyan(` ${clusters.length} cluster(s) with all history`));
2279
+ if (runningClusters.length > 0) {
2280
+ console.log(chalk.yellow(` ${runningClusters.length} running`));
2281
+ }
2282
+ }
2283
+ if (tasks.length > 0) {
2284
+ console.log(chalk.yellow(` ${tasks.length} task(s) with all logs`));
2285
+ if (runningTasks.length > 0) {
2286
+ console.log(chalk.yellow(` ${runningTasks.length} running`));
2287
+ }
2288
+ }
2289
+ console.log('');
2290
+
2291
+ // Confirm unless -y flag
2292
+ if (!options.yes) {
2293
+ const readline = require('readline');
2294
+ const rl = readline.createInterface({
2295
+ input: process.stdin,
2296
+ output: process.stdout,
2297
+ });
2298
+
2299
+ const answer = await new Promise((resolve) => {
2300
+ rl.question(
2301
+ chalk.bold.red(
2302
+ 'This will kill all processes and permanently delete all data. Proceed? [y/N] '
2303
+ ),
2304
+ resolve
2305
+ );
2306
+ });
2307
+ rl.close();
2308
+
2309
+ if (answer.toLowerCase() !== 'y') {
2310
+ console.log('Aborted.');
2311
+ return;
2312
+ }
2313
+ }
2314
+
2315
+ console.log('');
2316
+
2317
+ // Kill running clusters first
2318
+ if (runningClusters.length > 0) {
2319
+ console.log(chalk.bold('Killing running clusters...'));
2320
+ const clusterResults = await orchestrator.killAll();
2321
+ for (const id of clusterResults.killed) {
2322
+ console.log(chalk.green(`✓ Killed cluster: ${id}`));
2323
+ }
2324
+ for (const err of clusterResults.errors) {
2325
+ console.log(chalk.red(`✗ Failed to kill cluster ${err.id}: ${err.error}`));
2326
+ }
2327
+ }
2328
+
2329
+ // Kill running tasks
2330
+ if (runningTasks.length > 0) {
2331
+ console.log(chalk.bold('Killing running tasks...'));
2332
+ const { killTask } = await import('../task-lib/runner.js');
2333
+ const { updateTask } = await import('../task-lib/store.js');
2334
+
2335
+ for (const task of runningTasks) {
2336
+ if (!isProcessRunning(task.pid)) {
2337
+ updateTask(task.id, {
2338
+ status: 'stale',
2339
+ error: 'Process died unexpectedly',
2340
+ });
2341
+ console.log(chalk.yellow(`○ Task ${task.id} was already dead, marked stale`));
2342
+ continue;
2343
+ }
2344
+
2345
+ const killed = killTask(task.pid);
2346
+ if (killed) {
2347
+ updateTask(task.id, { status: 'killed', error: 'Killed by clear' });
2348
+ console.log(chalk.green(`✓ Killed task: ${task.id}`));
2349
+ } else {
2350
+ console.log(chalk.red(`✗ Failed to kill task: ${task.id}`));
2351
+ }
2352
+ }
2353
+ }
2354
+
2355
+ // Delete all cluster data
2356
+ if (clusters.length > 0) {
2357
+ console.log(chalk.bold('Deleting cluster data...'));
2358
+ const clustersFile = path.join(orchestrator.storageDir, 'clusters.json');
2359
+ const clustersDir = path.join(orchestrator.storageDir, 'clusters');
2360
+
2361
+ // Delete all cluster databases
2362
+ for (const cluster of clusters) {
2363
+ const dbPath = path.join(orchestrator.storageDir, `${cluster.id}.db`);
2364
+ if (fs.existsSync(dbPath)) {
2365
+ fs.unlinkSync(dbPath);
2366
+ console.log(chalk.green(`✓ Deleted cluster database: ${cluster.id}.db`));
2367
+ }
2368
+ }
2369
+
2370
+ // Delete clusters.json
2371
+ if (fs.existsSync(clustersFile)) {
2372
+ fs.unlinkSync(clustersFile);
2373
+ console.log(chalk.green(`✓ Deleted clusters.json`));
2374
+ }
2375
+
2376
+ // Delete clusters directory if exists
2377
+ if (fs.existsSync(clustersDir)) {
2378
+ fs.rmSync(clustersDir, { recursive: true, force: true });
2379
+ console.log(chalk.green(`✓ Deleted clusters/ directory`));
2380
+ }
2381
+
2382
+ // Clear in-memory clusters
2383
+ orchestrator.clusters.clear();
2384
+ }
2385
+
2386
+ // Delete all task data
2387
+ if (tasks.length > 0) {
2388
+ console.log(chalk.bold('Deleting task data...'));
2389
+ const { cleanTasks } = await import('../task-lib/commands/clean.js');
2390
+ await cleanTasks({ all: true });
2391
+ }
2392
+
2393
+ console.log(chalk.bold.green('\nAll runs killed and cleared.'));
2394
+ } catch (error) {
2395
+ console.error('Error clearing runs:', error.message);
2396
+ process.exit(1);
2397
+ }
2398
+ });
2399
+
2400
+ // Schedule a task
2401
+ program
2402
+ .command('schedule <prompt>')
2403
+ .description('Create a recurring scheduled task')
2404
+ .option('-e, --every <interval>', 'Interval (e.g., "1h", "30m", "1d")')
2405
+ .option('--cron <expression>', 'Cron expression')
2406
+ .option('-C, --cwd <path>', 'Working directory')
2407
+ .action(async (prompt, options) => {
2408
+ try {
2409
+ const { createSchedule } = await import('../task-lib/commands/schedule.js');
2410
+ await createSchedule(prompt, options);
2411
+ } catch (error) {
2412
+ console.error('Error creating schedule:', error.message);
2413
+ process.exit(1);
2414
+ }
2415
+ });
2416
+
2417
+ // List schedules
2418
+ program
2419
+ .command('schedules')
2420
+ .description('List all scheduled tasks')
2421
+ .action(async () => {
2422
+ try {
2423
+ const { listSchedules } = await import('../task-lib/commands/schedules.js');
2424
+ await listSchedules();
2425
+ } catch (error) {
2426
+ console.error('Error listing schedules:', error.message);
2427
+ process.exit(1);
2428
+ }
2429
+ });
2430
+
2431
+ // Unschedule a task
2432
+ program
2433
+ .command('unschedule <scheduleId>')
2434
+ .description('Remove a scheduled task')
2435
+ .action(async (scheduleId) => {
2436
+ try {
2437
+ const { deleteSchedule } = await import('../task-lib/commands/unschedule.js');
2438
+ await deleteSchedule(scheduleId);
2439
+ } catch (error) {
2440
+ console.error('Error unscheduling:', error.message);
2441
+ process.exit(1);
2442
+ }
2443
+ });
2444
+
2445
+ // Scheduler daemon management
2446
+ program
2447
+ .command('scheduler <action>')
2448
+ .description('Manage scheduler daemon (start, stop, status, logs)')
2449
+ .action(async (action) => {
2450
+ try {
2451
+ const { schedulerCommand } = await import('../task-lib/commands/scheduler-cmd.js');
2452
+ await schedulerCommand(action);
2453
+ } catch (error) {
2454
+ console.error('Error managing scheduler:', error.message);
2455
+ process.exit(1);
2456
+ }
2457
+ });
2458
+
2459
+ // Get log path (machine-readable)
2460
+ program
2461
+ .command('get-log-path <taskId>')
2462
+ .description('Output log file path for a task (machine-readable)')
2463
+ .action(async (taskId) => {
2464
+ try {
2465
+ const { getLogPath } = await import('../task-lib/commands/get-log-path.js');
2466
+ await getLogPath(taskId);
2467
+ } catch (error) {
2468
+ console.error('Error getting log path:', error.message);
2469
+ process.exit(1);
2470
+ }
2471
+ });
2472
+
2473
+ // Watch command - interactive TUI dashboard
2474
+ program
2475
+ .command('watch')
2476
+ .description('Interactive TUI to monitor clusters')
2477
+ .option('--refresh-rate <ms>', 'Refresh interval in milliseconds', '1000')
2478
+ .action(async (options) => {
2479
+ try {
2480
+ const TUI = require('../src/tui');
2481
+ const tui = new TUI({
2482
+ orchestrator: getOrchestrator(),
2483
+ refreshRate: parseInt(options.refreshRate, 10),
2484
+ });
2485
+ await tui.start();
2486
+ } catch (error) {
2487
+ console.error('Error starting TUI:', error.message);
2488
+ process.exit(1);
2489
+ }
2490
+ });
2491
+
2492
+ // Settings management
2493
+ const settingsCmd = program.command('settings').description('Manage zeroshot settings');
2494
+
2495
+ settingsCmd
2496
+ .command('list')
2497
+ .description('Show all settings')
2498
+ .action(() => {
2499
+ const settings = loadSettings();
2500
+ console.log(chalk.bold('\nCrew Settings:\n'));
2501
+ for (const [key, value] of Object.entries(settings)) {
2502
+ const isDefault = DEFAULT_SETTINGS[key] === value;
2503
+ const label = isDefault ? chalk.dim(key) : chalk.cyan(key);
2504
+ const val = isDefault ? chalk.dim(String(value)) : chalk.white(String(value));
2505
+ console.log(` ${label.padEnd(30)} ${val}`);
2506
+ }
2507
+ console.log('');
2508
+ });
2509
+
2510
+ settingsCmd
2511
+ .command('get <key>')
2512
+ .description('Get a setting value')
2513
+ .action((key) => {
2514
+ const settings = loadSettings();
2515
+ if (!(key in settings)) {
2516
+ console.error(chalk.red(`Unknown setting: ${key}`));
2517
+ console.log(chalk.dim('\nAvailable settings:'));
2518
+ Object.keys(DEFAULT_SETTINGS).forEach((k) => console.log(chalk.dim(` - ${k}`)));
2519
+ process.exit(1);
2520
+ }
2521
+ console.log(settings[key]);
2522
+ });
2523
+
2524
+ settingsCmd
2525
+ .command('set <key> <value>')
2526
+ .description('Set a setting value')
2527
+ .action((key, value) => {
2528
+ if (!(key in DEFAULT_SETTINGS)) {
2529
+ console.error(chalk.red(`Unknown setting: ${key}`));
2530
+ console.log(chalk.dim('\nAvailable settings:'));
2531
+ Object.keys(DEFAULT_SETTINGS).forEach((k) => console.log(chalk.dim(` - ${k}`)));
2532
+ process.exit(1);
2533
+ }
2534
+
2535
+ const settings = loadSettings();
2536
+
2537
+ // Type coercion
2538
+ let parsedValue;
2539
+ try {
2540
+ parsedValue = coerceValue(key, value);
2541
+ } catch (error) {
2542
+ console.error(chalk.red(error.message));
2543
+ process.exit(1);
2544
+ }
2545
+
2546
+ // Validation
2547
+ const validationError = validateSetting(key, parsedValue);
2548
+ if (validationError) {
2549
+ console.error(chalk.red(validationError));
2550
+ process.exit(1);
2551
+ }
2552
+
2553
+ settings[key] = parsedValue;
2554
+ saveSettings(settings);
2555
+ console.log(chalk.green(`✓ Set ${key} = ${parsedValue}`));
2556
+ });
2557
+
2558
+ settingsCmd
2559
+ .command('reset')
2560
+ .description('Reset all settings to defaults')
2561
+ .option('-y, --yes', 'Skip confirmation')
2562
+ .action((options) => {
2563
+ if (!options.yes) {
2564
+ const readline = require('readline');
2565
+ const rl = readline.createInterface({
2566
+ input: process.stdin,
2567
+ output: process.stdout,
2568
+ });
2569
+
2570
+ rl.question(chalk.yellow('Reset all settings to defaults? [y/N] '), (answer) => {
2571
+ rl.close();
2572
+ if (answer.toLowerCase() !== 'y') {
2573
+ console.log('Aborted.');
2574
+ return;
2575
+ }
2576
+ saveSettings(DEFAULT_SETTINGS);
2577
+ console.log(chalk.green('✓ Settings reset to defaults'));
2578
+ });
2579
+ } else {
2580
+ saveSettings(DEFAULT_SETTINGS);
2581
+ console.log(chalk.green('✓ Settings reset to defaults'));
2582
+ }
2583
+ });
2584
+
2585
+ // Add alias for settings list (just `zeroshot settings`)
2586
+ settingsCmd.action(() => {
2587
+ // Default action when no subcommand - show list
2588
+ const settings = loadSettings();
2589
+ console.log(chalk.bold('\nCrew Settings:\n'));
2590
+ for (const [key, value] of Object.entries(settings)) {
2591
+ const isDefault = DEFAULT_SETTINGS[key] === value;
2592
+ const label = isDefault ? chalk.dim(key) : chalk.cyan(key);
2593
+ const val = isDefault ? chalk.dim(String(value)) : chalk.white(String(value));
2594
+ console.log(` ${label.padEnd(30)} ${val}`);
2595
+ }
2596
+ console.log('');
2597
+ console.log(chalk.dim('Usage:'));
2598
+ console.log(chalk.dim(' zeroshot settings set <key> <value>'));
2599
+ console.log(chalk.dim(' zeroshot settings get <key>'));
2600
+ console.log(chalk.dim(' zeroshot settings reset'));
2601
+ console.log('');
2602
+ });
2603
+
2604
+ // Config visualization commands
2605
+ const configCmd = program.command('config').description('Manage and visualize cluster configs');
2606
+
2607
+ configCmd
2608
+ .command('list')
2609
+ .description('List available cluster configs')
2610
+ .action(() => {
2611
+ try {
2612
+ const configsDir = path.join(PACKAGE_ROOT, 'cluster-templates');
2613
+ const files = fs.readdirSync(configsDir).filter((f) => f.endsWith('.json'));
2614
+
2615
+ if (files.length === 0) {
2616
+ console.log(chalk.dim('No configs found in examples/'));
2617
+ return;
2618
+ }
2619
+
2620
+ console.log(chalk.bold('\nAvailable configs:\n'));
2621
+ for (const file of files) {
2622
+ const configPath = path.join(configsDir, file);
2623
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2624
+ const agentCount = config.agents?.length || 0;
2625
+ const name = file.replace('.json', '');
2626
+
2627
+ console.log(` ${chalk.cyan(name.padEnd(30))} ${chalk.dim(`${agentCount} agents`)}`);
2628
+ }
2629
+ console.log('');
2630
+ } catch (error) {
2631
+ console.error('Error listing configs:', error.message);
2632
+ process.exit(1);
2633
+ }
2634
+ });
2635
+
2636
+ configCmd
2637
+ .command('show <name>')
2638
+ .description('Visualize a cluster config')
2639
+ .action((name) => {
2640
+ try {
2641
+ // Support both with and without .json extension
2642
+ const configName = name.endsWith('.json') ? name : `${name}.json`;
2643
+ const configPath = path.join(PACKAGE_ROOT, 'cluster-templates', configName);
2644
+
2645
+ if (!fs.existsSync(configPath)) {
2646
+ console.error(chalk.red(`Config not found: ${configName}`));
2647
+ console.log(chalk.dim('\nAvailable configs:'));
2648
+ const files = fs
2649
+ .readdirSync(path.join(PACKAGE_ROOT, 'cluster-templates'))
2650
+ .filter((f) => f.endsWith('.json'));
2651
+ files.forEach((f) => console.log(chalk.dim(` - ${f.replace('.json', '')}`)));
2652
+ process.exit(1);
2653
+ }
2654
+
2655
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
2656
+
2657
+ // Header
2658
+ console.log('');
2659
+ console.log(chalk.bold.cyan('═'.repeat(80)));
2660
+ console.log(chalk.bold.cyan(` Config: ${name.replace('.json', '')}`));
2661
+ console.log(chalk.bold.cyan('═'.repeat(80)));
2662
+ console.log('');
2663
+
2664
+ // Agents section
2665
+ console.log(chalk.bold('Agents:\n'));
2666
+
2667
+ if (!config.agents || config.agents.length === 0) {
2668
+ console.log(chalk.dim(' No agents defined'));
2669
+ } else {
2670
+ for (const agent of config.agents) {
2671
+ const color = getColorForSender(agent.id);
2672
+ console.log(color.bold(` ${agent.id}`));
2673
+ console.log(chalk.dim(` Role: ${agent.role || 'none'}`));
2674
+
2675
+ if (agent.model) {
2676
+ console.log(chalk.dim(` Model: ${agent.model}`));
2677
+ }
2678
+
2679
+ if (agent.triggers && agent.triggers.length > 0) {
2680
+ // Triggers are objects with topic field
2681
+ const triggerTopics = agent.triggers
2682
+ .map((t) => (typeof t === 'string' ? t : t.topic))
2683
+ .filter(Boolean);
2684
+ console.log(chalk.dim(` Triggers: ${triggerTopics.join(', ')}`));
2685
+ } else {
2686
+ console.log(chalk.dim(` Triggers: none (manual only)`));
2687
+ }
2688
+
2689
+ console.log('');
2690
+ }
2691
+ }
2692
+
2693
+ // Message flow visualization
2694
+ if (config.agents && config.agents.length > 0) {
2695
+ console.log(chalk.bold('Message Flow:\n'));
2696
+
2697
+ // Build trigger map: topic -> [agents that listen]
2698
+ const triggerMap = new Map();
2699
+ for (const agent of config.agents) {
2700
+ if (agent.triggers) {
2701
+ for (const trigger of agent.triggers) {
2702
+ const topic = typeof trigger === 'string' ? trigger : trigger.topic;
2703
+ if (topic) {
2704
+ if (!triggerMap.has(topic)) {
2705
+ triggerMap.set(topic, []);
2706
+ }
2707
+ triggerMap.get(topic).push(agent.id);
2708
+ }
2709
+ }
2710
+ }
2711
+ }
2712
+
2713
+ if (triggerMap.size === 0) {
2714
+ console.log(chalk.dim(' No automatic triggers defined\n'));
2715
+ } else {
2716
+ for (const [topic, agents] of triggerMap) {
2717
+ console.log(
2718
+ ` ${chalk.yellow(topic)} ${chalk.dim('→')} ${agents.map((a) => getColorForSender(a)(a)).join(', ')}`
2719
+ );
2720
+ }
2721
+ console.log('');
2722
+ }
2723
+ }
2724
+
2725
+ console.log(chalk.bold.cyan('═'.repeat(80)));
2726
+ console.log('');
2727
+ } catch (error) {
2728
+ console.error('Error showing config:', error.message);
2729
+ process.exit(1);
2730
+ }
2731
+ });
2732
+
2733
+ configCmd
2734
+ .command('validate <configPath>')
2735
+ .description('Validate a cluster config for structural issues')
2736
+ .option('--strict', 'Treat warnings as errors')
2737
+ .option('--json', 'Output as JSON')
2738
+ .action((configPath, options) => {
2739
+ try {
2740
+ const { validateConfig, formatValidationResult } = require('../src/config-validator');
2741
+
2742
+ // Resolve path (support relative paths and built-in names)
2743
+ let fullPath;
2744
+ if (fs.existsSync(configPath)) {
2745
+ fullPath = path.resolve(configPath);
2746
+ } else {
2747
+ // Try examples directory
2748
+ const configName = configPath.endsWith('.json') ? configPath : `${configPath}.json`;
2749
+ fullPath = path.join(PACKAGE_ROOT, 'cluster-templates', configName);
2750
+ if (!fs.existsSync(fullPath)) {
2751
+ console.error(chalk.red(`Config not found: ${configPath}`));
2752
+ console.log(chalk.dim('\nAvailable built-in configs:'));
2753
+ const files = fs
2754
+ .readdirSync(path.join(PACKAGE_ROOT, 'cluster-templates'))
2755
+ .filter((f) => f.endsWith('.json'));
2756
+ files.forEach((f) => console.log(chalk.dim(` - ${f.replace('.json', '')}`)));
2757
+ process.exit(1);
2758
+ }
2759
+ }
2760
+
2761
+ // Load and validate
2762
+ const config = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
2763
+ const result = validateConfig(config);
2764
+
2765
+ // Apply strict mode
2766
+ if (options.strict && result.warnings.length > 0) {
2767
+ result.errors.push(...result.warnings.map((w) => `[strict] ${w}`));
2768
+ result.valid = false;
2769
+ }
2770
+
2771
+ // Output
2772
+ if (options.json) {
2773
+ console.log(JSON.stringify(result, null, 2));
2774
+ } else {
2775
+ console.log('');
2776
+ console.log(chalk.bold(`Validating: ${path.basename(fullPath)}`));
2777
+ console.log('');
2778
+ console.log(formatValidationResult(result));
2779
+ console.log('');
2780
+ }
2781
+
2782
+ // Exit code
2783
+ process.exit(result.valid ? 0 : 1);
2784
+ } catch (error) {
2785
+ if (error instanceof SyntaxError) {
2786
+ console.error(chalk.red(`Invalid JSON: ${error.message}`));
2787
+ } else {
2788
+ console.error(chalk.red(`Error: ${error.message}`));
2789
+ }
2790
+ process.exit(1);
2791
+ }
2792
+ });
2793
+
2794
+ // Helper function to keep the process alive for follow mode
2795
+ function keepProcessAlive(cleanupFn) {
2796
+ // Prevent Node.js from exiting by keeping the event loop active
2797
+ // Use setInterval with a long interval (1 hour) to minimize overhead
2798
+ const keepAliveInterval = setInterval(() => {}, 3600000);
2799
+
2800
+ // Handle graceful shutdown on Ctrl+C
2801
+ process.on('SIGINT', () => {
2802
+ clearInterval(keepAliveInterval);
2803
+ if (cleanupFn) cleanupFn();
2804
+ console.log('\n\nStopped following logs.');
2805
+ process.exit(0);
2806
+ });
2807
+
2808
+ // Also handle SIGTERM for graceful shutdown
2809
+ process.on('SIGTERM', () => {
2810
+ clearInterval(keepAliveInterval);
2811
+ if (cleanupFn) cleanupFn();
2812
+ process.exit(0);
2813
+ });
2814
+ }
2815
+
2816
+ // Tool icons for different tool types
2817
+ function getToolIcon(toolName) {
2818
+ const icons = {
2819
+ Read: '📖',
2820
+ Write: '📝',
2821
+ Edit: '✏️',
2822
+ Bash: '💻',
2823
+ Glob: '🔍',
2824
+ Grep: '🔎',
2825
+ WebFetch: '🌐',
2826
+ WebSearch: '🔎',
2827
+ Task: '🤖',
2828
+ TodoWrite: '📋',
2829
+ AskUserQuestion: '❓',
2830
+ };
2831
+ return icons[toolName] || '🔧';
2832
+ }
2833
+
2834
+ // Format tool call input for display
2835
+ function formatToolCall(toolName, input) {
2836
+ if (!input) return '';
2837
+
2838
+ switch (toolName) {
2839
+ case 'Bash':
2840
+ return input.command ? `$ ${input.command}` : '';
2841
+ case 'Read':
2842
+ return input.file_path ? input.file_path.split('/').slice(-2).join('/') : '';
2843
+ case 'Write':
2844
+ return input.file_path ? `→ ${input.file_path.split('/').slice(-2).join('/')}` : '';
2845
+ case 'Edit':
2846
+ return input.file_path ? input.file_path.split('/').slice(-2).join('/') : '';
2847
+ case 'Glob':
2848
+ return input.pattern || '';
2849
+ case 'Grep':
2850
+ return input.pattern ? `/${input.pattern}/` : '';
2851
+ case 'WebFetch':
2852
+ return input.url ? input.url.substring(0, 50) : '';
2853
+ case 'WebSearch':
2854
+ return input.query ? `"${input.query}"` : '';
2855
+ case 'Task':
2856
+ return input.description || '';
2857
+ case 'TodoWrite':
2858
+ if (input.todos && Array.isArray(input.todos)) {
2859
+ const statusCounts = {};
2860
+ input.todos.forEach((todo) => {
2861
+ statusCounts[todo.status] = (statusCounts[todo.status] || 0) + 1;
2862
+ });
2863
+ const parts = Object.entries(statusCounts).map(
2864
+ ([status, count]) => `${count} ${status.replace('_', ' ')}`
2865
+ );
2866
+ return `${input.todos.length} todo${input.todos.length === 1 ? '' : 's'} (${parts.join(', ')})`;
2867
+ }
2868
+ return '';
2869
+ case 'AskUserQuestion':
2870
+ if (input.questions && Array.isArray(input.questions)) {
2871
+ const q = input.questions[0];
2872
+ const preview = q.question.substring(0, 50);
2873
+ return input.questions.length > 1
2874
+ ? `${input.questions.length} questions: "${preview}..."`
2875
+ : `"${preview}${q.question.length > 50 ? '...' : ''}"`;
2876
+ }
2877
+ return '';
2878
+ default:
2879
+ // For unknown tools, show first key-value pair
2880
+ const keys = Object.keys(input);
2881
+ if (keys.length > 0) {
2882
+ const val = String(input[keys[0]]).substring(0, 40);
2883
+ return val.length < String(input[keys[0]]).length ? val + '...' : val;
2884
+ }
2885
+ return '';
2886
+ }
2887
+ }
2888
+
2889
+ // Format tool result for display
2890
+ function formatToolResult(content, isError, toolName, toolInput) {
2891
+ if (!content) return isError ? 'error' : 'done';
2892
+
2893
+ // For errors, show full message
2894
+ if (isError) {
2895
+ const firstLine = content.split('\n')[0].substring(0, 80);
2896
+ return chalk.red(firstLine);
2897
+ }
2898
+
2899
+ // For TodoWrite, show the actual todo items
2900
+ if (toolName === 'TodoWrite' && toolInput?.todos && Array.isArray(toolInput.todos)) {
2901
+ const todos = toolInput.todos;
2902
+ if (todos.length === 0) return chalk.dim('no todos');
2903
+ if (todos.length === 1) {
2904
+ const status =
2905
+ todos[0].status === 'completed' ? '✓' : todos[0].status === 'in_progress' ? '⧗' : '○';
2906
+ return chalk.dim(
2907
+ `${status} ${todos[0].content.substring(0, 50)}${todos[0].content.length > 50 ? '...' : ''}`
2908
+ );
2909
+ }
2910
+ // Multiple todos - show first one as preview
2911
+ const status =
2912
+ todos[0].status === 'completed' ? '✓' : todos[0].status === 'in_progress' ? '⧗' : '○';
2913
+ return chalk.dim(
2914
+ `${status} ${todos[0].content.substring(0, 40)}... (+${todos.length - 1} more)`
2915
+ );
2916
+ }
2917
+
2918
+ // For success, show summary
2919
+ const lines = content.split('\n').filter((l) => l.trim());
2920
+ if (lines.length === 0) return 'done';
2921
+ if (lines.length === 1) {
2922
+ const line = lines[0].substring(0, 60);
2923
+ return chalk.dim(line.length < lines[0].length ? line + '...' : line);
2924
+ }
2925
+ // Multiple lines - show count
2926
+ return chalk.dim(`${lines.length} lines`);
2927
+ }
2928
+
2929
+ // Helper function to get deterministic color for an agent/sender based on name hash
2930
+ // Uses djb2 hash algorithm for good distribution across color palette
2931
+ function getColorForSender(sender) {
2932
+ if (!agentColors.has(sender)) {
2933
+ let hash = 5381;
2934
+ for (let i = 0; i < sender.length; i++) {
2935
+ hash = (hash << 5) + hash + sender.charCodeAt(i);
2936
+ }
2937
+ const colorIndex = Math.abs(hash) % COLORS.length;
2938
+ agentColors.set(sender, COLORS[colorIndex]);
2939
+ }
2940
+ return agentColors.get(sender);
2941
+ }
2942
+
2943
+ // Track recently seen content to avoid duplicates
2944
+ const recentContentHashes = new Set();
2945
+ const MAX_RECENT_HASHES = 100;
2946
+
2947
+ // Track clusters that have already shown their NEW TASK header (suppress conductor re-publish)
2948
+ const shownNewTaskForCluster = new Set();
2949
+
2950
+ function hashContent(content) {
2951
+ // Simple hash for deduplication
2952
+ return content.substring(0, 200);
2953
+ }
2954
+
2955
+ function isDuplicate(content) {
2956
+ const hash = hashContent(content);
2957
+ if (recentContentHashes.has(hash)) {
2958
+ return true;
2959
+ }
2960
+ recentContentHashes.add(hash);
2961
+ // Prune old hashes
2962
+ if (recentContentHashes.size > MAX_RECENT_HASHES) {
2963
+ const arr = Array.from(recentContentHashes);
2964
+ recentContentHashes.clear();
2965
+ arr.slice(-50).forEach((h) => recentContentHashes.add(h));
2966
+ }
2967
+ return false;
2968
+ }
2969
+
2970
+ // Format task summary from ISSUE_OPENED message - truncated for display
2971
+ function formatTaskSummary(issueOpened, maxLen = 35) {
2972
+ const data = issueOpened.content?.data || {};
2973
+ const issueNum = data.issue_number || data.number;
2974
+ const title = data.title;
2975
+ const url = data.url || data.html_url;
2976
+
2977
+ // Prefer: #N: Short title
2978
+ if (issueNum && title) {
2979
+ const truncatedTitle = title.length > maxLen ? title.slice(0, maxLen - 3) + '...' : title;
2980
+ return `#${issueNum}: ${truncatedTitle}`;
2981
+ }
2982
+ if (issueNum) return `#${issueNum}`;
2983
+
2984
+ // Extract from URL
2985
+ if (url) {
2986
+ const match = url.match(/issues\/(\d+)/);
2987
+ if (match) return `#${match[1]}`;
2988
+ }
2989
+
2990
+ // Fallback: first meaningful line (for manual prompts)
2991
+ const text = issueOpened.content?.text || 'Task';
2992
+ const firstLine = text.split('\n').find((l) => l.trim() && !l.startsWith('#')) || 'Task';
2993
+ return firstLine.slice(0, maxLen) + (firstLine.length > maxLen ? '...' : '');
2994
+ }
2995
+
2996
+ // Set terminal title (works in most terminals)
2997
+ function setTerminalTitle(title) {
2998
+ // ESC ] 0 ; <title> BEL
2999
+ process.stdout.write(`\\x1b]0;${title}\x07`);
3000
+ }
3001
+
3002
+ // Restore terminal title on exit
3003
+ function restoreTerminalTitle() {
3004
+ // Reset to default (empty title lets terminal use its default)
3005
+ process.stdout.write('\\x1b]0;\x07');
3006
+ }
3007
+
3008
+ // Format markdown-style text for terminal display
3009
+ function formatMarkdownLine(line) {
3010
+ let formatted = line;
3011
+
3012
+ // Headers: ## Header -> bold cyan
3013
+ if (/^#{1,3}\s/.test(formatted)) {
3014
+ formatted = formatted.replace(/^#{1,3}\s*/, '');
3015
+ return chalk.bold.cyan(formatted);
3016
+ }
3017
+
3018
+ // Blockquotes: > text -> dim italic with bar
3019
+ if (/^>\s/.test(formatted)) {
3020
+ formatted = formatted.replace(/^>\s*/, '');
3021
+ return chalk.dim('│ ') + chalk.italic(formatted);
3022
+ }
3023
+
3024
+ // Numbered lists: 1. item -> yellow number
3025
+ const numMatch = formatted.match(/^(\d+)\.\s+(.*)$/);
3026
+ if (numMatch) {
3027
+ return chalk.yellow(numMatch[1] + '.') + ' ' + formatInlineMarkdown(numMatch[2]);
3028
+ }
3029
+
3030
+ // Bullet lists: - item or * item -> dim bullet
3031
+ const bulletMatch = formatted.match(/^[-*]\s+(.*)$/);
3032
+ if (bulletMatch) {
3033
+ return chalk.dim('•') + ' ' + formatInlineMarkdown(bulletMatch[1]);
3034
+ }
3035
+
3036
+ // Checkboxes: - [ ] or - [x]
3037
+ const checkMatch = formatted.match(/^[-*]\s+\[([ x])\]\s+(.*)$/i);
3038
+ if (checkMatch) {
3039
+ const checked = checkMatch[1].toLowerCase() === 'x';
3040
+ const icon = checked ? chalk.green('✓') : chalk.dim('○');
3041
+ return icon + ' ' + formatInlineMarkdown(checkMatch[2]);
3042
+ }
3043
+
3044
+ return formatInlineMarkdown(formatted);
3045
+ }
3046
+
3047
+ // Format inline markdown: **bold**, `code`
3048
+ function formatInlineMarkdown(text) {
3049
+ let result = text;
3050
+
3051
+ // Bold: **text** -> bold
3052
+ result = result.replace(/\*\*([^*]+)\*\*/g, (_, content) => chalk.bold(content));
3053
+
3054
+ // Inline code: `code` -> cyan dim
3055
+ result = result.replace(/`([^`]+)`/g, (_, content) => chalk.cyan.dim(content));
3056
+
3057
+ return result;
3058
+ }
3059
+
3060
+ // Line buffer per sender - tracks line state for prefix printing
3061
+ const lineBuffers = new Map();
3062
+
3063
+ // Track current tool call per sender - needed for matching tool results with calls
3064
+ const currentToolCall = new Map();
3065
+
3066
+ /**
3067
+ * Render messages to terminal-style output with ANSI colors (same as zeroshot logs)
3068
+ */
3069
+ function renderMessagesToTerminal(clusterId, messages) {
3070
+ const lines = [];
3071
+ const buffers = new Map(); // Line buffers per sender
3072
+ const toolCalls = new Map(); // Track tool calls per sender
3073
+
3074
+ const getBuffer = (sender) => {
3075
+ if (!buffers.has(sender)) {
3076
+ buffers.set(sender, { text: '', needsPrefix: true });
3077
+ }
3078
+ return buffers.get(sender);
3079
+ };
3080
+
3081
+ const flushBuffer = (sender, prefix) => {
3082
+ const buf = buffers.get(sender);
3083
+ if (buf && buf.text.trim()) {
3084
+ const textLines = buf.text.split('\n');
3085
+ for (const line of textLines) {
3086
+ if (line.trim()) {
3087
+ lines.push(`${prefix} ${formatMarkdownLine(line)}`);
3088
+ }
3089
+ }
3090
+ buf.text = '';
3091
+ buf.needsPrefix = true;
3092
+ }
3093
+ };
3094
+
3095
+ for (const msg of messages) {
3096
+ const timestamp = new Date(msg.timestamp).toLocaleTimeString('en-US', {
3097
+ hour12: false,
3098
+ });
3099
+ const color = getColorForSender(msg.sender);
3100
+ const prefix = color(`${msg.sender.padEnd(15)} |`);
3101
+
3102
+ // AGENT_LIFECYCLE
3103
+ if (msg.topic === 'AGENT_LIFECYCLE') {
3104
+ const data = msg.content?.data;
3105
+ const event = data?.event;
3106
+ let icon, eventText;
3107
+ switch (event) {
3108
+ case 'STARTED':
3109
+ icon = chalk.green('▶');
3110
+ const triggers = data.triggers?.join(', ') || 'none';
3111
+ eventText = `started (listening for: ${chalk.dim(triggers)})`;
3112
+ break;
3113
+ case 'TASK_STARTED':
3114
+ icon = chalk.yellow('⚡');
3115
+ eventText = `${chalk.cyan(data.triggeredBy)} → task #${data.iteration} (${chalk.dim(data.model)})`;
3116
+ break;
3117
+ case 'TASK_COMPLETED':
3118
+ icon = chalk.green('✓');
3119
+ eventText = `task #${data.iteration} completed`;
3120
+ break;
3121
+ default:
3122
+ icon = chalk.dim('•');
3123
+ eventText = event || 'unknown event';
3124
+ }
3125
+ lines.push(`${prefix} ${icon} ${eventText}`);
3126
+ continue;
3127
+ }
3128
+
3129
+ // ISSUE_OPENED
3130
+ if (msg.topic === 'ISSUE_OPENED') {
3131
+ lines.push('');
3132
+ lines.push(chalk.bold.blue('─'.repeat(80)));
3133
+ // Extract issue URL if present
3134
+ const issueData = msg.content?.data || {};
3135
+ const issueUrl = issueData.url || issueData.html_url;
3136
+ const issueTitle = issueData.title;
3137
+ const issueNum = issueData.issue_number || issueData.number;
3138
+
3139
+ if (issueUrl) {
3140
+ lines.push(
3141
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.blue('📋')} ${chalk.cyan(issueUrl)}`
3142
+ );
3143
+ if (issueTitle) {
3144
+ lines.push(`${prefix} ${chalk.white(issueTitle)}`);
3145
+ }
3146
+ } else if (issueNum) {
3147
+ lines.push(
3148
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.blue('📋')} Issue #${issueNum}`
3149
+ );
3150
+ if (issueTitle) {
3151
+ lines.push(`${prefix} ${chalk.white(issueTitle)}`);
3152
+ }
3153
+ } else {
3154
+ // Fallback: show first line of text only
3155
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.blue('📋 TASK')}`);
3156
+ if (msg.content?.text) {
3157
+ const firstLine = msg.content.text
3158
+ .split('\n')
3159
+ .find((l) => l.trim() && l.trim() !== '# Manual Input');
3160
+ if (firstLine) {
3161
+ lines.push(`${prefix} ${chalk.white(firstLine.slice(0, 100))}`);
3162
+ }
3163
+ }
3164
+ }
3165
+ lines.push(chalk.bold.blue('─'.repeat(80)));
3166
+ continue;
3167
+ }
3168
+
3169
+ // IMPLEMENTATION_READY
3170
+ if (msg.topic === 'IMPLEMENTATION_READY') {
3171
+ lines.push(
3172
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('✅ IMPLEMENTATION READY')}`
3173
+ );
3174
+ continue;
3175
+ }
3176
+
3177
+ // VALIDATION_RESULT
3178
+ if (msg.topic === 'VALIDATION_RESULT') {
3179
+ const data = msg.content?.data || {};
3180
+ const approved = data.approved === true || data.approved === 'true';
3181
+ const icon = approved ? chalk.green('✓ APPROVED') : chalk.red('✗ REJECTED');
3182
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.magenta('VALIDATION_RESULT')}`);
3183
+ lines.push(`${prefix} ${icon} ${chalk.dim(data.summary || '')}`);
3184
+ if (!approved) {
3185
+ let issues = data.issues || data.errors;
3186
+ if (typeof issues === 'string') {
3187
+ try {
3188
+ issues = JSON.parse(issues);
3189
+ } catch {
3190
+ issues = [];
3191
+ }
3192
+ }
3193
+ if (Array.isArray(issues)) {
3194
+ for (const issue of issues) {
3195
+ lines.push(`${prefix} ${chalk.red('•')} ${issue}`);
3196
+ }
3197
+ }
3198
+ }
3199
+ continue;
3200
+ }
3201
+
3202
+ // PR_CREATED
3203
+ if (msg.topic === 'PR_CREATED') {
3204
+ lines.push('');
3205
+ lines.push(chalk.bold.green('─'.repeat(80)));
3206
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('🔗 PR CREATED')}`);
3207
+ if (msg.content?.data?.pr_url) {
3208
+ lines.push(`${prefix} ${chalk.cyan(msg.content.data.pr_url)}`);
3209
+ }
3210
+ if (msg.content?.data?.merged) {
3211
+ lines.push(`${prefix} ${chalk.bold.cyan('✓ MERGED')}`);
3212
+ }
3213
+ lines.push(chalk.bold.green('─'.repeat(80)));
3214
+ continue;
3215
+ }
3216
+
3217
+ // CLUSTER_COMPLETE
3218
+ if (msg.topic === 'CLUSTER_COMPLETE') {
3219
+ lines.push('');
3220
+ lines.push(chalk.bold.green('─'.repeat(80)));
3221
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('✅ CLUSTER COMPLETE')}`);
3222
+ lines.push(chalk.bold.green('─'.repeat(80)));
3223
+ continue;
3224
+ }
3225
+
3226
+ // AGENT_ERROR
3227
+ if (msg.topic === 'AGENT_ERROR') {
3228
+ lines.push('');
3229
+ lines.push(chalk.bold.red('─'.repeat(80)));
3230
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.red('🔴 AGENT ERROR')}`);
3231
+ if (msg.content?.text) {
3232
+ lines.push(`${prefix} ${chalk.red(msg.content.text)}`);
3233
+ }
3234
+ lines.push(chalk.bold.red('─'.repeat(80)));
3235
+ continue;
3236
+ }
3237
+
3238
+ // AGENT_OUTPUT - parse streaming JSON
3239
+ if (msg.topic === 'AGENT_OUTPUT') {
3240
+ const content = msg.content?.data?.line || msg.content?.data?.chunk || msg.content?.text;
3241
+ if (!content || !content.trim()) continue;
3242
+
3243
+ const events = parseChunk(content);
3244
+ for (const event of events) {
3245
+ switch (event.type) {
3246
+ case 'text':
3247
+ const buf = getBuffer(msg.sender);
3248
+ buf.text += event.text;
3249
+ // Print complete lines
3250
+ while (buf.text.includes('\n')) {
3251
+ const idx = buf.text.indexOf('\n');
3252
+ const line = buf.text.slice(0, idx);
3253
+ buf.text = buf.text.slice(idx + 1);
3254
+ if (line.trim()) {
3255
+ lines.push(`${prefix} ${formatMarkdownLine(line)}`);
3256
+ }
3257
+ }
3258
+ break;
3259
+ case 'tool_call':
3260
+ flushBuffer(msg.sender, prefix);
3261
+ const icon = getToolIcon(event.toolName);
3262
+ const toolDesc = formatToolCall(event.toolName, event.input);
3263
+ lines.push(`${prefix} ${icon} ${chalk.cyan(event.toolName)} ${chalk.dim(toolDesc)}`);
3264
+ toolCalls.set(msg.sender, {
3265
+ toolName: event.toolName,
3266
+ input: event.input,
3267
+ });
3268
+ break;
3269
+ case 'tool_result':
3270
+ const status = event.isError ? chalk.red('✗') : chalk.green('✓');
3271
+ const tc = toolCalls.get(msg.sender);
3272
+ const resultDesc = formatToolResult(
3273
+ event.content,
3274
+ event.isError,
3275
+ tc?.toolName,
3276
+ tc?.input
3277
+ );
3278
+ lines.push(`${prefix} ${status} ${resultDesc}`);
3279
+ toolCalls.delete(msg.sender);
3280
+ break;
3281
+ }
3282
+ }
3283
+ continue;
3284
+ }
3285
+
3286
+ // Other topics - show topic name
3287
+ if (msg.topic && !['AGENT_OUTPUT', 'AGENT_LIFECYCLE'].includes(msg.topic)) {
3288
+ lines.push(`${prefix} ${chalk.gray(timestamp)} ${chalk.yellow(msg.topic)}`);
3289
+ }
3290
+ }
3291
+
3292
+ // Flush any remaining buffers
3293
+ for (const [sender, buf] of buffers) {
3294
+ if (buf.text.trim()) {
3295
+ const color = getColorForSender(sender);
3296
+ const prefix = color(`${sender.padEnd(15)} |`);
3297
+ for (const line of buf.text.split('\n')) {
3298
+ if (line.trim()) {
3299
+ lines.push(`${prefix} ${line}`);
3300
+ }
3301
+ }
3302
+ }
3303
+ }
3304
+
3305
+ return lines.join('\n');
3306
+ }
3307
+
3308
+ // Get terminal width for word wrapping
3309
+ function getTerminalWidth() {
3310
+ return process.stdout.columns || 100;
3311
+ }
3312
+
3313
+ // Word wrap text at terminal width, respecting word boundaries
3314
+ // Returns array of lines
3315
+ function wordWrap(text, maxWidth) {
3316
+ if (!text || maxWidth <= 0) return [text];
3317
+
3318
+ const words = text.split(/(\s+)/); // Keep whitespace as separate tokens
3319
+ const lines = [];
3320
+ let currentLine = '';
3321
+
3322
+ for (const word of words) {
3323
+ // If adding this word exceeds width, start new line
3324
+ if (currentLine.length + word.length > maxWidth && currentLine.trim()) {
3325
+ lines.push(currentLine.trimEnd());
3326
+ currentLine = word.trimStart(); // Don't start new line with whitespace
3327
+ } else {
3328
+ currentLine += word;
3329
+ }
3330
+ }
3331
+
3332
+ if (currentLine.trim()) {
3333
+ lines.push(currentLine.trimEnd());
3334
+ }
3335
+
3336
+ return lines.length > 0 ? lines : [''];
3337
+ }
3338
+
3339
+ function getLineBuffer(sender) {
3340
+ if (!lineBuffers.has(sender)) {
3341
+ // needsPrefix: true when at start of new line (need to print prefix)
3342
+ // pendingNewline: text written but no newline yet (need newline before next prefix)
3343
+ // textBuffer: accumulate text until we have a complete line
3344
+ lineBuffers.set(sender, {
3345
+ needsPrefix: true,
3346
+ pendingNewline: false,
3347
+ thinkingNeedsPrefix: true,
3348
+ thinkingPendingNewline: false,
3349
+ textBuffer: '', // NEW: buffer for accumulating text
3350
+ });
3351
+ }
3352
+ return lineBuffers.get(sender);
3353
+ }
3354
+
3355
+ // Accumulate text and print complete lines only
3356
+ // Word wrap long lines, aligning continuation with message column
3357
+ function accumulateText(prefix, sender, text) {
3358
+ if (!text) return;
3359
+ const buf = getLineBuffer(sender);
3360
+
3361
+ // Add incoming text to buffer
3362
+ buf.textBuffer += text;
3363
+
3364
+ // Calculate widths for word wrapping
3365
+ const prefixLen = chalk.reset(prefix).replace(/\\x1b\[[0-9;]*m/g, '').length + 1;
3366
+ const termWidth = getTerminalWidth();
3367
+ const contentWidth = Math.max(40, termWidth - prefixLen - 2);
3368
+ const continuationPrefix = ' '.repeat(prefixLen);
3369
+
3370
+ // Process complete lines (ending with \n)
3371
+ while (buf.textBuffer.includes('\n')) {
3372
+ const newlineIdx = buf.textBuffer.indexOf('\n');
3373
+ const completeLine = buf.textBuffer.slice(0, newlineIdx);
3374
+ buf.textBuffer = buf.textBuffer.slice(newlineIdx + 1);
3375
+
3376
+ // Word wrap and print the complete line
3377
+ const wrappedLines = wordWrap(completeLine, contentWidth);
3378
+ for (let i = 0; i < wrappedLines.length; i++) {
3379
+ const wrappedLine = wrappedLines[i];
3380
+
3381
+ // Print prefix (real or continuation)
3382
+ if (buf.needsPrefix) {
3383
+ process.stdout.write(`${prefix} `);
3384
+ buf.needsPrefix = false;
3385
+ } else if (i > 0) {
3386
+ process.stdout.write(`${continuationPrefix}`);
3387
+ }
3388
+
3389
+ if (wrappedLine.trim()) {
3390
+ process.stdout.write(formatInlineMarkdown(wrappedLine));
3391
+ }
3392
+
3393
+ // Newline after each wrapped segment
3394
+ if (i < wrappedLines.length - 1) {
3395
+ process.stdout.write('\n');
3396
+ }
3397
+ }
3398
+
3399
+ // Complete the line
3400
+ process.stdout.write('\n');
3401
+ buf.needsPrefix = true;
3402
+ buf.pendingNewline = false;
3403
+ }
3404
+
3405
+ // Mark that we have pending text (no newline yet)
3406
+ if (buf.textBuffer.length > 0) {
3407
+ buf.pendingNewline = true;
3408
+ }
3409
+ }
3410
+
3411
+ // Stream thinking text immediately with word wrapping
3412
+ function accumulateThinking(prefix, sender, text) {
3413
+ if (!text) return;
3414
+ const buf = getLineBuffer(sender);
3415
+
3416
+ // Calculate widths for word wrapping (same as accumulateText but with 💭 prefix)
3417
+ const prefixLen = chalk.reset(prefix).replace(/\\x1b\[[0-9;]*m/g, '').length + 4; // +4 for " 💭 "
3418
+ const termWidth = getTerminalWidth();
3419
+ const contentWidth = Math.max(40, termWidth - prefixLen - 2);
3420
+ const continuationPrefix = ' '.repeat(prefixLen);
3421
+
3422
+ let remaining = text;
3423
+ while (remaining.length > 0) {
3424
+ const newlineIdx = remaining.indexOf('\n');
3425
+ const rawLine = newlineIdx === -1 ? remaining : remaining.slice(0, newlineIdx);
3426
+
3427
+ const wrappedLines = wordWrap(rawLine, contentWidth);
3428
+
3429
+ for (let i = 0; i < wrappedLines.length; i++) {
3430
+ const wrappedLine = wrappedLines[i];
3431
+
3432
+ if (buf.thinkingNeedsPrefix) {
3433
+ process.stdout.write(`${prefix} ${chalk.dim.italic('💭 ')}`);
3434
+ buf.thinkingNeedsPrefix = false;
3435
+ } else if (i > 0) {
3436
+ process.stdout.write(`${continuationPrefix}`);
3437
+ }
3438
+
3439
+ if (wrappedLine.trim()) {
3440
+ process.stdout.write(chalk.dim.italic(wrappedLine));
3441
+ }
3442
+
3443
+ if (i < wrappedLines.length - 1) {
3444
+ process.stdout.write('\n');
3445
+ }
3446
+ }
3447
+
3448
+ if (newlineIdx === -1) {
3449
+ buf.thinkingPendingNewline = true;
3450
+ break;
3451
+ } else {
3452
+ process.stdout.write('\n');
3453
+ buf.thinkingNeedsPrefix = true;
3454
+ buf.thinkingPendingNewline = false;
3455
+ remaining = remaining.slice(newlineIdx + 1);
3456
+ }
3457
+ }
3458
+ }
3459
+
3460
+ // Flush pending content - just add newline if we have pending text
3461
+ function flushLineBuffer(prefix, sender) {
3462
+ const buf = lineBuffers.get(sender);
3463
+ if (!buf) return;
3464
+
3465
+ // CRITICAL: Flush any remaining text in textBuffer (text without trailing newline)
3466
+ if (buf.textBuffer && buf.textBuffer.length > 0) {
3467
+ // Calculate widths for word wrapping (same as accumulateText)
3468
+ const prefixLen = chalk.reset(prefix).replace(/\\x1b\[[0-9;]*m/g, '').length + 1;
3469
+ const termWidth = getTerminalWidth();
3470
+ const contentWidth = Math.max(40, termWidth - prefixLen - 2);
3471
+ const continuationPrefix = ' '.repeat(prefixLen);
3472
+
3473
+ const wrappedLines = wordWrap(buf.textBuffer, contentWidth);
3474
+ for (let i = 0; i < wrappedLines.length; i++) {
3475
+ const wrappedLine = wrappedLines[i];
3476
+
3477
+ if (buf.needsPrefix) {
3478
+ process.stdout.write(`${prefix} `);
3479
+ buf.needsPrefix = false;
3480
+ } else if (i > 0) {
3481
+ process.stdout.write(`${continuationPrefix}`);
3482
+ }
3483
+
3484
+ if (wrappedLine.trim()) {
3485
+ process.stdout.write(formatInlineMarkdown(wrappedLine));
3486
+ }
3487
+
3488
+ if (i < wrappedLines.length - 1) {
3489
+ process.stdout.write('\n');
3490
+ }
3491
+ }
3492
+
3493
+ // Clear the buffer
3494
+ buf.textBuffer = '';
3495
+ buf.pendingNewline = true; // Mark that we need a newline before next prefix
3496
+ }
3497
+
3498
+ if (buf.pendingNewline) {
3499
+ process.stdout.write('\n');
3500
+ buf.needsPrefix = true;
3501
+ buf.pendingNewline = false;
3502
+ }
3503
+ if (buf.thinkingPendingNewline) {
3504
+ process.stdout.write('\n');
3505
+ buf.thinkingNeedsPrefix = true;
3506
+ buf.thinkingPendingNewline = false;
3507
+ }
3508
+ }
3509
+
3510
+ // Lines to filter out (noise, metadata, errors)
3511
+ const FILTERED_PATTERNS = [
3512
+ // ct internal output
3513
+ /^--- Following log/,
3514
+ /--- Following logs/,
3515
+ /Ctrl\+C to stop/,
3516
+ /^=== Claude Task:/,
3517
+ /^Started:/,
3518
+ /^Finished:/,
3519
+ /^Exit code:/,
3520
+ /^CWD:/,
3521
+ /^={50}$/,
3522
+ // Agent context metadata
3523
+ /^Prompt: You are agent/,
3524
+ /^Iteration:/,
3525
+ /^## Triggering Message/,
3526
+ /^## Messages from topic:/,
3527
+ /^## Instructions/,
3528
+ /^## Output Format/,
3529
+ /^Topic: [A-Z_]+$/,
3530
+ /^Sender:/,
3531
+ /^Data: \{/,
3532
+ /^"issue_number"/,
3533
+ /^"title"/,
3534
+ /^"commit"/,
3535
+ /^\[\d{4}-\d{2}-\d{2}T/, // ISO timestamps
3536
+ /^# Manual Input$/,
3537
+ // Task errors (internal)
3538
+ /^Task not found:/,
3539
+ // JSON fragments
3540
+ /^\s*\{$/,
3541
+ /^\s*\}$/,
3542
+ /^\s*"[a-z_]+":.*,?\s*$/,
3543
+ // Template variables (unresolved)
3544
+ /\{\{[a-z.]+\}\}/,
3545
+ ];
3546
+
3547
+ // Helper function to print a message (docker-compose style with colors)
3548
+ function printMessage(msg, showClusterId = false, watchMode = false, isActive = true) {
3549
+ const timestamp = new Date(msg.timestamp).toLocaleTimeString('en-US', {
3550
+ hour12: false,
3551
+ });
3552
+ // Use dim colors for inactive clusters
3553
+ const color = isActive ? getColorForSender(msg.sender) : chalk.dim;
3554
+
3555
+ // Build prefix with optional cluster ID and model (sender_model is set by agent-wrapper._publish)
3556
+ let senderLabel = msg.sender;
3557
+ if (showClusterId && msg.cluster_id) {
3558
+ senderLabel = `${msg.cluster_id}/${msg.sender}`;
3559
+ }
3560
+ const modelSuffix = msg.sender_model ? chalk.dim(` [${msg.sender_model}]`) : '';
3561
+ const prefix = color(`${senderLabel.padEnd(showClusterId ? 25 : 15)} |`) + modelSuffix;
3562
+
3563
+ // Watch mode: ONLY show high-level business events in human-readable format
3564
+ if (watchMode) {
3565
+ // Skip low-level topics (too noisy for watch mode)
3566
+ if (msg.topic === 'AGENT_OUTPUT' || msg.topic === 'AGENT_LIFECYCLE') {
3567
+ return;
3568
+ }
3569
+
3570
+ // Clear status line, print message, will be redrawn by status interval
3571
+ process.stdout.write('\r' + ' '.repeat(120) + '\r');
3572
+
3573
+ // Simplified prefix for watch mode: just cluster ID (white = alive, grey = dead)
3574
+ const clusterPrefix = isActive
3575
+ ? chalk.white(`${msg.cluster_id.padEnd(20)} |`)
3576
+ : chalk.dim(`${msg.cluster_id.padEnd(20)} |`);
3577
+
3578
+ // AGENT_ERROR: Show errors prominently
3579
+ if (msg.topic === 'AGENT_ERROR') {
3580
+ const errorMsg = `${msg.sender} ${chalk.bold.red('ERROR')}`;
3581
+ console.log(`${clusterPrefix} ${errorMsg}`);
3582
+ if (msg.content?.text) {
3583
+ console.log(`${clusterPrefix} ${chalk.red(msg.content.text)}`);
3584
+ }
3585
+ return;
3586
+ }
3587
+
3588
+ // Human-readable event descriptions (with consistent agent colors)
3589
+ const agentColor = getColorForSender(msg.sender);
3590
+ const agentName = agentColor(msg.sender);
3591
+ let eventText = '';
3592
+
3593
+ switch (msg.topic) {
3594
+ case 'ISSUE_OPENED':
3595
+ const issueNum = msg.content?.data?.issue_number || '';
3596
+ const title = msg.content?.data?.title || '';
3597
+ const prompt = msg.content?.data?.prompt || msg.content?.text || '';
3598
+
3599
+ // If it's manual input, show the prompt instead of "Manual Input"
3600
+ const taskDesc = title === 'Manual Input' && prompt ? prompt : title;
3601
+ const truncatedDesc =
3602
+ taskDesc && taskDesc.length > 60 ? taskDesc.substring(0, 60) + '...' : taskDesc;
3603
+
3604
+ eventText = `Started ${issueNum ? `#${issueNum}` : 'task'}${truncatedDesc ? chalk.dim(` - ${truncatedDesc}`) : ''}`;
3605
+ break;
3606
+
3607
+ case 'IMPLEMENTATION_READY':
3608
+ eventText = `${agentName} completed implementation`;
3609
+ break;
3610
+
3611
+ case 'VALIDATION_RESULT':
3612
+ const data = msg.content?.data;
3613
+ const approved = data?.approved === 'true' || data?.approved === true;
3614
+ const status = approved ? chalk.green('APPROVED') : chalk.red('REJECTED');
3615
+ eventText = `${agentName} ${status}`;
3616
+ if (data?.summary && !approved) {
3617
+ eventText += chalk.dim(` - ${data.summary}`);
3618
+ }
3619
+ console.log(`${clusterPrefix} ${eventText}`);
3620
+
3621
+ // Show rejection details (character counts only)
3622
+ if (!approved) {
3623
+ let errors = data.errors;
3624
+ let issues = data.issues;
3625
+
3626
+ if (typeof errors === 'string') {
3627
+ try {
3628
+ errors = JSON.parse(errors);
3629
+ } catch {
3630
+ errors = [];
3631
+ }
3632
+ }
3633
+ if (typeof issues === 'string') {
3634
+ try {
3635
+ issues = JSON.parse(issues);
3636
+ } catch {
3637
+ issues = [];
3638
+ }
3639
+ }
3640
+
3641
+ // Calculate total character counts
3642
+ let errorsCharCount = 0;
3643
+ let issuesCharCount = 0;
3644
+
3645
+ if (Array.isArray(errors) && errors.length > 0) {
3646
+ errorsCharCount = JSON.stringify(errors).length;
3647
+ console.log(
3648
+ `${clusterPrefix} ${chalk.red('•')} ${errors.length} error${errors.length > 1 ? 's' : ''} (${errorsCharCount} chars)`
3649
+ );
3650
+ }
3651
+
3652
+ if (Array.isArray(issues) && issues.length > 0) {
3653
+ issuesCharCount = JSON.stringify(issues).length;
3654
+ console.log(
3655
+ `${clusterPrefix} ${chalk.yellow('•')} ${issues.length} issue${issues.length > 1 ? 's' : ''} (${issuesCharCount} chars)`
3656
+ );
3657
+ }
3658
+ }
3659
+ return;
3660
+
3661
+ case 'PR_CREATED':
3662
+ const prNum = msg.content?.data?.pr_number || '';
3663
+ eventText = `${agentName} created PR${prNum ? ` #${prNum}` : ''}`;
3664
+ break;
3665
+
3666
+ case 'PR_MERGED':
3667
+ eventText = `${agentName} merged PR`;
3668
+ break;
3669
+
3670
+ default:
3671
+ // Fallback for unknown topics
3672
+ eventText = `${agentName} ${msg.topic.toLowerCase().replace(/_/g, ' ')}`;
3673
+ }
3674
+
3675
+ console.log(`${clusterPrefix} ${eventText}`);
3676
+ return;
3677
+ }
3678
+
3679
+ // AGENT_LIFECYCLE: Show agent start/trigger/task events
3680
+ if (msg.topic === 'AGENT_LIFECYCLE') {
3681
+ const data = msg.content?.data;
3682
+ const event = data?.event;
3683
+
3684
+ let icon, eventText;
3685
+ switch (event) {
3686
+ case 'STARTED':
3687
+ icon = chalk.green('▶');
3688
+ const triggers = data.triggers?.join(', ') || 'none';
3689
+ eventText = `started (listening for: ${chalk.dim(triggers)})`;
3690
+ break;
3691
+ case 'TASK_STARTED':
3692
+ icon = chalk.yellow('⚡');
3693
+ eventText = `${chalk.cyan(data.triggeredBy)} → task #${data.iteration} (${chalk.dim(data.model)})`;
3694
+ break;
3695
+ case 'TASK_COMPLETED':
3696
+ icon = chalk.green('✓');
3697
+ eventText = `task #${data.iteration} completed`;
3698
+ break;
3699
+ default:
3700
+ icon = chalk.dim('•');
3701
+ eventText = event || 'unknown event';
3702
+ }
3703
+
3704
+ console.log(`${prefix} ${icon} ${eventText}`);
3705
+ return;
3706
+ }
3707
+
3708
+ // AGENT_OUTPUT: parse streaming JSON and display all content
3709
+ if (msg.topic === 'AGENT_OUTPUT') {
3710
+ // Support both old 'chunk' and new 'line' formats
3711
+ const content = msg.content?.data?.line || msg.content?.data?.chunk || msg.content?.text;
3712
+ if (!content || !content.trim()) return;
3713
+
3714
+ // Parse streaming JSON events using the parser
3715
+ const events = parseChunk(content);
3716
+
3717
+ for (const event of events) {
3718
+ switch (event.type) {
3719
+ case 'text':
3720
+ // Accumulate text, print complete lines
3721
+ accumulateText(prefix, msg.sender, event.text);
3722
+ break;
3723
+
3724
+ case 'thinking':
3725
+ case 'thinking_start':
3726
+ // Accumulate thinking, print complete lines
3727
+ if (event.text) {
3728
+ accumulateThinking(prefix, msg.sender, event.text);
3729
+ } else if (event.type === 'thinking_start') {
3730
+ console.log(`${prefix} ${chalk.dim.italic('💭 thinking...')}`);
3731
+ }
3732
+ break;
3733
+
3734
+ case 'tool_start':
3735
+ // Flush pending text before tool - don't print, tool_call has details
3736
+ flushLineBuffer(prefix, msg.sender);
3737
+ break;
3738
+
3739
+ case 'tool_call':
3740
+ // Flush pending text before tool
3741
+ flushLineBuffer(prefix, msg.sender);
3742
+ const icon = getToolIcon(event.toolName);
3743
+ const toolDesc = formatToolCall(event.toolName, event.input);
3744
+ console.log(`${prefix} ${icon} ${chalk.cyan(event.toolName)} ${chalk.dim(toolDesc)}`);
3745
+ // Store tool call info for matching with result
3746
+ currentToolCall.set(msg.sender, {
3747
+ toolName: event.toolName,
3748
+ input: event.input,
3749
+ });
3750
+ break;
3751
+
3752
+ case 'tool_input':
3753
+ // Streaming tool input JSON - skip (shown in tool_call)
3754
+ break;
3755
+
3756
+ case 'tool_result':
3757
+ const status = event.isError ? chalk.red('✗') : chalk.green('✓');
3758
+ // Get stored tool call info for better formatting
3759
+ const toolCall = currentToolCall.get(msg.sender);
3760
+ const resultDesc = formatToolResult(
3761
+ event.content,
3762
+ event.isError,
3763
+ toolCall?.toolName,
3764
+ toolCall?.input
3765
+ );
3766
+ console.log(`${prefix} ${status} ${resultDesc}`);
3767
+ // Clear stored tool call after result
3768
+ currentToolCall.delete(msg.sender);
3769
+ break;
3770
+
3771
+ case 'result':
3772
+ // Flush remaining buffer before result
3773
+ flushLineBuffer(prefix, msg.sender);
3774
+ // Final result - only show errors (success text already streamed)
3775
+ if (!event.success) {
3776
+ console.log(`${prefix} ${chalk.bold.red('✗ Error:')} ${event.error || 'Task failed'}`);
3777
+ }
3778
+ break;
3779
+
3780
+ case 'block_end':
3781
+ // Block ended - skip
3782
+ break;
3783
+
3784
+ default:
3785
+ // Unknown event type - skip
3786
+ break;
3787
+ }
3788
+ }
3789
+
3790
+ // If no JSON events parsed, fall through to text filtering
3791
+ if (events.length === 0) {
3792
+ const lines = content.split('\n');
3793
+ for (const line of lines) {
3794
+ const trimmed = line.trim();
3795
+ if (!trimmed) continue;
3796
+
3797
+ // Check against filtered patterns
3798
+ let shouldSkip = false;
3799
+ for (const pattern of FILTERED_PATTERNS) {
3800
+ if (pattern.test(trimmed)) {
3801
+ shouldSkip = true;
3802
+ break;
3803
+ }
3804
+ }
3805
+ if (shouldSkip) continue;
3806
+
3807
+ // Skip JSON-like content
3808
+ if (
3809
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
3810
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
3811
+ )
3812
+ continue;
3813
+
3814
+ // Skip duplicate content
3815
+ if (isDuplicate(trimmed)) continue;
3816
+
3817
+ console.log(`${prefix} ${line}`);
3818
+ }
3819
+ }
3820
+ return;
3821
+ }
3822
+
3823
+ // AGENT_ERROR: Show errors with visual prominence
3824
+ if (msg.topic === 'AGENT_ERROR') {
3825
+ console.log(''); // Blank line before error
3826
+ console.log(chalk.bold.red(`${'─'.repeat(60)}`));
3827
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.red('🔴 AGENT ERROR')}`);
3828
+ if (msg.content?.text) {
3829
+ console.log(`${prefix} ${chalk.red(msg.content.text)}`);
3830
+ }
3831
+ if (msg.content?.data?.stack) {
3832
+ // Show first 5 lines of stack trace
3833
+ const stackLines = msg.content.data.stack.split('\n').slice(0, 5);
3834
+ for (const line of stackLines) {
3835
+ if (line.trim()) {
3836
+ console.log(`${prefix} ${chalk.dim(line)}`);
3837
+ }
3838
+ }
3839
+ }
3840
+ console.log(chalk.bold.red(`${'─'.repeat(60)}`));
3841
+ return;
3842
+ }
3843
+
3844
+ // ISSUE_OPENED: Show as task header with visual separation
3845
+ // Skip duplicate - conductor re-publishes after spawning agents (same task, confusing UX)
3846
+ if (msg.topic === 'ISSUE_OPENED') {
3847
+ if (shownNewTaskForCluster.has(msg.cluster_id)) {
3848
+ return; // Already shown NEW TASK for this cluster
3849
+ }
3850
+ shownNewTaskForCluster.add(msg.cluster_id);
3851
+
3852
+ console.log(''); // Blank line before new task
3853
+ console.log(chalk.bold.blue(`${'─'.repeat(60)}`));
3854
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.blue('📋 NEW TASK')}`);
3855
+ if (msg.content?.text) {
3856
+ // Show task description (first 3 lines max)
3857
+ const lines = msg.content.text.split('\n').slice(0, 3);
3858
+ for (const line of lines) {
3859
+ if (line.trim() && line.trim() !== '# Manual Input') {
3860
+ console.log(`${prefix} ${chalk.white(line)}`);
3861
+ }
3862
+ }
3863
+ }
3864
+ console.log(chalk.bold.blue(`${'─'.repeat(60)}`));
3865
+ return;
3866
+ }
3867
+
3868
+ // IMPLEMENTATION_READY: milestone marker
3869
+ if (msg.topic === 'IMPLEMENTATION_READY') {
3870
+ console.log(
3871
+ `${prefix} ${chalk.gray(timestamp)} ${chalk.bold.yellow('✅ IMPLEMENTATION READY')}`
3872
+ );
3873
+ if (msg.content?.data?.commit) {
3874
+ console.log(
3875
+ `${prefix} ${chalk.gray('Commit:')} ${chalk.cyan(msg.content.data.commit.substring(0, 8))}`
3876
+ );
3877
+ }
3878
+ return;
3879
+ }
3880
+
3881
+ // VALIDATION_RESULT: show approval/rejection clearly
3882
+ if (msg.topic === 'VALIDATION_RESULT') {
3883
+ const data = msg.content?.data || {};
3884
+ const approved = data.approved === true || data.approved === 'true';
3885
+ const status = approved ? chalk.bold.green('✓ APPROVED') : chalk.bold.red('✗ REJECTED');
3886
+
3887
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${status}`);
3888
+
3889
+ // Show summary if present and not a template variable
3890
+ if (msg.content?.text && !msg.content.text.includes('{{')) {
3891
+ console.log(`${prefix} ${msg.content.text.substring(0, 100)}`);
3892
+ }
3893
+
3894
+ // Show full JSON data structure
3895
+ console.log(
3896
+ `${prefix} ${chalk.dim(JSON.stringify(data, null, 2).split('\n').join(`\n${prefix} `))}`
3897
+ );
3898
+
3899
+ // Show errors/issues if any
3900
+ if (data.errors && Array.isArray(data.errors) && data.errors.length > 0) {
3901
+ console.log(`${prefix} ${chalk.red('Errors:')}`);
3902
+ data.errors.forEach((err) => {
3903
+ if (err && typeof err === 'string') {
3904
+ console.log(`${prefix} - ${err}`);
3905
+ }
3906
+ });
3907
+ }
3908
+
3909
+ if (data.issues && Array.isArray(data.issues) && data.issues.length > 0) {
3910
+ console.log(`${prefix} ${chalk.yellow('Issues:')}`);
3911
+ data.issues.forEach((issue) => {
3912
+ if (issue && typeof issue === 'string') {
3913
+ console.log(`${prefix} - ${issue}`);
3914
+ }
3915
+ });
3916
+ }
3917
+ return;
3918
+ }
3919
+
3920
+ // PR_CREATED: show PR created banner
3921
+ if (msg.topic === 'PR_CREATED') {
3922
+ console.log('');
3923
+ console.log(chalk.bold.green(`${'─'.repeat(60)}`));
3924
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('🔗 PR CREATED')}`);
3925
+ if (msg.content?.data?.pr_url) {
3926
+ console.log(`${prefix} ${chalk.cyan(msg.content.data.pr_url)}`);
3927
+ }
3928
+ if (msg.content?.data?.merged) {
3929
+ console.log(`${prefix} ${chalk.bold.cyan('✓ MERGED')}`);
3930
+ }
3931
+ console.log(chalk.bold.green(`${'─'.repeat(60)}`));
3932
+ return;
3933
+ }
3934
+
3935
+ // CLUSTER_COMPLETE: show completion banner
3936
+ if (msg.topic === 'CLUSTER_COMPLETE') {
3937
+ console.log('');
3938
+ console.log(chalk.bold.green(`${'═'.repeat(60)}`));
3939
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${chalk.bold.green('✅ CLUSTER COMPLETED')}`);
3940
+ if (msg.content?.text) {
3941
+ console.log(`${prefix} ${chalk.white(msg.content.text)}`);
3942
+ }
3943
+ if (msg.content?.data?.reason) {
3944
+ console.log(`${prefix} ${chalk.dim('Reason:')} ${msg.content.data.reason}`);
3945
+ }
3946
+ console.log(chalk.bold.green(`${'═'.repeat(60)}`));
3947
+ console.log('');
3948
+ return;
3949
+ }
3950
+
3951
+ // Other topics: generic display
3952
+ const topicColor = msg.topic.startsWith('TASK_')
3953
+ ? chalk.bold.green
3954
+ : msg.topic.startsWith('ERROR')
3955
+ ? chalk.bold.red
3956
+ : chalk.bold;
3957
+
3958
+ console.log(`${prefix} ${chalk.gray(timestamp)} ${topicColor(msg.topic)}`);
3959
+
3960
+ // Show text content (skip template variables)
3961
+ if (msg.content?.text && !msg.content.text.includes('{{')) {
3962
+ const lines = msg.content.text.split('\n');
3963
+ for (const line of lines) {
3964
+ if (line.trim()) {
3965
+ console.log(`${prefix} ${line}`);
3966
+ }
3967
+ }
3968
+ }
3969
+ }
3970
+
3971
+ // Default command handling: if first arg doesn't match a known command, treat it as 'run'
3972
+ // This allows `zeroshot "task"` to work the same as `zeroshot run "task"`
3973
+ const args = process.argv.slice(2);
3974
+ if (args.length > 0) {
3975
+ const firstArg = args[0];
3976
+
3977
+ // Skip if it's a flag/option (starts with -)
3978
+ // Skip if it's --help or --version (these are handled by commander)
3979
+ if (!firstArg.startsWith('-')) {
3980
+ // Get all registered command names
3981
+ const commandNames = program.commands.map((cmd) => cmd.name());
3982
+
3983
+ // If first arg is not a known command, prepend 'run'
3984
+ if (!commandNames.includes(firstArg)) {
3985
+ process.argv.splice(2, 0, 'run');
3986
+ }
3987
+ }
3988
+ }
3989
+
3990
+ program.parse();