@automagik/genie 0.260202.1607 → 0.260202.1901

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.
@@ -0,0 +1,298 @@
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 * as beadsRegistry from '../lib/beads-registry.js';
14
+ import { detectState, stripAnsi } from '../lib/orchestrator/index.js';
15
+
16
+ // Use beads registry when enabled
17
+ const useBeads = beadsRegistry.isBeadsRegistryEnabled();
18
+
19
+ // ============================================================================
20
+ // Types
21
+ // ============================================================================
22
+
23
+ export interface WorkersOptions {
24
+ json?: boolean;
25
+ watch?: boolean;
26
+ }
27
+
28
+ interface WorkerDisplay {
29
+ name: string;
30
+ pane: string;
31
+ task: string;
32
+ state: string;
33
+ time: string;
34
+ alive: boolean;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Helper Functions
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Check if a pane still exists
43
+ */
44
+ async function isPaneAlive(paneId: string): Promise<boolean> {
45
+ try {
46
+ await tmux.capturePaneContent(paneId, 1);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get current state from pane output
55
+ */
56
+ async function getCurrentState(paneId: string): Promise<string> {
57
+ try {
58
+ const output = await tmux.capturePaneContent(paneId, 30);
59
+ const state = detectState(output);
60
+
61
+ // Map to display format
62
+ switch (state.type) {
63
+ case 'working':
64
+ case 'tool_use':
65
+ return 'working';
66
+ case 'idle':
67
+ return 'idle';
68
+ case 'permission':
69
+ return '⚠️ perm';
70
+ case 'question':
71
+ return '⚠️ question';
72
+ case 'error':
73
+ return '❌ error';
74
+ case 'complete':
75
+ return '✅ done';
76
+ default:
77
+ return state.type;
78
+ }
79
+ } catch {
80
+ return 'unknown';
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Get beads queue status
86
+ */
87
+ async function getQueueStatus(): Promise<{ ready: string[]; blocked: string[] }> {
88
+ const ready: string[] = [];
89
+ const blocked: string[] = [];
90
+
91
+ try {
92
+ // Get ready issues
93
+ const readyResult = await $`bd ready --json`.quiet();
94
+ const readyOutput = readyResult.stdout.toString().trim();
95
+ if (readyOutput) {
96
+ try {
97
+ const issues = JSON.parse(readyOutput);
98
+ for (const issue of issues) {
99
+ ready.push(`${issue.id}`);
100
+ }
101
+ } catch {
102
+ // Parse line-based format
103
+ const lines = readyOutput.split('\n').filter(l => l.trim());
104
+ for (const line of lines) {
105
+ const match = line.match(/^(bd-\d+)/);
106
+ if (match) ready.push(match[1]);
107
+ }
108
+ }
109
+ }
110
+ } catch {
111
+ // Ignore bd errors
112
+ }
113
+
114
+ try {
115
+ // Get blocked issues
116
+ const listResult = await $`bd list --json`.quiet();
117
+ const listOutput = listResult.stdout.toString().trim();
118
+ if (listOutput) {
119
+ try {
120
+ const issues = JSON.parse(listOutput);
121
+ for (const issue of issues) {
122
+ if (issue.blockedBy && issue.blockedBy.length > 0) {
123
+ blocked.push(`${issue.id} (blocked by ${issue.blockedBy.join(', ')})`);
124
+ }
125
+ }
126
+ } catch {
127
+ // Ignore parse errors
128
+ }
129
+ }
130
+ } catch {
131
+ // Ignore bd errors
132
+ }
133
+
134
+ return { ready, blocked };
135
+ }
136
+
137
+ /**
138
+ * Format time elapsed
139
+ */
140
+ function formatElapsed(startedAt: string): string {
141
+ const startTime = new Date(startedAt).getTime();
142
+ const ms = Date.now() - startTime;
143
+ const minutes = Math.floor(ms / 60000);
144
+ const hours = Math.floor(minutes / 60);
145
+
146
+ if (hours > 0) {
147
+ return `${hours}h ${minutes % 60}m`;
148
+ } else if (minutes > 0) {
149
+ return `${minutes}m`;
150
+ }
151
+ return '<1m';
152
+ }
153
+
154
+ // ============================================================================
155
+ // Main Command
156
+ // ============================================================================
157
+
158
+ export async function workersCommand(options: WorkersOptions = {}): Promise<void> {
159
+ try {
160
+ // Get workers from beads or JSON registry
161
+ // During transition, merge results from both
162
+ let workers: registry.Worker[] = [];
163
+
164
+ if (useBeads) {
165
+ try {
166
+ const beadsWorkers = await beadsRegistry.listWorkers();
167
+ workers = beadsWorkers;
168
+ } catch {
169
+ // Fallback to JSON registry
170
+ workers = await registry.list();
171
+ }
172
+ } else {
173
+ workers = await registry.list();
174
+ }
175
+
176
+ // Also check JSON registry for any workers not in beads
177
+ const jsonWorkers = await registry.list();
178
+ const beadsIds = new Set(workers.map(w => w.id));
179
+ for (const jw of jsonWorkers) {
180
+ if (!beadsIds.has(jw.id)) {
181
+ workers.push(jw);
182
+ }
183
+ }
184
+
185
+ // Gather display data for each worker
186
+ const displayData: WorkerDisplay[] = [];
187
+
188
+ for (const worker of workers) {
189
+ const alive = await isPaneAlive(worker.paneId);
190
+ let currentState = worker.state;
191
+
192
+ if (alive) {
193
+ // Get live state from pane
194
+ currentState = await getCurrentState(worker.paneId);
195
+
196
+ // Update both registries if state differs
197
+ const mappedState = mapDisplayStateToRegistry(currentState);
198
+ if (mappedState && mappedState !== worker.state) {
199
+ if (useBeads) {
200
+ // Update beads and send heartbeat
201
+ await beadsRegistry.updateState(worker.id, mappedState).catch(() => {});
202
+ }
203
+ await registry.updateState(worker.id, mappedState);
204
+ } else if (useBeads) {
205
+ // Just send heartbeat even if state unchanged
206
+ await beadsRegistry.heartbeat(worker.id).catch(() => {});
207
+ }
208
+ } else {
209
+ currentState = '💀 dead';
210
+ }
211
+
212
+ displayData.push({
213
+ name: worker.id,
214
+ pane: worker.paneId,
215
+ task: worker.taskTitle
216
+ ? `"${worker.taskTitle.substring(0, 25)}${worker.taskTitle.length > 25 ? '...' : ''}"`
217
+ : worker.taskId,
218
+ state: currentState,
219
+ time: formatElapsed(worker.startedAt),
220
+ alive,
221
+ });
222
+ }
223
+
224
+ // Get queue status
225
+ const queue = await getQueueStatus();
226
+
227
+ // Filter out dead workers from ready count
228
+ const activeTaskIds = workers.filter(w => displayData.find(d => d.name === w.id && d.alive)).map(w => w.taskId);
229
+ const actuallyReady = queue.ready.filter(id => !activeTaskIds.includes(id));
230
+
231
+ if (options.json) {
232
+ console.log(JSON.stringify({
233
+ workers: displayData,
234
+ queue: {
235
+ ready: actuallyReady,
236
+ blocked: queue.blocked,
237
+ },
238
+ }, null, 2));
239
+ return;
240
+ }
241
+
242
+ // Display workers table
243
+ console.log('┌─────────────────────────────────────────────────────────────────┐');
244
+ console.log('│ WORKERS │');
245
+ console.log('├──────────┬──────────┬───────────────────────────┬──────────┬────┤');
246
+ console.log('│ Name │ Pane │ Task │ State │Time│');
247
+ console.log('├──────────┼──────────┼───────────────────────────┼──────────┼────┤');
248
+
249
+ if (displayData.length === 0) {
250
+ console.log('│ (no workers) │');
251
+ } else {
252
+ for (const w of displayData) {
253
+ const name = w.name.padEnd(8).substring(0, 8);
254
+ const pane = w.pane.padEnd(8).substring(0, 8);
255
+ const task = w.task.padEnd(25).substring(0, 25);
256
+ const state = w.state.padEnd(8).substring(0, 8);
257
+ const time = w.time.padStart(4).substring(0, 4);
258
+ console.log(`│ ${name} │ ${pane} │ ${task} │ ${state} │${time}│`);
259
+ }
260
+ }
261
+
262
+ console.log('└──────────┴──────────┴───────────────────────────┴──────────┴────┘');
263
+
264
+ // Display queue
265
+ if (queue.blocked.length > 0) {
266
+ console.log(`\nBlocked: ${queue.blocked.slice(0, 5).join(', ')}${queue.blocked.length > 5 ? '...' : ''}`);
267
+ }
268
+
269
+ if (actuallyReady.length > 0) {
270
+ console.log(`Ready: ${actuallyReady.slice(0, 5).join(', ')}${actuallyReady.length > 5 ? '...' : ''}`);
271
+ } else if (displayData.length > 0) {
272
+ console.log(`\nReady: (none - all assigned or blocked)`);
273
+ }
274
+
275
+ // Cleanup dead workers (optional - could prompt)
276
+ const deadWorkers = displayData.filter(w => !w.alive);
277
+ if (deadWorkers.length > 0) {
278
+ console.log(`\n⚠️ ${deadWorkers.length} dead worker(s) detected. Run \`term kill <name>\` to clean up.`);
279
+ }
280
+
281
+ } catch (error: any) {
282
+ console.error(`❌ Error: ${error.message}`);
283
+ process.exit(1);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Map display state to registry state
289
+ */
290
+ function mapDisplayStateToRegistry(displayState: string): registry.WorkerState | null {
291
+ if (displayState === 'working') return 'working';
292
+ if (displayState === 'idle') return 'idle';
293
+ if (displayState === '⚠️ perm') return 'permission';
294
+ if (displayState === '⚠️ question') return 'question';
295
+ if (displayState === '❌ error') return 'error';
296
+ if (displayState === '✅ done') return 'done';
297
+ return null;
298
+ }
package/src/term.ts CHANGED
@@ -15,6 +15,12 @@ import * as windowCmd from './term-commands/window.js';
15
15
  import * as paneCmd from './term-commands/pane.js';
16
16
  import * as statusCmd from './term-commands/status.js';
17
17
  import * as shortcutsCmd from './term-commands/shortcuts.js';
18
+ import * as orchestrateCmd from './term-commands/orchestrate.js';
19
+ import * as workCmd from './term-commands/work.js';
20
+ import * as workersCmd from './term-commands/workers.js';
21
+ import * as closeCmd from './term-commands/close.js';
22
+ import * as killCmd from './term-commands/kill.js';
23
+ import * as daemonCmd from './term-commands/daemon.js';
18
24
 
19
25
  const program = new Command();
20
26
 
@@ -28,7 +34,15 @@ Collaborative Usage:
28
34
  AI reads: term read genie
29
35
 
30
36
  Workflow: new → exec → read → rm
31
- Full control: window new/ls/rm, pane ls/rm, split, status`)
37
+ Full control: window new/ls/rm, pane ls/rm, split, status
38
+
39
+ Worker Orchestration:
40
+ term work <bd-id> - Spawn worker bound to beads issue
41
+ term work next - Work on next ready issue
42
+ term workers - List all workers and states
43
+ term close <bd-id> - Close issue, cleanup worker
44
+ term kill <worker> - Force kill a stuck worker
45
+ term daemon start - Start beads daemon for auto-sync`)
32
46
  .version(VERSION);
33
47
 
34
48
  // Session management
@@ -200,4 +214,216 @@ program
200
214
  await shortcutsCmd.handleShortcuts(options);
201
215
  });
202
216
 
217
+ // Worker management commands (beads + Claude orchestration)
218
+ program
219
+ .command('work <target>')
220
+ .description('Spawn worker bound to beads issue (target: bd-id, "next", or "wish")')
221
+ .option('--no-worktree', 'Use shared repo instead of worktree')
222
+ .option('-s, --session <name>', 'Target tmux session')
223
+ .option('--no-focus', 'Don\'t focus the worker pane')
224
+ .option('-p, --prompt <message>', 'Custom initial prompt')
225
+ .action(async (target: string, options: workCmd.WorkOptions) => {
226
+ await workCmd.workCommand(target, options);
227
+ });
228
+
229
+ program
230
+ .command('workers')
231
+ .description('List all workers and their states')
232
+ .option('--json', 'Output as JSON')
233
+ .option('-w, --watch', 'Live updates (coming soon)')
234
+ .action(async (options: workersCmd.WorkersOptions) => {
235
+ if (options.watch) {
236
+ console.log('ℹ️ --watch mode coming in Phase 1.5');
237
+ }
238
+ await workersCmd.workersCommand(options);
239
+ });
240
+
241
+ program
242
+ .command('close <task-id>')
243
+ .description('Close beads issue and cleanup worker')
244
+ .option('--no-sync', 'Skip bd sync')
245
+ .option('--keep-worktree', 'Don\'t remove the worktree')
246
+ .option('--merge', 'Merge worktree changes to main branch')
247
+ .option('-y, --yes', 'Skip confirmation')
248
+ .action(async (taskId: string, options: closeCmd.CloseOptions) => {
249
+ await closeCmd.closeCommand(taskId, options);
250
+ });
251
+
252
+ program
253
+ .command('kill <worker>')
254
+ .description('Force kill a worker')
255
+ .option('-y, --yes', 'Skip confirmation')
256
+ .option('--keep-worktree', 'Don\'t remove the worktree')
257
+ .action(async (worker: string, options: killCmd.KillOptions) => {
258
+ await killCmd.killCommand(worker, options);
259
+ });
260
+
261
+ // Approve command (shortcut to orc approve for worker use)
262
+ program
263
+ .command('approve <worker>')
264
+ .description('Approve pending permission request for a worker')
265
+ .option('--deny', 'Deny instead of approve')
266
+ .action(async (worker: string, options: { deny?: boolean }) => {
267
+ // Find worker to get pane
268
+ const registry = await import('./lib/worker-registry.js');
269
+ let workerInfo = await registry.get(worker);
270
+ if (!workerInfo) {
271
+ workerInfo = await registry.findByTask(worker);
272
+ }
273
+ if (!workerInfo) {
274
+ console.error(`❌ Worker "${worker}" not found. Run \`term workers\` to see workers.`);
275
+ process.exit(1);
276
+ }
277
+ await orchestrateCmd.approvePermission(workerInfo.session, {
278
+ pane: workerInfo.paneId,
279
+ deny: options.deny,
280
+ });
281
+ });
282
+
283
+ // Answer command (shortcut to orc answer for worker use)
284
+ program
285
+ .command('answer <worker> <choice>')
286
+ .description('Answer a question for a worker (use "text:..." for text input)')
287
+ .action(async (worker: string, choice: string) => {
288
+ const registry = await import('./lib/worker-registry.js');
289
+ let workerInfo = await registry.get(worker);
290
+ if (!workerInfo) {
291
+ workerInfo = await registry.findByTask(worker);
292
+ }
293
+ if (!workerInfo) {
294
+ console.error(`❌ Worker "${worker}" not found. Run \`term workers\` to see workers.`);
295
+ process.exit(1);
296
+ }
297
+ await orchestrateCmd.answerQuestion(workerInfo.session, choice, {
298
+ pane: workerInfo.paneId,
299
+ });
300
+ });
301
+
302
+ // Daemon management (beads auto-sync)
303
+ const daemonProgram = program.command('daemon').description('Manage beads daemon for auto-sync');
304
+
305
+ daemonProgram
306
+ .command('start')
307
+ .description('Start beads daemon (auto-commit, auto-sync)')
308
+ .option('--no-auto-commit', 'Disable auto-commit')
309
+ .option('--auto-push', 'Enable auto-push to remote')
310
+ .action(async (options: daemonCmd.DaemonStartOptions) => {
311
+ await daemonCmd.startCommand(options);
312
+ });
313
+
314
+ daemonProgram
315
+ .command('stop')
316
+ .description('Stop beads daemon')
317
+ .action(async () => {
318
+ await daemonCmd.stopCommand();
319
+ });
320
+
321
+ daemonProgram
322
+ .command('status')
323
+ .description('Show daemon status')
324
+ .option('--json', 'Output as JSON')
325
+ .action(async (options: daemonCmd.DaemonStatusOptions) => {
326
+ await daemonCmd.statusCommand(options);
327
+ });
328
+
329
+ daemonProgram
330
+ .command('restart')
331
+ .description('Restart beads daemon')
332
+ .option('--no-auto-commit', 'Disable auto-commit')
333
+ .option('--auto-push', 'Enable auto-push to remote')
334
+ .action(async (options: daemonCmd.DaemonStartOptions) => {
335
+ await daemonCmd.restartCommand(options);
336
+ });
337
+
338
+ // Orchestration commands (Claude Code automation)
339
+ const orcProgram = program.command('orc').description('Orchestrate Claude Code sessions');
340
+
341
+ orcProgram
342
+ .command('start <session>')
343
+ .description('Start Claude Code in a session with optional monitoring')
344
+ .option('-p, --pane <id>', 'Target specific pane ID (e.g., %16)')
345
+ .option('-m, --monitor', 'Enable real-time event monitoring')
346
+ .option('-c, --command <cmd>', 'Command to run instead of claude')
347
+ .option('--json', 'Output events as JSON')
348
+ .action(async (session: string, options: orchestrateCmd.StartOptions) => {
349
+ await orchestrateCmd.startSession(session, options);
350
+ });
351
+
352
+ orcProgram
353
+ .command('send <session> <message>')
354
+ .description('Send message to Claude and track completion')
355
+ .option('--pane <id>', 'Target specific pane ID (e.g., %16)')
356
+ .option('--method <name>', 'Completion detection method')
357
+ .option('-t, --timeout <ms>', 'Timeout in milliseconds')
358
+ .option('--no-wait', 'Send without waiting for completion')
359
+ .option('--json', 'Output as JSON')
360
+ .action(async (session: string, message: string, options: orchestrateCmd.SendOptions) => {
361
+ await orchestrateCmd.sendMessage(session, message, options);
362
+ });
363
+
364
+ orcProgram
365
+ .command('status <session>')
366
+ .description('Show current Claude state and details')
367
+ .option('--pane <id>', 'Target specific pane ID (e.g., %16)')
368
+ .option('--json', 'Output as JSON')
369
+ .action(async (session: string, options: orchestrateCmd.StatusOptions) => {
370
+ await orchestrateCmd.showStatus(session, options);
371
+ });
372
+
373
+ orcProgram
374
+ .command('watch <session>')
375
+ .description('Watch session events in real-time')
376
+ .option('--pane <id>', 'Target specific pane ID (e.g., %16)')
377
+ .option('--json', 'Output events as JSON')
378
+ .option('-p, --poll <ms>', 'Poll interval in milliseconds')
379
+ .action(async (session: string, options: orchestrateCmd.WatchOptions) => {
380
+ await orchestrateCmd.watchSession(session, options);
381
+ });
382
+
383
+ orcProgram
384
+ .command('approve <session>')
385
+ .description('Approve pending permission request')
386
+ .option('-p, --pane <id>', 'Specific pane ID to target')
387
+ .option('--auto', 'Auto-approve all future permissions (dangerous!)')
388
+ .option('--deny', 'Deny instead of approve')
389
+ .action(async (session: string, options: orchestrateCmd.ApproveOptions) => {
390
+ await orchestrateCmd.approvePermission(session, options);
391
+ });
392
+
393
+ orcProgram
394
+ .command('answer <session> <choice>')
395
+ .description('Answer a question with the given choice (use "text:..." to send feedback)')
396
+ .option('-p, --pane <id>', 'Specific pane ID to target')
397
+ .action(async (session: string, choice: string, options: { pane?: string }) => {
398
+ await orchestrateCmd.answerQuestion(session, choice, options);
399
+ });
400
+
401
+ orcProgram
402
+ .command('experiment <method>')
403
+ .description('Test a completion detection method')
404
+ .option('-n, --runs <number>', 'Number of test runs')
405
+ .option('--task <command>', 'Test command to run')
406
+ .option('--json', 'Output as JSON')
407
+ .action(async (method: string, options: orchestrateCmd.ExperimentOptions) => {
408
+ await orchestrateCmd.runExperiment(method, options);
409
+ });
410
+
411
+ orcProgram
412
+ .command('methods')
413
+ .description('List available completion detection methods')
414
+ .action(async () => {
415
+ await orchestrateCmd.listMethods();
416
+ });
417
+
418
+ orcProgram
419
+ .command('run <session> <message>')
420
+ .description('Send task and auto-approve until idle (fire-and-forget)')
421
+ .option('-p, --pane <id>', 'Target specific pane ID (e.g., %16)')
422
+ .option('-a, --auto-approve', 'Auto-approve permissions and plans')
423
+ .option('-t, --timeout <ms>', 'Timeout in milliseconds (default: 300000)')
424
+ .option('--json', 'Output final state as JSON')
425
+ .action(async (session: string, message: string, options: orchestrateCmd.RunOptions) => {
426
+ await orchestrateCmd.runTask(session, message, options);
427
+ });
428
+
203
429
  program.parse();