@automagik/genie 0.260202.1607 β 0.260202.1833
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/dist/claudio.js +5 -5
- package/dist/genie.js +6 -6
- package/dist/term.js +115 -53
- package/package.json +1 -1
- package/src/lib/orchestrator/completion.ts +392 -0
- package/src/lib/orchestrator/event-monitor.ts +442 -0
- package/src/lib/orchestrator/index.ts +12 -0
- package/src/lib/orchestrator/patterns.ts +277 -0
- package/src/lib/orchestrator/state-detector.ts +339 -0
- package/src/lib/version.ts +1 -1
- package/src/lib/worker-registry.ts +229 -0
- package/src/term-commands/close.ts +221 -0
- package/src/term-commands/kill.ts +143 -0
- package/src/term-commands/orchestrate.ts +844 -0
- package/src/term-commands/work.ts +415 -0
- package/src/term-commands/workers.ts +264 -0
- package/src/term.ts +189 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work command - Spawn worker bound to beads issue
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* term work <bd-id> - Work on specific beads issue
|
|
6
|
+
* term work next - Work on next ready issue
|
|
7
|
+
* term work wish - Create a new wish (deferred)
|
|
8
|
+
*
|
|
9
|
+
* Options:
|
|
10
|
+
* --no-worktree - Use shared repo instead of worktree
|
|
11
|
+
* -s, --session <name> - Target tmux session
|
|
12
|
+
* --focus - Focus the worker pane (default: true)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { $ } from 'bun';
|
|
16
|
+
import * as tmux from '../lib/tmux.js';
|
|
17
|
+
import * as registry from '../lib/worker-registry.js';
|
|
18
|
+
import { WorktreeManager } from '../lib/worktree.js';
|
|
19
|
+
import { EventMonitor, detectState } from '../lib/orchestrator/index.js';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
export interface WorkOptions {
|
|
28
|
+
noWorktree?: boolean;
|
|
29
|
+
session?: string;
|
|
30
|
+
focus?: boolean;
|
|
31
|
+
prompt?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface BeadsIssue {
|
|
35
|
+
id: string;
|
|
36
|
+
title: string;
|
|
37
|
+
status: string;
|
|
38
|
+
blockedBy?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Configuration
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
const WORKTREE_BASE = join(homedir(), '.local', 'share', 'term', 'worktrees');
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Helper Functions
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Run bd command and parse output
|
|
53
|
+
*/
|
|
54
|
+
async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> {
|
|
55
|
+
try {
|
|
56
|
+
const result = await $`bd ${args}`.quiet();
|
|
57
|
+
return { stdout: result.stdout.toString().trim(), exitCode: 0 };
|
|
58
|
+
} catch (error: any) {
|
|
59
|
+
return { stdout: error.stdout?.toString().trim() || '', exitCode: error.exitCode || 1 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get a beads issue by ID
|
|
65
|
+
*/
|
|
66
|
+
async function getBeadsIssue(id: string): Promise<BeadsIssue | null> {
|
|
67
|
+
const { stdout, exitCode } = await runBd(['show', id, '--json']);
|
|
68
|
+
if (exitCode !== 0 || !stdout) return null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const issue = JSON.parse(stdout);
|
|
72
|
+
return {
|
|
73
|
+
id: issue.id,
|
|
74
|
+
title: issue.title || issue.description?.substring(0, 50) || 'Untitled',
|
|
75
|
+
status: issue.status,
|
|
76
|
+
blockedBy: issue.blockedBy || [],
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get next ready beads issue
|
|
85
|
+
*/
|
|
86
|
+
async function getNextReadyIssue(): Promise<BeadsIssue | null> {
|
|
87
|
+
const { stdout, exitCode } = await runBd(['ready', '--json']);
|
|
88
|
+
if (exitCode !== 0 || !stdout) return null;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const issues = JSON.parse(stdout);
|
|
92
|
+
if (Array.isArray(issues) && issues.length > 0) {
|
|
93
|
+
const issue = issues[0];
|
|
94
|
+
return {
|
|
95
|
+
id: issue.id,
|
|
96
|
+
title: issue.title || issue.description?.substring(0, 50) || 'Untitled',
|
|
97
|
+
status: issue.status,
|
|
98
|
+
blockedBy: issue.blockedBy || [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
} catch {
|
|
103
|
+
// Try parsing as single issue or line-based format
|
|
104
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
105
|
+
if (lines.length > 0) {
|
|
106
|
+
// Extract ID from first line (format: "bd-1: title" or just "bd-1")
|
|
107
|
+
const match = lines[0].match(/^(bd-\d+)/);
|
|
108
|
+
if (match) {
|
|
109
|
+
return getBeadsIssue(match[1]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mark beads issue as in_progress
|
|
118
|
+
*/
|
|
119
|
+
async function claimIssue(id: string): Promise<boolean> {
|
|
120
|
+
const { exitCode } = await runBd(['update', id, '--status', 'in_progress']);
|
|
121
|
+
return exitCode === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Get current tmux session name
|
|
126
|
+
*/
|
|
127
|
+
async function getCurrentSession(): Promise<string | null> {
|
|
128
|
+
try {
|
|
129
|
+
const result = await tmux.executeTmux(`display-message -p '#{session_name}'`);
|
|
130
|
+
return result.trim() || null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create worktree for worker
|
|
138
|
+
*/
|
|
139
|
+
async function createWorktree(
|
|
140
|
+
taskId: string,
|
|
141
|
+
repoPath: string
|
|
142
|
+
): Promise<string | null> {
|
|
143
|
+
try {
|
|
144
|
+
const manager = new WorktreeManager({
|
|
145
|
+
baseDir: WORKTREE_BASE,
|
|
146
|
+
repoPath,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Check if worktree already exists
|
|
150
|
+
if (await manager.worktreeExists(taskId)) {
|
|
151
|
+
console.log(`βΉοΈ Worktree for ${taskId} already exists`);
|
|
152
|
+
return manager.getWorktreePath(taskId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Create new worktree with branch
|
|
156
|
+
const info = await manager.createWorktree(taskId, true);
|
|
157
|
+
return info.path;
|
|
158
|
+
} catch (error: any) {
|
|
159
|
+
console.error(`β οΈ Failed to create worktree: ${error.message}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Spawn Claude worker in new pane
|
|
166
|
+
*/
|
|
167
|
+
async function spawnWorkerPane(
|
|
168
|
+
session: string,
|
|
169
|
+
workingDir: string
|
|
170
|
+
): Promise<{ paneId: string } | null> {
|
|
171
|
+
try {
|
|
172
|
+
// Find current window
|
|
173
|
+
const sessionObj = await tmux.findSessionByName(session);
|
|
174
|
+
if (!sessionObj) {
|
|
175
|
+
console.error(`β Session "${session}" not found`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const windows = await tmux.listWindows(sessionObj.id);
|
|
180
|
+
if (!windows || windows.length === 0) {
|
|
181
|
+
console.error(`β No windows in session "${session}"`);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const panes = await tmux.listPanes(windows[0].id);
|
|
186
|
+
if (!panes || panes.length === 0) {
|
|
187
|
+
console.error(`β No panes in session "${session}"`);
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Split current pane horizontally (side by side)
|
|
192
|
+
const newPane = await tmux.splitPane(
|
|
193
|
+
panes[0].id,
|
|
194
|
+
'horizontal',
|
|
195
|
+
50,
|
|
196
|
+
workingDir
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
if (!newPane) {
|
|
200
|
+
console.error(`β Failed to create new pane`);
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { paneId: newPane.id };
|
|
205
|
+
} catch (error: any) {
|
|
206
|
+
console.error(`β Error spawning worker pane: ${error.message}`);
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Start monitoring worker state and update registry
|
|
213
|
+
*/
|
|
214
|
+
function startWorkerMonitoring(
|
|
215
|
+
workerId: string,
|
|
216
|
+
session: string,
|
|
217
|
+
paneId: string
|
|
218
|
+
): void {
|
|
219
|
+
const monitor = new EventMonitor(session, {
|
|
220
|
+
pollIntervalMs: 1000,
|
|
221
|
+
paneId,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
monitor.on('state_change', async (event) => {
|
|
225
|
+
if (!event.state) return;
|
|
226
|
+
|
|
227
|
+
let newState: registry.WorkerState;
|
|
228
|
+
switch (event.state.type) {
|
|
229
|
+
case 'working':
|
|
230
|
+
case 'tool_use':
|
|
231
|
+
newState = 'working';
|
|
232
|
+
break;
|
|
233
|
+
case 'idle':
|
|
234
|
+
newState = 'idle';
|
|
235
|
+
break;
|
|
236
|
+
case 'permission':
|
|
237
|
+
newState = 'permission';
|
|
238
|
+
break;
|
|
239
|
+
case 'question':
|
|
240
|
+
newState = 'question';
|
|
241
|
+
break;
|
|
242
|
+
case 'error':
|
|
243
|
+
newState = 'error';
|
|
244
|
+
break;
|
|
245
|
+
case 'complete':
|
|
246
|
+
newState = 'done';
|
|
247
|
+
break;
|
|
248
|
+
default:
|
|
249
|
+
return; // Don't update for unknown states
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await registry.updateState(workerId, newState);
|
|
254
|
+
} catch {
|
|
255
|
+
// Ignore errors in background monitoring
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
monitor.on('poll_error', () => {
|
|
260
|
+
// Pane may have been killed - unregister worker
|
|
261
|
+
registry.unregister(workerId).catch(() => {});
|
|
262
|
+
monitor.stop();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
monitor.start().catch(() => {
|
|
266
|
+
// Session/pane not found - ignore
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Store monitor reference for cleanup (could be enhanced)
|
|
270
|
+
// For now, monitoring is fire-and-forget
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================================================
|
|
274
|
+
// Main Command
|
|
275
|
+
// ============================================================================
|
|
276
|
+
|
|
277
|
+
export async function workCommand(
|
|
278
|
+
target: string,
|
|
279
|
+
options: WorkOptions = {}
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
try {
|
|
282
|
+
// Get current working directory as repo path
|
|
283
|
+
const repoPath = process.cwd();
|
|
284
|
+
|
|
285
|
+
// 1. Resolve target
|
|
286
|
+
let issue: BeadsIssue | null = null;
|
|
287
|
+
|
|
288
|
+
if (target === 'next') {
|
|
289
|
+
console.log('π Finding next ready issue...');
|
|
290
|
+
issue = await getNextReadyIssue();
|
|
291
|
+
if (!issue) {
|
|
292
|
+
console.log('βΉοΈ No ready issues. Run `bd ready` to see the queue.');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
console.log(`π Found: ${issue.id} - "${issue.title}"`);
|
|
296
|
+
} else if (target === 'wish') {
|
|
297
|
+
console.error('β `term work wish` is not yet implemented. Coming in Phase 1.5.');
|
|
298
|
+
process.exit(1);
|
|
299
|
+
} else {
|
|
300
|
+
// Validate bd-id exists
|
|
301
|
+
issue = await getBeadsIssue(target);
|
|
302
|
+
if (!issue) {
|
|
303
|
+
console.error(`β Issue "${target}" not found. Run \`bd list\` to see issues.`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const taskId = issue.id;
|
|
309
|
+
|
|
310
|
+
// 2. Check not already assigned
|
|
311
|
+
const existingWorker = await registry.findByTask(taskId);
|
|
312
|
+
if (existingWorker) {
|
|
313
|
+
console.error(`β ${taskId} already has a worker (pane ${existingWorker.paneId})`);
|
|
314
|
+
console.log(` Run \`term kill ${existingWorker.id}\` first, or work on a different issue.`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 3. Get session
|
|
319
|
+
const session = options.session || await getCurrentSession();
|
|
320
|
+
if (!session) {
|
|
321
|
+
console.error('β Not in a tmux session. Attach to a session first or use --session.');
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 4. Claim in beads
|
|
326
|
+
console.log(`π Claiming ${taskId}...`);
|
|
327
|
+
const claimed = await claimIssue(taskId);
|
|
328
|
+
if (!claimed) {
|
|
329
|
+
console.error(`β Failed to claim ${taskId}. Check \`bd show ${taskId}\`.`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 5. Create worktree (unless --no-worktree)
|
|
334
|
+
let workingDir = repoPath;
|
|
335
|
+
let worktreePath: string | null = null;
|
|
336
|
+
|
|
337
|
+
if (!options.noWorktree) {
|
|
338
|
+
console.log(`π³ Creating worktree for ${taskId}...`);
|
|
339
|
+
worktreePath = await createWorktree(taskId, repoPath);
|
|
340
|
+
if (worktreePath) {
|
|
341
|
+
workingDir = worktreePath;
|
|
342
|
+
console.log(` Created: ${worktreePath}`);
|
|
343
|
+
} else {
|
|
344
|
+
console.log(`β οΈ Worktree creation failed. Using shared repo.`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 6. Spawn Claude pane
|
|
349
|
+
console.log(`π Spawning worker pane...`);
|
|
350
|
+
const paneResult = await spawnWorkerPane(session, workingDir);
|
|
351
|
+
if (!paneResult) {
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { paneId } = paneResult;
|
|
356
|
+
|
|
357
|
+
// 7. Register worker
|
|
358
|
+
const worker: registry.Worker = {
|
|
359
|
+
id: taskId,
|
|
360
|
+
paneId,
|
|
361
|
+
session,
|
|
362
|
+
worktree: worktreePath,
|
|
363
|
+
taskId,
|
|
364
|
+
taskTitle: issue.title,
|
|
365
|
+
startedAt: new Date().toISOString(),
|
|
366
|
+
state: 'spawning',
|
|
367
|
+
lastStateChange: new Date().toISOString(),
|
|
368
|
+
repoPath,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
await registry.register(worker);
|
|
372
|
+
|
|
373
|
+
// 8. Start Claude in pane
|
|
374
|
+
await tmux.executeCommand(paneId, 'claude', false, false);
|
|
375
|
+
|
|
376
|
+
// Wait a bit for Claude to start
|
|
377
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
378
|
+
|
|
379
|
+
// 9. Send initial prompt
|
|
380
|
+
const prompt = options.prompt || `Work on beads issue ${taskId}: "${issue.title}"
|
|
381
|
+
|
|
382
|
+
Read the issue details with: bd show ${taskId}
|
|
383
|
+
|
|
384
|
+
When you're done, commit your changes and let me know.`;
|
|
385
|
+
|
|
386
|
+
await tmux.executeCommand(paneId, prompt, false, false);
|
|
387
|
+
|
|
388
|
+
// 10. Update state to working
|
|
389
|
+
await registry.updateState(taskId, 'working');
|
|
390
|
+
|
|
391
|
+
// 11. Start monitoring
|
|
392
|
+
startWorkerMonitoring(taskId, session, paneId);
|
|
393
|
+
|
|
394
|
+
// 12. Focus pane (unless disabled)
|
|
395
|
+
if (options.focus !== false) {
|
|
396
|
+
await tmux.executeTmux(`select-pane -t '${paneId}'`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
console.log(`\nβ
Worker started for ${taskId}`);
|
|
400
|
+
console.log(` Pane: ${paneId}`);
|
|
401
|
+
console.log(` Session: ${session}`);
|
|
402
|
+
if (worktreePath) {
|
|
403
|
+
console.log(` Worktree: ${worktreePath}`);
|
|
404
|
+
}
|
|
405
|
+
console.log(`\nCommands:`);
|
|
406
|
+
console.log(` term workers - Check worker status`);
|
|
407
|
+
console.log(` term approve ${taskId} - Approve permissions`);
|
|
408
|
+
console.log(` term close ${taskId} - Close issue when done`);
|
|
409
|
+
console.log(` term kill ${taskId} - Force kill worker`);
|
|
410
|
+
|
|
411
|
+
} catch (error: any) {
|
|
412
|
+
console.error(`β Error: ${error.message}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workers command - Show worker status
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* term workers - List all workers and their states
|
|
6
|
+
* term workers --json - Output as JSON
|
|
7
|
+
* term workers --watch - Live updates (TODO: Phase 1.5)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { $ } from 'bun';
|
|
11
|
+
import * as tmux from '../lib/tmux.js';
|
|
12
|
+
import * as registry from '../lib/worker-registry.js';
|
|
13
|
+
import { detectState, stripAnsi } from '../lib/orchestrator/index.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export interface WorkersOptions {
|
|
20
|
+
json?: boolean;
|
|
21
|
+
watch?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface WorkerDisplay {
|
|
25
|
+
name: string;
|
|
26
|
+
pane: string;
|
|
27
|
+
task: string;
|
|
28
|
+
state: string;
|
|
29
|
+
time: string;
|
|
30
|
+
alive: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Helper Functions
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if a pane still exists
|
|
39
|
+
*/
|
|
40
|
+
async function isPaneAlive(paneId: string): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
await tmux.capturePaneContent(paneId, 1);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get current state from pane output
|
|
51
|
+
*/
|
|
52
|
+
async function getCurrentState(paneId: string): Promise<string> {
|
|
53
|
+
try {
|
|
54
|
+
const output = await tmux.capturePaneContent(paneId, 30);
|
|
55
|
+
const state = detectState(output);
|
|
56
|
+
|
|
57
|
+
// Map to display format
|
|
58
|
+
switch (state.type) {
|
|
59
|
+
case 'working':
|
|
60
|
+
case 'tool_use':
|
|
61
|
+
return 'working';
|
|
62
|
+
case 'idle':
|
|
63
|
+
return 'idle';
|
|
64
|
+
case 'permission':
|
|
65
|
+
return 'β οΈ perm';
|
|
66
|
+
case 'question':
|
|
67
|
+
return 'β οΈ question';
|
|
68
|
+
case 'error':
|
|
69
|
+
return 'β error';
|
|
70
|
+
case 'complete':
|
|
71
|
+
return 'β
done';
|
|
72
|
+
default:
|
|
73
|
+
return state.type;
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return 'unknown';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Get beads queue status
|
|
82
|
+
*/
|
|
83
|
+
async function getQueueStatus(): Promise<{ ready: string[]; blocked: string[] }> {
|
|
84
|
+
const ready: string[] = [];
|
|
85
|
+
const blocked: string[] = [];
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
// Get ready issues
|
|
89
|
+
const readyResult = await $`bd ready --json`.quiet();
|
|
90
|
+
const readyOutput = readyResult.stdout.toString().trim();
|
|
91
|
+
if (readyOutput) {
|
|
92
|
+
try {
|
|
93
|
+
const issues = JSON.parse(readyOutput);
|
|
94
|
+
for (const issue of issues) {
|
|
95
|
+
ready.push(`${issue.id}`);
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// Parse line-based format
|
|
99
|
+
const lines = readyOutput.split('\n').filter(l => l.trim());
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
const match = line.match(/^(bd-\d+)/);
|
|
102
|
+
if (match) ready.push(match[1]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore bd errors
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
// Get blocked issues
|
|
112
|
+
const listResult = await $`bd list --json`.quiet();
|
|
113
|
+
const listOutput = listResult.stdout.toString().trim();
|
|
114
|
+
if (listOutput) {
|
|
115
|
+
try {
|
|
116
|
+
const issues = JSON.parse(listOutput);
|
|
117
|
+
for (const issue of issues) {
|
|
118
|
+
if (issue.blockedBy && issue.blockedBy.length > 0) {
|
|
119
|
+
blocked.push(`${issue.id} (blocked by ${issue.blockedBy.join(', ')})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Ignore parse errors
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore bd errors
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { ready, blocked };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Format time elapsed
|
|
135
|
+
*/
|
|
136
|
+
function formatElapsed(startedAt: string): string {
|
|
137
|
+
const startTime = new Date(startedAt).getTime();
|
|
138
|
+
const ms = Date.now() - startTime;
|
|
139
|
+
const minutes = Math.floor(ms / 60000);
|
|
140
|
+
const hours = Math.floor(minutes / 60);
|
|
141
|
+
|
|
142
|
+
if (hours > 0) {
|
|
143
|
+
return `${hours}h ${minutes % 60}m`;
|
|
144
|
+
} else if (minutes > 0) {
|
|
145
|
+
return `${minutes}m`;
|
|
146
|
+
}
|
|
147
|
+
return '<1m';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ============================================================================
|
|
151
|
+
// Main Command
|
|
152
|
+
// ============================================================================
|
|
153
|
+
|
|
154
|
+
export async function workersCommand(options: WorkersOptions = {}): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const workers = await registry.list();
|
|
157
|
+
|
|
158
|
+
// Gather display data for each worker
|
|
159
|
+
const displayData: WorkerDisplay[] = [];
|
|
160
|
+
|
|
161
|
+
for (const worker of workers) {
|
|
162
|
+
const alive = await isPaneAlive(worker.paneId);
|
|
163
|
+
let currentState = worker.state;
|
|
164
|
+
|
|
165
|
+
if (alive) {
|
|
166
|
+
// Get live state from pane
|
|
167
|
+
currentState = await getCurrentState(worker.paneId);
|
|
168
|
+
|
|
169
|
+
// Update registry if state differs
|
|
170
|
+
const mappedState = mapDisplayStateToRegistry(currentState);
|
|
171
|
+
if (mappedState && mappedState !== worker.state) {
|
|
172
|
+
await registry.updateState(worker.id, mappedState);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
currentState = 'π dead';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
displayData.push({
|
|
179
|
+
name: worker.id,
|
|
180
|
+
pane: worker.paneId,
|
|
181
|
+
task: worker.taskTitle
|
|
182
|
+
? `"${worker.taskTitle.substring(0, 25)}${worker.taskTitle.length > 25 ? '...' : ''}"`
|
|
183
|
+
: worker.taskId,
|
|
184
|
+
state: currentState,
|
|
185
|
+
time: formatElapsed(worker.startedAt),
|
|
186
|
+
alive,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Get queue status
|
|
191
|
+
const queue = await getQueueStatus();
|
|
192
|
+
|
|
193
|
+
// Filter out dead workers from ready count
|
|
194
|
+
const activeTaskIds = workers.filter(w => displayData.find(d => d.name === w.id && d.alive)).map(w => w.taskId);
|
|
195
|
+
const actuallyReady = queue.ready.filter(id => !activeTaskIds.includes(id));
|
|
196
|
+
|
|
197
|
+
if (options.json) {
|
|
198
|
+
console.log(JSON.stringify({
|
|
199
|
+
workers: displayData,
|
|
200
|
+
queue: {
|
|
201
|
+
ready: actuallyReady,
|
|
202
|
+
blocked: queue.blocked,
|
|
203
|
+
},
|
|
204
|
+
}, null, 2));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Display workers table
|
|
209
|
+
console.log('βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
|
|
210
|
+
console.log('β WORKERS β');
|
|
211
|
+
console.log('ββββββββββββ¬βββββββββββ¬ββββββββββββββββββββββββββββ¬βββββββββββ¬βββββ€');
|
|
212
|
+
console.log('β Name β Pane β Task β State βTimeβ');
|
|
213
|
+
console.log('ββββββββββββΌβββββββββββΌββββββββββββββββββββββββββββΌβββββββββββΌβββββ€');
|
|
214
|
+
|
|
215
|
+
if (displayData.length === 0) {
|
|
216
|
+
console.log('β (no workers) β');
|
|
217
|
+
} else {
|
|
218
|
+
for (const w of displayData) {
|
|
219
|
+
const name = w.name.padEnd(8).substring(0, 8);
|
|
220
|
+
const pane = w.pane.padEnd(8).substring(0, 8);
|
|
221
|
+
const task = w.task.padEnd(25).substring(0, 25);
|
|
222
|
+
const state = w.state.padEnd(8).substring(0, 8);
|
|
223
|
+
const time = w.time.padStart(4).substring(0, 4);
|
|
224
|
+
console.log(`β ${name} β ${pane} β ${task} β ${state} β${time}β`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
console.log('ββββββββββββ΄βββββββββββ΄ββββββββββββββββββββββββββββ΄βββββββββββ΄βββββ');
|
|
229
|
+
|
|
230
|
+
// Display queue
|
|
231
|
+
if (queue.blocked.length > 0) {
|
|
232
|
+
console.log(`\nBlocked: ${queue.blocked.slice(0, 5).join(', ')}${queue.blocked.length > 5 ? '...' : ''}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (actuallyReady.length > 0) {
|
|
236
|
+
console.log(`Ready: ${actuallyReady.slice(0, 5).join(', ')}${actuallyReady.length > 5 ? '...' : ''}`);
|
|
237
|
+
} else if (displayData.length > 0) {
|
|
238
|
+
console.log(`\nReady: (none - all assigned or blocked)`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Cleanup dead workers (optional - could prompt)
|
|
242
|
+
const deadWorkers = displayData.filter(w => !w.alive);
|
|
243
|
+
if (deadWorkers.length > 0) {
|
|
244
|
+
console.log(`\nβ οΈ ${deadWorkers.length} dead worker(s) detected. Run \`term kill <name>\` to clean up.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
} catch (error: any) {
|
|
248
|
+
console.error(`β Error: ${error.message}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Map display state to registry state
|
|
255
|
+
*/
|
|
256
|
+
function mapDisplayStateToRegistry(displayState: string): registry.WorkerState | null {
|
|
257
|
+
if (displayState === 'working') return 'working';
|
|
258
|
+
if (displayState === 'idle') return 'idle';
|
|
259
|
+
if (displayState === 'β οΈ perm') return 'permission';
|
|
260
|
+
if (displayState === 'β οΈ question') return 'question';
|
|
261
|
+
if (displayState === 'β error') return 'error';
|
|
262
|
+
if (displayState === 'β
done') return 'done';
|
|
263
|
+
return null;
|
|
264
|
+
}
|