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