@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,229 @@
1
+ /**
2
+ * Worker Registry - Manages worker state persistence
3
+ *
4
+ * Tracks Claude workers bound to beads issues, storing state in
5
+ * ~/.config/term/workers.json
6
+ */
7
+
8
+ import { mkdir, readFile, writeFile } from 'fs/promises';
9
+ import { join, dirname } from 'path';
10
+ import { homedir } from 'os';
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ export type WorkerState =
17
+ | 'spawning' // Worker being created
18
+ | 'working' // Actively producing output
19
+ | 'idle' // At prompt, waiting for input
20
+ | 'permission' // Waiting for permission approval
21
+ | 'question' // Waiting for question answer
22
+ | 'done' // Task completed, ready for close
23
+ | 'error'; // Encountered error
24
+
25
+ export interface Worker {
26
+ /** Unique worker ID (usually matches taskId, e.g., "bd-42") */
27
+ id: string;
28
+ /** tmux pane ID (e.g., "%16") */
29
+ paneId: string;
30
+ /** tmux session name */
31
+ session: string;
32
+ /** Path to git worktree, null if using shared repo */
33
+ worktree: string | null;
34
+ /** Beads issue ID this worker is bound to */
35
+ taskId: string;
36
+ /** Task title from beads */
37
+ taskTitle?: string;
38
+ /** Associated wish slug (if from decompose) */
39
+ wishSlug?: string;
40
+ /** Execution group number within wish */
41
+ groupNumber?: number;
42
+ /** ISO timestamp when worker was started */
43
+ startedAt: string;
44
+ /** Current worker state */
45
+ state: WorkerState;
46
+ /** Last state change timestamp */
47
+ lastStateChange: string;
48
+ /** Repository path where worker operates */
49
+ repoPath: string;
50
+ }
51
+
52
+ export interface WorkerRegistry {
53
+ workers: Record<string, Worker>;
54
+ lastUpdated: string;
55
+ }
56
+
57
+ // ============================================================================
58
+ // Configuration
59
+ // ============================================================================
60
+
61
+ const CONFIG_DIR = join(homedir(), '.config', 'term');
62
+ const REGISTRY_FILE = join(CONFIG_DIR, 'workers.json');
63
+
64
+ // ============================================================================
65
+ // Private Functions
66
+ // ============================================================================
67
+
68
+ async function ensureConfigDir(): Promise<void> {
69
+ await mkdir(CONFIG_DIR, { recursive: true });
70
+ }
71
+
72
+ async function loadRegistry(): Promise<WorkerRegistry> {
73
+ try {
74
+ const content = await readFile(REGISTRY_FILE, 'utf-8');
75
+ return JSON.parse(content);
76
+ } catch {
77
+ return { workers: {}, lastUpdated: new Date().toISOString() };
78
+ }
79
+ }
80
+
81
+ async function saveRegistry(registry: WorkerRegistry): Promise<void> {
82
+ await ensureConfigDir();
83
+ registry.lastUpdated = new Date().toISOString();
84
+ await writeFile(REGISTRY_FILE, JSON.stringify(registry, null, 2));
85
+ }
86
+
87
+ // ============================================================================
88
+ // Public API
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Register a new worker in the registry
93
+ */
94
+ export async function register(worker: Worker): Promise<void> {
95
+ const registry = await loadRegistry();
96
+ registry.workers[worker.id] = worker;
97
+ await saveRegistry(registry);
98
+ }
99
+
100
+ /**
101
+ * Unregister (remove) a worker from the registry
102
+ */
103
+ export async function unregister(id: string): Promise<void> {
104
+ const registry = await loadRegistry();
105
+ delete registry.workers[id];
106
+ await saveRegistry(registry);
107
+ }
108
+
109
+ /**
110
+ * Get a worker by ID
111
+ */
112
+ export async function get(id: string): Promise<Worker | null> {
113
+ const registry = await loadRegistry();
114
+ return registry.workers[id] || null;
115
+ }
116
+
117
+ /**
118
+ * List all workers
119
+ */
120
+ export async function list(): Promise<Worker[]> {
121
+ const registry = await loadRegistry();
122
+ return Object.values(registry.workers);
123
+ }
124
+
125
+ /**
126
+ * Update a worker's state
127
+ */
128
+ export async function updateState(id: string, state: WorkerState): Promise<void> {
129
+ const registry = await loadRegistry();
130
+ const worker = registry.workers[id];
131
+ if (worker) {
132
+ worker.state = state;
133
+ worker.lastStateChange = new Date().toISOString();
134
+ await saveRegistry(registry);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Update multiple worker fields
140
+ */
141
+ export async function update(id: string, updates: Partial<Worker>): Promise<void> {
142
+ const registry = await loadRegistry();
143
+ const worker = registry.workers[id];
144
+ if (worker) {
145
+ Object.assign(worker, updates);
146
+ if (updates.state) {
147
+ worker.lastStateChange = new Date().toISOString();
148
+ }
149
+ await saveRegistry(registry);
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Find worker by tmux pane ID
155
+ */
156
+ export async function findByPane(paneId: string): Promise<Worker | null> {
157
+ const workers = await list();
158
+ // Normalize pane ID (with or without % prefix)
159
+ const normalizedPaneId = paneId.startsWith('%') ? paneId : `%${paneId}`;
160
+ return workers.find(w => w.paneId === normalizedPaneId) || null;
161
+ }
162
+
163
+ /**
164
+ * Find worker by beads task ID
165
+ */
166
+ export async function findByTask(taskId: string): Promise<Worker | null> {
167
+ const workers = await list();
168
+ return workers.find(w => w.taskId === taskId) || null;
169
+ }
170
+
171
+ /**
172
+ * Find workers by wish slug
173
+ */
174
+ export async function findByWish(wishSlug: string): Promise<Worker[]> {
175
+ const workers = await list();
176
+ return workers.filter(w => w.wishSlug === wishSlug);
177
+ }
178
+
179
+ /**
180
+ * Check if a worker exists for a given task
181
+ */
182
+ export async function hasWorkerForTask(taskId: string): Promise<boolean> {
183
+ const worker = await findByTask(taskId);
184
+ return worker !== null;
185
+ }
186
+
187
+ /**
188
+ * Get workers in a specific state
189
+ */
190
+ export async function getByState(state: WorkerState): Promise<Worker[]> {
191
+ const workers = await list();
192
+ return workers.filter(w => w.state === state);
193
+ }
194
+
195
+ /**
196
+ * Calculate elapsed time for a worker
197
+ */
198
+ export function getElapsedTime(worker: Worker): { ms: number; formatted: string } {
199
+ const startTime = new Date(worker.startedAt).getTime();
200
+ const ms = Date.now() - startTime;
201
+
202
+ const minutes = Math.floor(ms / 60000);
203
+ const hours = Math.floor(minutes / 60);
204
+
205
+ let formatted: string;
206
+ if (hours > 0) {
207
+ formatted = `${hours}h ${minutes % 60}m`;
208
+ } else if (minutes > 0) {
209
+ formatted = `${minutes}m`;
210
+ } else {
211
+ formatted = '<1m';
212
+ }
213
+
214
+ return { ms, formatted };
215
+ }
216
+
217
+ /**
218
+ * Get the config directory path
219
+ */
220
+ export function getConfigDir(): string {
221
+ return CONFIG_DIR;
222
+ }
223
+
224
+ /**
225
+ * Get the registry file path
226
+ */
227
+ export function getRegistryPath(): string {
228
+ return REGISTRY_FILE;
229
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Close command - Close beads issue and cleanup worker
3
+ *
4
+ * Usage:
5
+ * term close <bd-id> - Close issue, cleanup worktree, kill worker
6
+ *
7
+ * Options:
8
+ * --no-sync - Skip bd sync
9
+ * --keep-worktree - Don't remove the worktree
10
+ * --merge - Merge worktree changes to main branch
11
+ * -y, --yes - Skip confirmation
12
+ */
13
+
14
+ import { $ } from 'bun';
15
+ import { confirm } from '@inquirer/prompts';
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 { join } from 'path';
21
+ import { homedir } from 'os';
22
+
23
+ // Use beads registry when enabled
24
+ const useBeads = beadsRegistry.isBeadsRegistryEnabled();
25
+
26
+ // ============================================================================
27
+ // Types
28
+ // ============================================================================
29
+
30
+ export interface CloseOptions {
31
+ noSync?: boolean;
32
+ keepWorktree?: boolean;
33
+ merge?: boolean;
34
+ yes?: boolean;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Configuration
39
+ // ============================================================================
40
+
41
+ const WORKTREE_BASE = join(homedir(), '.local', 'share', 'term', 'worktrees');
42
+
43
+ // ============================================================================
44
+ // Helper Functions
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Run bd command
49
+ */
50
+ async function runBd(args: string[]): Promise<{ stdout: string; exitCode: number }> {
51
+ try {
52
+ const result = await $`bd ${args}`.quiet();
53
+ return { stdout: result.stdout.toString().trim(), exitCode: 0 };
54
+ } catch (error: any) {
55
+ return { stdout: error.stdout?.toString().trim() || '', exitCode: error.exitCode || 1 };
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Close beads issue
61
+ */
62
+ async function closeBeadsIssue(taskId: string): Promise<boolean> {
63
+ const { exitCode } = await runBd(['close', taskId]);
64
+ return exitCode === 0;
65
+ }
66
+
67
+ /**
68
+ * Sync beads to git
69
+ */
70
+ async function syncBeads(): Promise<boolean> {
71
+ const { exitCode } = await runBd(['sync']);
72
+ return exitCode === 0;
73
+ }
74
+
75
+ /**
76
+ * Merge worktree branch to main
77
+ */
78
+ async function mergeToMain(
79
+ repoPath: string,
80
+ branchName: string
81
+ ): Promise<boolean> {
82
+ try {
83
+ // Get current branch
84
+ const currentResult = await $`git -C ${repoPath} branch --show-current`.quiet();
85
+ const currentBranch = currentResult.stdout.toString().trim();
86
+
87
+ if (currentBranch === branchName) {
88
+ console.log(`⚠️ Already on branch ${branchName}. Skipping merge.`);
89
+ return true;
90
+ }
91
+
92
+ // Checkout main and merge
93
+ console.log(` Switching to ${currentBranch}...`);
94
+ await $`git -C ${repoPath} checkout ${currentBranch}`.quiet();
95
+
96
+ console.log(` Merging ${branchName}...`);
97
+ await $`git -C ${repoPath} merge ${branchName} --no-edit`.quiet();
98
+
99
+ return true;
100
+ } catch (error: any) {
101
+ console.error(`⚠️ Merge failed: ${error.message}`);
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Remove worktree
108
+ * Uses bd worktree when beads registry is enabled
109
+ * Falls back to WorktreeManager otherwise
110
+ */
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
124
+ try {
125
+ const manager = new WorktreeManager({
126
+ baseDir: WORKTREE_BASE,
127
+ repoPath,
128
+ });
129
+
130
+ if (await manager.worktreeExists(taskId)) {
131
+ await manager.removeWorktree(taskId);
132
+ return true;
133
+ }
134
+ return true; // Already doesn't exist
135
+ } catch (error: any) {
136
+ console.error(`⚠️ Failed to remove worktree: ${error.message}`);
137
+ return false;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Kill worker pane
143
+ */
144
+ async function killWorkerPane(paneId: string): Promise<boolean> {
145
+ try {
146
+ await tmux.killPane(paneId);
147
+ return true;
148
+ } catch {
149
+ return false; // Pane may already be gone
150
+ }
151
+ }
152
+
153
+ // ============================================================================
154
+ // Main Command
155
+ // ============================================================================
156
+
157
+ export async function closeCommand(
158
+ taskId: string,
159
+ options: CloseOptions = {}
160
+ ): Promise<void> {
161
+ try {
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
+ }
169
+
170
+ if (!worker) {
171
+ console.log(`ℹ️ No active worker for ${taskId}. Closing issue only.`);
172
+ }
173
+
174
+ // Confirm with user
175
+ if (!options.yes) {
176
+ const confirmed = await confirm({
177
+ message: `Close ${taskId}${worker ? ` and kill worker (pane ${worker.paneId})` : ''}?`,
178
+ default: true,
179
+ });
180
+
181
+ if (!confirmed) {
182
+ console.log('Cancelled.');
183
+ return;
184
+ }
185
+ }
186
+
187
+ // 1. Close beads issue
188
+ console.log(`📝 Closing ${taskId}...`);
189
+ const closed = await closeBeadsIssue(taskId);
190
+ if (!closed) {
191
+ console.error(`❌ Failed to close ${taskId}. Check \`bd show ${taskId}\`.`);
192
+ // Continue with cleanup anyway
193
+ } else {
194
+ console.log(` ✅ Issue closed`);
195
+ }
196
+
197
+ // 2. Sync beads (unless --no-sync)
198
+ if (!options.noSync) {
199
+ console.log(`🔄 Syncing beads...`);
200
+ const synced = await syncBeads();
201
+ if (synced) {
202
+ console.log(` ✅ Synced to git`);
203
+ } else {
204
+ console.log(` ⚠️ Sync failed (non-fatal)`);
205
+ }
206
+ }
207
+
208
+ // 3. Handle worktree
209
+ if (worker?.worktree && !options.keepWorktree) {
210
+ // Merge if requested
211
+ if (options.merge) {
212
+ console.log(`🔀 Merging changes...`);
213
+ const merged = await mergeToMain(worker.repoPath, taskId);
214
+ if (merged) {
215
+ console.log(` ✅ Merged to main`);
216
+ }
217
+ }
218
+
219
+ // Remove worktree
220
+ console.log(`🌳 Removing worktree...`);
221
+ const removed = await removeWorktree(taskId, worker.repoPath);
222
+ if (removed) {
223
+ console.log(` ✅ Worktree removed`);
224
+ }
225
+ }
226
+
227
+ // 4. Kill worker pane
228
+ if (worker) {
229
+ console.log(`💀 Killing worker pane...`);
230
+ await killWorkerPane(worker.paneId);
231
+ console.log(` ✅ Pane killed`);
232
+
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
+ }
246
+ await registry.unregister(worker.id);
247
+ console.log(` ✅ Worker unregistered`);
248
+ }
249
+
250
+ console.log(`\n✅ ${taskId} closed successfully`);
251
+
252
+ } catch (error: any) {
253
+ console.error(`❌ Error: ${error.message}`);
254
+ process.exit(1);
255
+ }
256
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Daemon command - Manage beads daemon
3
+ *
4
+ * Usage:
5
+ * term daemon start - Start beads daemon (auto-commit, auto-sync)
6
+ * term daemon stop - Stop beads daemon
7
+ * term daemon status - Show daemon status
8
+ * term daemon restart - Restart daemon
9
+ *
10
+ * Options:
11
+ * --auto-commit - Enable auto-commit (default: true for start)
12
+ * --auto-push - Enable auto-push to remote
13
+ * --json - Output as JSON
14
+ */
15
+
16
+ import * as beadsRegistry from '../lib/beads-registry.js';
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ export interface DaemonStartOptions {
23
+ autoCommit?: boolean;
24
+ autoPush?: boolean;
25
+ }
26
+
27
+ export interface DaemonStatusOptions {
28
+ json?: boolean;
29
+ }
30
+
31
+ // ============================================================================
32
+ // Commands
33
+ // ============================================================================
34
+
35
+ /**
36
+ * Start the beads daemon
37
+ */
38
+ export async function startCommand(options: DaemonStartOptions = {}): Promise<void> {
39
+ try {
40
+ // Check if already running
41
+ const status = await beadsRegistry.checkDaemonStatus();
42
+ if (status.running) {
43
+ console.log('ℹ️ Daemon is already running');
44
+ if (status.pid) {
45
+ console.log(` PID: ${status.pid}`);
46
+ }
47
+ return;
48
+ }
49
+
50
+ console.log('🚀 Starting beads daemon...');
51
+ const started = await beadsRegistry.startDaemon({
52
+ autoCommit: options.autoCommit !== false, // Default to true
53
+ autoPush: options.autoPush,
54
+ });
55
+
56
+ if (started) {
57
+ console.log(' ✅ Daemon started');
58
+
59
+ // Show updated status
60
+ const newStatus = await beadsRegistry.checkDaemonStatus();
61
+ if (newStatus.pid) {
62
+ console.log(` PID: ${newStatus.pid}`);
63
+ }
64
+ if (newStatus.autoCommit) {
65
+ console.log(' Auto-commit: enabled');
66
+ }
67
+ if (newStatus.autoPush) {
68
+ console.log(' Auto-push: enabled');
69
+ }
70
+ } else {
71
+ console.error('❌ Failed to start daemon');
72
+ console.log(' Check `bd daemon status` for details');
73
+ process.exit(1);
74
+ }
75
+ } catch (error: any) {
76
+ console.error(`❌ Error: ${error.message}`);
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Stop the beads daemon
83
+ */
84
+ export async function stopCommand(): Promise<void> {
85
+ try {
86
+ // Check if running
87
+ const status = await beadsRegistry.checkDaemonStatus();
88
+ if (!status.running) {
89
+ console.log('ℹ️ Daemon is not running');
90
+ return;
91
+ }
92
+
93
+ console.log('🛑 Stopping beads daemon...');
94
+ const stopped = await beadsRegistry.stopDaemon();
95
+
96
+ if (stopped) {
97
+ console.log(' ✅ Daemon stopped');
98
+ } else {
99
+ console.error('❌ Failed to stop daemon');
100
+ process.exit(1);
101
+ }
102
+ } catch (error: any) {
103
+ console.error(`❌ Error: ${error.message}`);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Show daemon status
110
+ */
111
+ export async function statusCommand(options: DaemonStatusOptions = {}): Promise<void> {
112
+ try {
113
+ const status = await beadsRegistry.checkDaemonStatus();
114
+
115
+ if (options.json) {
116
+ console.log(JSON.stringify(status, null, 2));
117
+ return;
118
+ }
119
+
120
+ console.log('Beads Daemon Status');
121
+ console.log('───────────────────');
122
+ console.log(`Running: ${status.running ? '✅ yes' : '❌ no'}`);
123
+
124
+ if (status.running) {
125
+ if (status.pid) {
126
+ console.log(`PID: ${status.pid}`);
127
+ }
128
+ if (status.lastSync) {
129
+ console.log(`Last sync: ${status.lastSync}`);
130
+ }
131
+ if (status.autoCommit !== undefined) {
132
+ console.log(`Auto-commit: ${status.autoCommit ? 'enabled' : 'disabled'}`);
133
+ }
134
+ if (status.autoPush !== undefined) {
135
+ console.log(`Auto-push: ${status.autoPush ? 'enabled' : 'disabled'}`);
136
+ }
137
+ } else {
138
+ console.log('\nRun `term daemon start` to start the daemon');
139
+ }
140
+ } catch (error: any) {
141
+ console.error(`❌ Error: ${error.message}`);
142
+ process.exit(1);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Restart the beads daemon
148
+ */
149
+ export async function restartCommand(options: DaemonStartOptions = {}): Promise<void> {
150
+ try {
151
+ // Check if running and stop
152
+ const status = await beadsRegistry.checkDaemonStatus();
153
+ if (status.running) {
154
+ console.log('🛑 Stopping beads daemon...');
155
+ await beadsRegistry.stopDaemon();
156
+ console.log(' ✅ Stopped');
157
+ }
158
+
159
+ // Start with new options
160
+ console.log('🚀 Starting beads daemon...');
161
+ const started = await beadsRegistry.startDaemon({
162
+ autoCommit: options.autoCommit !== false,
163
+ autoPush: options.autoPush,
164
+ });
165
+
166
+ if (started) {
167
+ console.log(' ✅ Daemon restarted');
168
+ } else {
169
+ console.error('❌ Failed to restart daemon');
170
+ process.exit(1);
171
+ }
172
+ } catch (error: any) {
173
+ console.error(`❌ Error: ${error.message}`);
174
+ process.exit(1);
175
+ }
176
+ }