@automagik/genie 0.260202.1833 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "0.260202.1833",
3
+ "version": "0.260202.1901",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Beads Registry - Worker state management via beads native commands
3
+ *
4
+ * Replaces JSON file-based worker-registry.ts with beads agent/slot system.
5
+ * Provides unified state tracking with Witness support and built-in cardinality.
6
+ *
7
+ * Environment:
8
+ * TERM_USE_BEADS_REGISTRY=false - Disable beads registry (fallback to JSON)
9
+ */
10
+
11
+ import { $ } from 'bun';
12
+ import type { Worker, WorkerState } from './worker-registry.js';
13
+
14
+ // ============================================================================
15
+ // Configuration
16
+ // ============================================================================
17
+
18
+ const AGENT_LABEL = 'gt:agent';
19
+
20
+ /**
21
+ * Check if beads registry is enabled (default: true)
22
+ */
23
+ export function isBeadsRegistryEnabled(): boolean {
24
+ return process.env.TERM_USE_BEADS_REGISTRY !== 'false';
25
+ }
26
+
27
+ // ============================================================================
28
+ // Helper Functions
29
+ // ============================================================================
30
+
31
+ /**
32
+ * Run bd command and parse output
33
+ */
34
+ async function runBd(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> {
35
+ try {
36
+ const result = await $`bd ${args}`.quiet();
37
+ return {
38
+ stdout: result.stdout.toString().trim(),
39
+ stderr: result.stderr.toString().trim(),
40
+ exitCode: 0,
41
+ };
42
+ } catch (error: any) {
43
+ return {
44
+ stdout: error.stdout?.toString().trim() || '',
45
+ stderr: error.stderr?.toString().trim() || '',
46
+ exitCode: error.exitCode || 1,
47
+ };
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Parse JSON from bd command, handling potential errors
53
+ */
54
+ function parseJson<T>(output: string): T | null {
55
+ if (!output) return null;
56
+ try {
57
+ return JSON.parse(output);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // ============================================================================
64
+ // Agent Bead Management
65
+ // ============================================================================
66
+
67
+ interface AgentBead {
68
+ id: string;
69
+ title: string;
70
+ type: string;
71
+ labels: string[];
72
+ state?: string;
73
+ metadata?: Record<string, unknown>;
74
+ }
75
+
76
+ /**
77
+ * Create a new agent bead for a worker
78
+ */
79
+ export async function createAgent(
80
+ workerId: string,
81
+ metadata: {
82
+ paneId: string;
83
+ session: string;
84
+ worktree: string | null;
85
+ repoPath: string;
86
+ taskId: string;
87
+ taskTitle?: string;
88
+ }
89
+ ): Promise<string> {
90
+ // Create agent bead with metadata
91
+ const title = `Worker: ${workerId}`;
92
+ const { stdout, exitCode } = await runBd([
93
+ 'create',
94
+ `--title=${title}`,
95
+ '--type=agent',
96
+ `--label=${AGENT_LABEL}`,
97
+ `--label=worker:${workerId}`,
98
+ '--json',
99
+ ]);
100
+
101
+ if (exitCode !== 0) {
102
+ throw new Error(`Failed to create agent bead: ${stdout}`);
103
+ }
104
+
105
+ const created = parseJson<{ id: string }>(stdout);
106
+ if (!created?.id) {
107
+ throw new Error('Failed to parse created agent bead');
108
+ }
109
+
110
+ // Store worker metadata in agent
111
+ const metadataJson = JSON.stringify({
112
+ paneId: metadata.paneId,
113
+ session: metadata.session,
114
+ worktree: metadata.worktree,
115
+ repoPath: metadata.repoPath,
116
+ taskId: metadata.taskId,
117
+ taskTitle: metadata.taskTitle,
118
+ startedAt: new Date().toISOString(),
119
+ });
120
+
121
+ await runBd(['update', created.id, `--metadata=${metadataJson}`]);
122
+
123
+ return created.id;
124
+ }
125
+
126
+ /**
127
+ * Ensure an agent bead exists for a worker, creating if needed
128
+ */
129
+ export async function ensureAgent(
130
+ workerId: string,
131
+ metadata: {
132
+ paneId: string;
133
+ session: string;
134
+ worktree: string | null;
135
+ repoPath: string;
136
+ taskId: string;
137
+ taskTitle?: string;
138
+ }
139
+ ): Promise<string> {
140
+ // Check if agent already exists
141
+ const existing = await findAgentByWorkerId(workerId);
142
+ if (existing) {
143
+ return existing.id;
144
+ }
145
+
146
+ return createAgent(workerId, metadata);
147
+ }
148
+
149
+ /**
150
+ * Find agent bead by worker ID
151
+ */
152
+ async function findAgentByWorkerId(workerId: string): Promise<AgentBead | null> {
153
+ const { stdout, exitCode } = await runBd([
154
+ 'list',
155
+ `--label=${AGENT_LABEL}`,
156
+ `--label=worker:${workerId}`,
157
+ '--json',
158
+ ]);
159
+
160
+ if (exitCode !== 0 || !stdout) return null;
161
+
162
+ const agents = parseJson<AgentBead[]>(stdout);
163
+ return agents?.[0] || null;
164
+ }
165
+
166
+ /**
167
+ * Delete an agent bead
168
+ */
169
+ export async function deleteAgent(agentIdOrWorkerId: string): Promise<boolean> {
170
+ // Try direct ID first
171
+ let { exitCode } = await runBd(['delete', agentIdOrWorkerId]);
172
+ if (exitCode === 0) return true;
173
+
174
+ // Try finding by worker ID
175
+ const agent = await findAgentByWorkerId(agentIdOrWorkerId);
176
+ if (agent) {
177
+ ({ exitCode } = await runBd(['delete', agent.id]));
178
+ return exitCode === 0;
179
+ }
180
+
181
+ return false;
182
+ }
183
+
184
+ // ============================================================================
185
+ // Agent State Management
186
+ // ============================================================================
187
+
188
+ /**
189
+ * Map our WorkerState to beads agent states
190
+ */
191
+ function mapToBeadsState(state: WorkerState): string {
192
+ switch (state) {
193
+ case 'spawning':
194
+ return 'spawning';
195
+ case 'working':
196
+ return 'working';
197
+ case 'idle':
198
+ return 'idle';
199
+ case 'permission':
200
+ return 'blocked';
201
+ case 'question':
202
+ return 'blocked';
203
+ case 'done':
204
+ return 'done';
205
+ case 'error':
206
+ return 'error';
207
+ default:
208
+ return 'unknown';
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Map beads agent state to WorkerState
214
+ */
215
+ function mapFromBeadsState(beadsState: string): WorkerState {
216
+ switch (beadsState) {
217
+ case 'spawning':
218
+ return 'spawning';
219
+ case 'working':
220
+ return 'working';
221
+ case 'idle':
222
+ return 'idle';
223
+ case 'blocked':
224
+ return 'permission'; // Could be permission or question
225
+ case 'done':
226
+ return 'done';
227
+ case 'error':
228
+ return 'error';
229
+ default:
230
+ return 'idle';
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Set agent state
236
+ */
237
+ export async function setAgentState(workerId: string, state: WorkerState): Promise<void> {
238
+ const agent = await findAgentByWorkerId(workerId);
239
+ if (!agent) {
240
+ throw new Error(`Agent not found for worker ${workerId}`);
241
+ }
242
+
243
+ const beadsState = mapToBeadsState(state);
244
+ const { exitCode, stderr } = await runBd(['agent', 'state', agent.id, beadsState]);
245
+
246
+ if (exitCode !== 0) {
247
+ throw new Error(`Failed to set agent state: ${stderr}`);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Send heartbeat for agent
253
+ */
254
+ export async function heartbeat(workerId: string): Promise<void> {
255
+ const agent = await findAgentByWorkerId(workerId);
256
+ if (!agent) return; // Silently ignore if agent doesn't exist
257
+
258
+ await runBd(['agent', 'heartbeat', agent.id]);
259
+ }
260
+
261
+ // ============================================================================
262
+ // Work Binding (Slots)
263
+ // ============================================================================
264
+
265
+ /**
266
+ * Bind work (task) to an agent
267
+ */
268
+ export async function bindWork(workerId: string, taskId: string): Promise<void> {
269
+ const agent = await findAgentByWorkerId(workerId);
270
+ if (!agent) {
271
+ throw new Error(`Agent not found for worker ${workerId}`);
272
+ }
273
+
274
+ const { exitCode, stderr } = await runBd(['slot', 'set', agent.id, 'hook', taskId]);
275
+
276
+ if (exitCode !== 0) {
277
+ throw new Error(`Failed to bind work: ${stderr}`);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Unbind work from an agent
283
+ */
284
+ export async function unbindWork(workerId: string): Promise<void> {
285
+ const agent = await findAgentByWorkerId(workerId);
286
+ if (!agent) return; // Silently ignore if agent doesn't exist
287
+
288
+ await runBd(['slot', 'clear', agent.id, 'hook']);
289
+ }
290
+
291
+ // ============================================================================
292
+ // Worker Query Functions
293
+ // ============================================================================
294
+
295
+ interface AgentMetadata {
296
+ paneId: string;
297
+ session: string;
298
+ worktree: string | null;
299
+ repoPath: string;
300
+ taskId: string;
301
+ taskTitle?: string;
302
+ startedAt: string;
303
+ wishSlug?: string;
304
+ groupNumber?: number;
305
+ }
306
+
307
+ /**
308
+ * Convert agent bead to Worker interface
309
+ */
310
+ function agentToWorker(agent: AgentBead, metadata: AgentMetadata): Worker {
311
+ return {
312
+ id: metadata.taskId, // Worker ID matches task ID
313
+ paneId: metadata.paneId,
314
+ session: metadata.session,
315
+ worktree: metadata.worktree,
316
+ taskId: metadata.taskId,
317
+ taskTitle: metadata.taskTitle,
318
+ wishSlug: metadata.wishSlug,
319
+ groupNumber: metadata.groupNumber,
320
+ startedAt: metadata.startedAt,
321
+ state: mapFromBeadsState(agent.state || 'idle'),
322
+ lastStateChange: new Date().toISOString(), // Would need to track this in beads
323
+ repoPath: metadata.repoPath,
324
+ };
325
+ }
326
+
327
+ /**
328
+ * Get a worker by ID
329
+ */
330
+ export async function getWorker(workerId: string): Promise<Worker | null> {
331
+ const agent = await findAgentByWorkerId(workerId);
332
+ if (!agent) return null;
333
+
334
+ // Get full agent details with metadata
335
+ const { stdout, exitCode } = await runBd(['show', agent.id, '--json']);
336
+ if (exitCode !== 0 || !stdout) return null;
337
+
338
+ const fullAgent = parseJson<AgentBead & { metadata?: AgentMetadata }>(stdout);
339
+ if (!fullAgent?.metadata) return null;
340
+
341
+ return agentToWorker(fullAgent, fullAgent.metadata);
342
+ }
343
+
344
+ /**
345
+ * List all workers
346
+ */
347
+ export async function listWorkers(): Promise<Worker[]> {
348
+ const { stdout, exitCode } = await runBd([
349
+ 'list',
350
+ `--label=${AGENT_LABEL}`,
351
+ '--json',
352
+ ]);
353
+
354
+ if (exitCode !== 0 || !stdout) return [];
355
+
356
+ const agents = parseJson<Array<AgentBead & { metadata?: AgentMetadata }>>(stdout);
357
+ if (!agents) return [];
358
+
359
+ const workers: Worker[] = [];
360
+ for (const agent of agents) {
361
+ if (agent.metadata) {
362
+ workers.push(agentToWorker(agent, agent.metadata));
363
+ }
364
+ }
365
+
366
+ return workers;
367
+ }
368
+
369
+ /**
370
+ * Find worker by pane ID
371
+ */
372
+ export async function findByPane(paneId: string): Promise<Worker | null> {
373
+ const workers = await listWorkers();
374
+ const normalizedPaneId = paneId.startsWith('%') ? paneId : `%${paneId}`;
375
+ return workers.find(w => w.paneId === normalizedPaneId) || null;
376
+ }
377
+
378
+ /**
379
+ * Find worker by task ID
380
+ */
381
+ export async function findByTask(taskId: string): Promise<Worker | null> {
382
+ // Worker ID typically matches task ID
383
+ return getWorker(taskId);
384
+ }
385
+
386
+ /**
387
+ * Check if a worker exists for a task
388
+ */
389
+ export async function hasWorkerForTask(taskId: string): Promise<boolean> {
390
+ const worker = await findByTask(taskId);
391
+ return worker !== null;
392
+ }
393
+
394
+ // ============================================================================
395
+ // Daemon Management
396
+ // ============================================================================
397
+
398
+ interface DaemonStatus {
399
+ running: boolean;
400
+ pid?: number;
401
+ lastSync?: string;
402
+ autoCommit?: boolean;
403
+ autoPush?: boolean;
404
+ }
405
+
406
+ /**
407
+ * Check if beads daemon is running
408
+ */
409
+ export async function checkDaemonStatus(): Promise<DaemonStatus> {
410
+ const { stdout, exitCode } = await runBd(['daemon', 'status', '--json']);
411
+
412
+ if (exitCode !== 0) {
413
+ return { running: false };
414
+ }
415
+
416
+ const status = parseJson<DaemonStatus>(stdout);
417
+ return status || { running: false };
418
+ }
419
+
420
+ /**
421
+ * Start beads daemon
422
+ */
423
+ export async function startDaemon(options?: { autoCommit?: boolean; autoPush?: boolean }): Promise<boolean> {
424
+ const args = ['daemon', 'start'];
425
+ if (options?.autoCommit) args.push('--auto-commit');
426
+ if (options?.autoPush) args.push('--auto-push');
427
+
428
+ const { exitCode } = await runBd(args);
429
+ return exitCode === 0;
430
+ }
431
+
432
+ /**
433
+ * Stop beads daemon
434
+ */
435
+ export async function stopDaemon(): Promise<boolean> {
436
+ const { exitCode } = await runBd(['daemon', 'stop']);
437
+ return exitCode === 0;
438
+ }
439
+
440
+ /**
441
+ * Ensure daemon is running, start if not
442
+ */
443
+ export async function ensureDaemon(options?: { autoCommit?: boolean }): Promise<boolean> {
444
+ const status = await checkDaemonStatus();
445
+ if (status.running) return true;
446
+
447
+ return startDaemon(options);
448
+ }
449
+
450
+ // ============================================================================
451
+ // Worktree Management (via bd worktree)
452
+ // ============================================================================
453
+
454
+ export interface BeadsWorktreeInfo {
455
+ path: string;
456
+ branch: string;
457
+ name: string;
458
+ }
459
+
460
+ /**
461
+ * Create worktree via beads
462
+ */
463
+ export async function createWorktree(name: string): Promise<BeadsWorktreeInfo | null> {
464
+ const { stdout, exitCode, stderr } = await runBd(['worktree', 'create', name, '--json']);
465
+
466
+ if (exitCode !== 0) {
467
+ console.error(`bd worktree create failed: ${stderr}`);
468
+ return null;
469
+ }
470
+
471
+ const info = parseJson<BeadsWorktreeInfo>(stdout);
472
+ return info;
473
+ }
474
+
475
+ /**
476
+ * Remove worktree via beads
477
+ */
478
+ export async function removeWorktree(name: string): Promise<boolean> {
479
+ const { exitCode } = await runBd(['worktree', 'remove', name]);
480
+ return exitCode === 0;
481
+ }
482
+
483
+ /**
484
+ * List worktrees via beads
485
+ */
486
+ export async function listWorktrees(): Promise<BeadsWorktreeInfo[]> {
487
+ const { stdout, exitCode } = await runBd(['worktree', 'list', '--json']);
488
+
489
+ if (exitCode !== 0 || !stdout) return [];
490
+
491
+ const worktrees = parseJson<BeadsWorktreeInfo[]>(stdout);
492
+ return worktrees || [];
493
+ }
494
+
495
+ /**
496
+ * Check if worktree exists via beads
497
+ */
498
+ export async function worktreeExists(name: string): Promise<boolean> {
499
+ const worktrees = await listWorktrees();
500
+ return worktrees.some(wt => wt.name === name || wt.branch === name);
501
+ }
502
+
503
+ /**
504
+ * Get worktree info via beads
505
+ */
506
+ export async function getWorktree(name: string): Promise<BeadsWorktreeInfo | null> {
507
+ const worktrees = await listWorktrees();
508
+ return worktrees.find(wt => wt.name === name || wt.branch === name) || null;
509
+ }
510
+
511
+ // ============================================================================
512
+ // Migration/Compatibility
513
+ // ============================================================================
514
+
515
+ /**
516
+ * Register a worker (compatibility with worker-registry interface)
517
+ * Writes to both beads and JSON registry during migration
518
+ */
519
+ export async function register(worker: Worker): Promise<void> {
520
+ await ensureAgent(worker.id, {
521
+ paneId: worker.paneId,
522
+ session: worker.session,
523
+ worktree: worker.worktree,
524
+ repoPath: worker.repoPath,
525
+ taskId: worker.taskId,
526
+ taskTitle: worker.taskTitle,
527
+ });
528
+
529
+ await setAgentState(worker.id, worker.state);
530
+ }
531
+
532
+ /**
533
+ * Unregister a worker (compatibility with worker-registry interface)
534
+ */
535
+ export async function unregister(workerId: string): Promise<void> {
536
+ await unbindWork(workerId);
537
+ await deleteAgent(workerId);
538
+ }
539
+
540
+ /**
541
+ * Update worker state (compatibility with worker-registry interface)
542
+ */
543
+ export async function updateState(workerId: string, state: WorkerState): Promise<void> {
544
+ await setAgentState(workerId, state);
545
+ await heartbeat(workerId);
546
+ }
package/src/lib/tmux.ts CHANGED
@@ -222,13 +222,22 @@ export async function killPane(paneId: string): Promise<void> {
222
222
  await executeTmux(`kill-pane -t '${paneId}'`);
223
223
  }
224
224
 
225
+ /**
226
+ * Escape a string for safe use in shell single quotes.
227
+ * Replaces ' with '\'' (end quote, escaped quote, start quote).
228
+ */
229
+ function escapeShellPath(path: string): string {
230
+ return path.replace(/'/g, "'\\''");
231
+ }
232
+
225
233
  /**
226
234
  * Split a tmux pane horizontally or vertically
227
235
  */
228
236
  export async function splitPane(
229
237
  targetPaneId: string,
230
238
  direction: 'horizontal' | 'vertical' = 'vertical',
231
- size?: number
239
+ size?: number,
240
+ workingDir?: string
232
241
  ): Promise<TmuxPane | null> {
233
242
  // Build the split-window command
234
243
  let splitCommand = 'split-window';
@@ -248,6 +257,11 @@ export async function splitPane(
248
257
  splitCommand += ` -p ${size}`;
249
258
  }
250
259
 
260
+ // Add working directory if specified
261
+ if (workingDir) {
262
+ splitCommand += ` -c '${escapeShellPath(workingDir)}'`;
263
+ }
264
+
251
265
  // Execute the split command
252
266
  await executeTmux(splitCommand);
253
267
 
@@ -1,5 +1,5 @@
1
1
  // Runtime version (baked in at build time)
2
- export const VERSION = '0.260202.1833';
2
+ export const VERSION = '0.260202.1901';
3
3
 
4
4
  // Generate version string from current datetime
5
5
  // Format: 0.YYMMDD.HHMM (e.g., 0.260201.1430 = Feb 1, 2026 at 14:30)
@@ -15,10 +15,14 @@ import { $ } from 'bun';
15
15
  import { confirm } from '@inquirer/prompts';
16
16
  import * as tmux from '../lib/tmux.js';
17
17
  import * as registry from '../lib/worker-registry.js';
18
+ import * as beadsRegistry from '../lib/beads-registry.js';
18
19
  import { WorktreeManager } from '../lib/worktree.js';
19
20
  import { join } from 'path';
20
21
  import { homedir } from 'os';
21
22
 
23
+ // Use beads registry when enabled
24
+ const useBeads = beadsRegistry.isBeadsRegistryEnabled();
25
+
22
26
  // ============================================================================
23
27
  // Types
24
28
  // ============================================================================
@@ -101,8 +105,22 @@ async function mergeToMain(
101
105
 
102
106
  /**
103
107
  * Remove worktree
108
+ * Uses bd worktree when beads registry is enabled
109
+ * Falls back to WorktreeManager otherwise
104
110
  */
105
111
  async function removeWorktree(taskId: string, repoPath: string): Promise<boolean> {
112
+ // Try bd worktree first when beads is enabled
113
+ if (useBeads) {
114
+ try {
115
+ const removed = await beadsRegistry.removeWorktree(taskId);
116
+ if (removed) return true;
117
+ // Fall through to WorktreeManager if bd worktree fails
118
+ } catch {
119
+ // Fall through
120
+ }
121
+ }
122
+
123
+ // Fallback to WorktreeManager
106
124
  try {
107
125
  const manager = new WorktreeManager({
108
126
  baseDir: WORKTREE_BASE,
@@ -141,8 +159,13 @@ export async function closeCommand(
141
159
  options: CloseOptions = {}
142
160
  ): Promise<void> {
143
161
  try {
144
- // Find worker in registry
145
- const worker = await registry.findByTask(taskId);
162
+ // Find worker in registry (check both during transition)
163
+ let worker = useBeads
164
+ ? await beadsRegistry.findByTask(taskId)
165
+ : null;
166
+ if (!worker) {
167
+ worker = await registry.findByTask(taskId);
168
+ }
146
169
 
147
170
  if (!worker) {
148
171
  console.log(`ℹ️ No active worker for ${taskId}. Closing issue only.`);
@@ -207,7 +230,19 @@ export async function closeCommand(
207
230
  await killWorkerPane(worker.paneId);
208
231
  console.log(` ✅ Pane killed`);
209
232
 
210
- // 5. Unregister worker
233
+ // 5. Unregister worker from both registries
234
+ if (useBeads) {
235
+ try {
236
+ // Unbind work from agent
237
+ await beadsRegistry.unbindWork(worker.id);
238
+ // Set agent state to done
239
+ await beadsRegistry.setAgentState(worker.id, 'done');
240
+ // Delete agent bead
241
+ await beadsRegistry.deleteAgent(worker.id);
242
+ } catch {
243
+ // Non-fatal if beads cleanup fails
244
+ }
245
+ }
211
246
  await registry.unregister(worker.id);
212
247
  console.log(` ✅ Worker unregistered`);
213
248
  }