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