@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.
@@ -38,6 +38,9 @@ export async function splitSessionPane(
38
38
  // Determine direction
39
39
  const splitDirection = direction === 'h' ? 'horizontal' : 'vertical';
40
40
 
41
+ // Get source pane's current working directory
42
+ const sourcePath = await tmux.executeTmux(`display-message -p -t '${paneId}' '#{pane_current_path}'`);
43
+
41
44
  // Handle workspace and worktree options
42
45
  let workingDir: string | undefined;
43
46
 
@@ -62,20 +65,18 @@ export async function splitSessionPane(
62
65
  workingDir = manager.getWorktreePath(options.worktree);
63
66
  } else if (options.workspace) {
64
67
  workingDir = options.workspace;
68
+ } else {
69
+ // Default to source pane's current directory
70
+ workingDir = sourcePath.trim() || undefined;
65
71
  }
66
72
 
67
- // Split pane
68
- const newPane = await tmux.splitPane(paneId, splitDirection);
73
+ // Split pane with working directory (tmux -c flag handles this natively)
74
+ const newPane = await tmux.splitPane(paneId, splitDirection, undefined, workingDir);
69
75
  if (!newPane) {
70
76
  console.error('❌ Failed to split pane');
71
77
  process.exit(1);
72
78
  }
73
79
 
74
- // Change to working directory if specified
75
- if (workingDir && newPane) {
76
- await tmux.executeTmux(`send-keys -t '${newPane.id}' 'cd ${workingDir}' Enter`);
77
- }
78
-
79
80
  console.log(`✅ Pane split ${splitDirection}ly in session "${sessionName}"`);
80
81
  if (workingDir) {
81
82
  console.log(` Working directory: ${workingDir}`);
@@ -0,0 +1,497 @@
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 * as beadsRegistry from '../lib/beads-registry.js';
19
+ import { WorktreeManager } from '../lib/worktree.js';
20
+ import { EventMonitor, detectState } from '../lib/orchestrator/index.js';
21
+ import { join } from 'path';
22
+ import { homedir } from 'os';
23
+
24
+ // Use beads registry when enabled
25
+ const useBeads = beadsRegistry.isBeadsRegistryEnabled();
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ export interface WorkOptions {
32
+ noWorktree?: boolean;
33
+ session?: string;
34
+ focus?: boolean;
35
+ prompt?: string;
36
+ }
37
+
38
+ interface BeadsIssue {
39
+ id: string;
40
+ title: string;
41
+ status: string;
42
+ blockedBy?: string[];
43
+ }
44
+
45
+ // ============================================================================
46
+ // Configuration
47
+ // ============================================================================
48
+
49
+ const WORKTREE_BASE = join(homedir(), '.local', 'share', 'term', 'worktrees');
50
+
51
+ // ============================================================================
52
+ // Helper Functions
53
+ // ============================================================================
54
+
55
+ /**
56
+ * Run bd command and parse output
57
+ */
58
+ async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> {
59
+ try {
60
+ const result = await $`bd ${args}`.quiet();
61
+ return { stdout: result.stdout.toString().trim(), exitCode: 0 };
62
+ } catch (error: any) {
63
+ return { stdout: error.stdout?.toString().trim() || '', exitCode: error.exitCode || 1 };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Get a beads issue by ID
69
+ */
70
+ async function getBeadsIssue(id: string): Promise<BeadsIssue | null> {
71
+ const { stdout, exitCode } = await runBd(['show', id, '--json']);
72
+ if (exitCode !== 0 || !stdout) return null;
73
+
74
+ try {
75
+ const issue = JSON.parse(stdout);
76
+ return {
77
+ id: issue.id,
78
+ title: issue.title || issue.description?.substring(0, 50) || 'Untitled',
79
+ status: issue.status,
80
+ blockedBy: issue.blockedBy || [],
81
+ };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Get next ready beads issue
89
+ */
90
+ async function getNextReadyIssue(): Promise<BeadsIssue | null> {
91
+ const { stdout, exitCode } = await runBd(['ready', '--json']);
92
+ if (exitCode !== 0 || !stdout) return null;
93
+
94
+ try {
95
+ const issues = JSON.parse(stdout);
96
+ if (Array.isArray(issues) && issues.length > 0) {
97
+ const issue = issues[0];
98
+ return {
99
+ id: issue.id,
100
+ title: issue.title || issue.description?.substring(0, 50) || 'Untitled',
101
+ status: issue.status,
102
+ blockedBy: issue.blockedBy || [],
103
+ };
104
+ }
105
+ return null;
106
+ } catch {
107
+ // Try parsing as single issue or line-based format
108
+ const lines = stdout.split('\n').filter(l => l.trim());
109
+ if (lines.length > 0) {
110
+ // Extract ID from first line (format: "bd-1: title" or just "bd-1")
111
+ const match = lines[0].match(/^(bd-\d+)/);
112
+ if (match) {
113
+ return getBeadsIssue(match[1]);
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Mark beads issue as in_progress
122
+ */
123
+ async function claimIssue(id: string): Promise<boolean> {
124
+ const { exitCode } = await runBd(['update', id, '--status', 'in_progress']);
125
+ return exitCode === 0;
126
+ }
127
+
128
+ /**
129
+ * Get current tmux session name
130
+ */
131
+ async function getCurrentSession(): Promise<string | null> {
132
+ try {
133
+ const result = await tmux.executeTmux(`display-message -p '#{session_name}'`);
134
+ return result.trim() || null;
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Create worktree for worker
142
+ * Uses bd worktree when beads registry is enabled (auto .beads redirect)
143
+ * Falls back to WorktreeManager otherwise
144
+ */
145
+ async function createWorktree(
146
+ taskId: string,
147
+ repoPath: string
148
+ ): Promise<string | null> {
149
+ // Try bd worktree first when beads is enabled
150
+ if (useBeads) {
151
+ try {
152
+ // Check if worktree exists via beads
153
+ const existing = await beadsRegistry.getWorktree(taskId);
154
+ if (existing) {
155
+ console.log(`ℹ️ Worktree for ${taskId} already exists`);
156
+ return existing.path;
157
+ }
158
+
159
+ // Create via bd worktree (includes .beads redirect)
160
+ const info = await beadsRegistry.createWorktree(taskId);
161
+ if (info) {
162
+ return info.path;
163
+ }
164
+ // Fall through to WorktreeManager if bd worktree fails
165
+ console.log(`⚠️ bd worktree failed, falling back to git worktree`);
166
+ } catch (error: any) {
167
+ console.log(`⚠️ bd worktree error: ${error.message}, falling back`);
168
+ }
169
+ }
170
+
171
+ // Fallback to WorktreeManager
172
+ try {
173
+ const manager = new WorktreeManager({
174
+ baseDir: WORKTREE_BASE,
175
+ repoPath,
176
+ });
177
+
178
+ // Check if worktree already exists
179
+ if (await manager.worktreeExists(taskId)) {
180
+ console.log(`ℹ️ Worktree for ${taskId} already exists`);
181
+ return manager.getWorktreePath(taskId);
182
+ }
183
+
184
+ // Create new worktree with branch
185
+ const info = await manager.createWorktree(taskId, true);
186
+ return info.path;
187
+ } catch (error: any) {
188
+ console.error(`⚠️ Failed to create worktree: ${error.message}`);
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Spawn Claude worker in new pane
195
+ */
196
+ async function spawnWorkerPane(
197
+ session: string,
198
+ workingDir: string
199
+ ): Promise<{ paneId: string } | null> {
200
+ try {
201
+ // Find current window
202
+ const sessionObj = await tmux.findSessionByName(session);
203
+ if (!sessionObj) {
204
+ console.error(`❌ Session "${session}" not found`);
205
+ return null;
206
+ }
207
+
208
+ const windows = await tmux.listWindows(sessionObj.id);
209
+ if (!windows || windows.length === 0) {
210
+ console.error(`❌ No windows in session "${session}"`);
211
+ return null;
212
+ }
213
+
214
+ const panes = await tmux.listPanes(windows[0].id);
215
+ if (!panes || panes.length === 0) {
216
+ console.error(`❌ No panes in session "${session}"`);
217
+ return null;
218
+ }
219
+
220
+ // Split current pane horizontally (side by side)
221
+ const newPane = await tmux.splitPane(
222
+ panes[0].id,
223
+ 'horizontal',
224
+ 50,
225
+ workingDir
226
+ );
227
+
228
+ if (!newPane) {
229
+ console.error(`❌ Failed to create new pane`);
230
+ return null;
231
+ }
232
+
233
+ return { paneId: newPane.id };
234
+ } catch (error: any) {
235
+ console.error(`❌ Error spawning worker pane: ${error.message}`);
236
+ return null;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Start monitoring worker state and update registry
242
+ * Updates both beads and JSON registry during transition
243
+ */
244
+ function startWorkerMonitoring(
245
+ workerId: string,
246
+ session: string,
247
+ paneId: string
248
+ ): void {
249
+ const monitor = new EventMonitor(session, {
250
+ pollIntervalMs: 1000,
251
+ paneId,
252
+ });
253
+
254
+ monitor.on('state_change', async (event) => {
255
+ if (!event.state) return;
256
+
257
+ let newState: registry.WorkerState;
258
+ switch (event.state.type) {
259
+ case 'working':
260
+ case 'tool_use':
261
+ newState = 'working';
262
+ break;
263
+ case 'idle':
264
+ newState = 'idle';
265
+ break;
266
+ case 'permission':
267
+ newState = 'permission';
268
+ break;
269
+ case 'question':
270
+ newState = 'question';
271
+ break;
272
+ case 'error':
273
+ newState = 'error';
274
+ break;
275
+ case 'complete':
276
+ newState = 'done';
277
+ break;
278
+ default:
279
+ return; // Don't update for unknown states
280
+ }
281
+
282
+ try {
283
+ // Update both registries during transition
284
+ if (useBeads) {
285
+ await beadsRegistry.updateState(workerId, newState);
286
+ }
287
+ await registry.updateState(workerId, newState);
288
+ } catch {
289
+ // Ignore errors in background monitoring
290
+ }
291
+ });
292
+
293
+ monitor.on('poll_error', () => {
294
+ // Pane may have been killed - unregister worker
295
+ if (useBeads) {
296
+ beadsRegistry.unregister(workerId).catch(() => {});
297
+ }
298
+ registry.unregister(workerId).catch(() => {});
299
+ monitor.stop();
300
+ });
301
+
302
+ monitor.start().catch(() => {
303
+ // Session/pane not found - ignore
304
+ });
305
+
306
+ // Store monitor reference for cleanup (could be enhanced)
307
+ // For now, monitoring is fire-and-forget
308
+ }
309
+
310
+ // ============================================================================
311
+ // Main Command
312
+ // ============================================================================
313
+
314
+ export async function workCommand(
315
+ target: string,
316
+ options: WorkOptions = {}
317
+ ): Promise<void> {
318
+ try {
319
+ // Get current working directory as repo path
320
+ const repoPath = process.cwd();
321
+
322
+ // Ensure beads daemon is running for auto-sync
323
+ if (useBeads) {
324
+ const daemonStatus = await beadsRegistry.checkDaemonStatus();
325
+ if (!daemonStatus.running) {
326
+ console.log('🔄 Starting beads daemon for auto-sync...');
327
+ const started = await beadsRegistry.startDaemon({ autoCommit: true });
328
+ if (started) {
329
+ console.log(' ✅ Daemon started');
330
+ } else {
331
+ console.log(' ⚠️ Daemon failed to start (non-fatal)');
332
+ }
333
+ }
334
+ }
335
+
336
+ // 1. Resolve target
337
+ let issue: BeadsIssue | null = null;
338
+
339
+ if (target === 'next') {
340
+ console.log('🔍 Finding next ready issue...');
341
+ issue = await getNextReadyIssue();
342
+ if (!issue) {
343
+ console.log('ℹ️ No ready issues. Run `bd ready` to see the queue.');
344
+ return;
345
+ }
346
+ console.log(`📋 Found: ${issue.id} - "${issue.title}"`);
347
+ } else if (target === 'wish') {
348
+ console.error('❌ `term work wish` is not yet implemented. Coming in Phase 1.5.');
349
+ process.exit(1);
350
+ } else {
351
+ // Validate bd-id exists
352
+ issue = await getBeadsIssue(target);
353
+ if (!issue) {
354
+ console.error(`❌ Issue "${target}" not found. Run \`bd list\` to see issues.`);
355
+ process.exit(1);
356
+ }
357
+ }
358
+
359
+ const taskId = issue.id;
360
+
361
+ // 2. Check not already assigned (check both registries)
362
+ let existingWorker = useBeads
363
+ ? await beadsRegistry.findByTask(taskId)
364
+ : null;
365
+ if (!existingWorker) {
366
+ existingWorker = await registry.findByTask(taskId);
367
+ }
368
+ if (existingWorker) {
369
+ console.error(`❌ ${taskId} already has a worker (pane ${existingWorker.paneId})`);
370
+ console.log(` Run \`term kill ${existingWorker.id}\` first, or work on a different issue.`);
371
+ process.exit(1);
372
+ }
373
+
374
+ // 3. Get session
375
+ const session = options.session || await getCurrentSession();
376
+ if (!session) {
377
+ console.error('❌ Not in a tmux session. Attach to a session first or use --session.');
378
+ process.exit(1);
379
+ }
380
+
381
+ // 4. Claim in beads
382
+ console.log(`📝 Claiming ${taskId}...`);
383
+ const claimed = await claimIssue(taskId);
384
+ if (!claimed) {
385
+ console.error(`❌ Failed to claim ${taskId}. Check \`bd show ${taskId}\`.`);
386
+ process.exit(1);
387
+ }
388
+
389
+ // 5. Create worktree (unless --no-worktree)
390
+ let workingDir = repoPath;
391
+ let worktreePath: string | null = null;
392
+
393
+ if (!options.noWorktree) {
394
+ console.log(`🌳 Creating worktree for ${taskId}...`);
395
+ worktreePath = await createWorktree(taskId, repoPath);
396
+ if (worktreePath) {
397
+ workingDir = worktreePath;
398
+ console.log(` Created: ${worktreePath}`);
399
+ } else {
400
+ console.log(`⚠️ Worktree creation failed. Using shared repo.`);
401
+ }
402
+ }
403
+
404
+ // 6. Spawn Claude pane
405
+ console.log(`🚀 Spawning worker pane...`);
406
+ const paneResult = await spawnWorkerPane(session, workingDir);
407
+ if (!paneResult) {
408
+ process.exit(1);
409
+ }
410
+
411
+ const { paneId } = paneResult;
412
+
413
+ // 7. Register worker (write to both registries during transition)
414
+ const worker: registry.Worker = {
415
+ id: taskId,
416
+ paneId,
417
+ session,
418
+ worktree: worktreePath,
419
+ taskId,
420
+ taskTitle: issue.title,
421
+ startedAt: new Date().toISOString(),
422
+ state: 'spawning',
423
+ lastStateChange: new Date().toISOString(),
424
+ repoPath,
425
+ };
426
+
427
+ // Register in beads (creates agent bead)
428
+ if (useBeads) {
429
+ try {
430
+ const agentId = await beadsRegistry.ensureAgent(taskId, {
431
+ paneId,
432
+ session,
433
+ worktree: worktreePath,
434
+ repoPath,
435
+ taskId,
436
+ taskTitle: issue.title,
437
+ });
438
+
439
+ // Bind work to agent
440
+ await beadsRegistry.bindWork(taskId, taskId);
441
+
442
+ // Set initial state
443
+ await beadsRegistry.setAgentState(taskId, 'spawning');
444
+ } catch (error: any) {
445
+ console.log(`⚠️ Beads registration failed: ${error.message} (non-fatal)`);
446
+ }
447
+ }
448
+
449
+ // Also register in JSON registry (parallel operation during transition)
450
+ await registry.register(worker);
451
+
452
+ // 8. Start Claude in pane
453
+ await tmux.executeCommand(paneId, 'claude', false, false);
454
+
455
+ // Wait a bit for Claude to start
456
+ await new Promise(r => setTimeout(r, 2000));
457
+
458
+ // 9. Send initial prompt
459
+ const prompt = options.prompt || `Work on beads issue ${taskId}: "${issue.title}"
460
+
461
+ Read the issue details with: bd show ${taskId}
462
+
463
+ When you're done, commit your changes and let me know.`;
464
+
465
+ await tmux.executeCommand(paneId, prompt, false, false);
466
+
467
+ // 10. Update state to working (both registries)
468
+ if (useBeads) {
469
+ await beadsRegistry.setAgentState(taskId, 'working').catch(() => {});
470
+ }
471
+ await registry.updateState(taskId, 'working');
472
+
473
+ // 11. Start monitoring
474
+ startWorkerMonitoring(taskId, session, paneId);
475
+
476
+ // 12. Focus pane (unless disabled)
477
+ if (options.focus !== false) {
478
+ await tmux.executeTmux(`select-pane -t '${paneId}'`);
479
+ }
480
+
481
+ console.log(`\n✅ Worker started for ${taskId}`);
482
+ console.log(` Pane: ${paneId}`);
483
+ console.log(` Session: ${session}`);
484
+ if (worktreePath) {
485
+ console.log(` Worktree: ${worktreePath}`);
486
+ }
487
+ console.log(`\nCommands:`);
488
+ console.log(` term workers - Check worker status`);
489
+ console.log(` term approve ${taskId} - Approve permissions`);
490
+ console.log(` term close ${taskId} - Close issue when done`);
491
+ console.log(` term kill ${taskId} - Force kill worker`);
492
+
493
+ } catch (error: any) {
494
+ console.error(`❌ Error: ${error.message}`);
495
+ process.exit(1);
496
+ }
497
+ }