@agentuity/opencode 0.1.41 → 0.1.42

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.
Files changed (41) hide show
  1. package/README.md +3 -10
  2. package/dist/agents/lead.d.ts +1 -1
  3. package/dist/agents/lead.d.ts.map +1 -1
  4. package/dist/agents/lead.js +2 -3
  5. package/dist/agents/lead.js.map +1 -1
  6. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  7. package/dist/plugin/hooks/cadence.js +3 -1
  8. package/dist/plugin/hooks/cadence.js.map +1 -1
  9. package/dist/plugin/plugin.d.ts.map +1 -1
  10. package/dist/plugin/plugin.js +49 -11
  11. package/dist/plugin/plugin.js.map +1 -1
  12. package/dist/skills/frontmatter.js +1 -1
  13. package/dist/skills/frontmatter.js.map +1 -1
  14. package/dist/tmux/executor.d.ts +29 -1
  15. package/dist/tmux/executor.d.ts.map +1 -1
  16. package/dist/tmux/executor.js +328 -13
  17. package/dist/tmux/executor.js.map +1 -1
  18. package/dist/tmux/index.d.ts +1 -1
  19. package/dist/tmux/index.d.ts.map +1 -1
  20. package/dist/tmux/index.js +1 -1
  21. package/dist/tmux/index.js.map +1 -1
  22. package/dist/tmux/manager.d.ts +36 -0
  23. package/dist/tmux/manager.d.ts.map +1 -1
  24. package/dist/tmux/manager.js +222 -10
  25. package/dist/tmux/manager.js.map +1 -1
  26. package/dist/tmux/state-query.d.ts.map +1 -1
  27. package/dist/tmux/state-query.js +4 -1
  28. package/dist/tmux/state-query.js.map +1 -1
  29. package/dist/tmux/types.d.ts +1 -0
  30. package/dist/tmux/types.d.ts.map +1 -1
  31. package/dist/tmux/types.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/agents/lead.ts +2 -3
  34. package/src/plugin/hooks/cadence.ts +2 -1
  35. package/src/plugin/plugin.ts +62 -11
  36. package/src/skills/frontmatter.ts +1 -1
  37. package/src/tmux/executor.ts +345 -13
  38. package/src/tmux/index.ts +3 -0
  39. package/src/tmux/manager.ts +255 -9
  40. package/src/tmux/state-query.ts +4 -1
  41. package/src/tmux/types.ts +1 -0
@@ -1,5 +1,17 @@
1
1
  import type { PaneAction, WindowState, TmuxConfig } from './types';
2
2
  import { runTmuxCommand, runTmuxCommandSync } from './utils';
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawn, spawnSync } from 'bun';
7
+
8
+ /**
9
+ * Path to persist the agents window ID for crash recovery.
10
+ * Uses ~/.config/agentuity/coder/cache/ which is consistent with other Agentuity paths
11
+ * and likely exists for any Agentuity user.
12
+ */
13
+ const CACHE_DIR = join(homedir(), '.config', 'agentuity', 'coder', 'cache');
14
+ const AGENTS_WINDOW_FILE = join(CACHE_DIR, 'agents-window-id');
3
15
 
4
16
  /**
5
17
  * Escape a string for safe use in shell commands.
@@ -17,14 +29,141 @@ export interface ActionResult {
17
29
  success: boolean;
18
30
  paneId?: string;
19
31
  windowId?: string;
32
+ pid?: number;
20
33
  error?: string;
21
34
  }
22
35
 
36
+ const PROCESS_TERM_WAIT_MS = 1000;
37
+
38
+ function isProcessAlive(pid: number): boolean {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (error) {
43
+ const code = (error as NodeJS.ErrnoException).code;
44
+ return code !== 'ESRCH';
45
+ }
46
+ }
47
+
48
+ async function getPanePid(paneId: string): Promise<number | undefined> {
49
+ if (!paneId) return undefined;
50
+ const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{pane_pid}']);
51
+ if (!result.success) return undefined;
52
+ const pid = Number(result.output.trim());
53
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
54
+ return pid;
55
+ }
56
+
57
+ /**
58
+ * Kill a process and all its children (the entire process tree).
59
+ *
60
+ * This is necessary because we spawn `bash -c "opencode attach ...; tmux kill-pane"`
61
+ * and #{pane_pid} returns the bash PID, not the opencode attach PID.
62
+ * We need to kill the children (opencode attach) not just the parent (bash).
63
+ */
64
+ export async function killProcessByPid(pid: number): Promise<boolean> {
65
+ if (!Number.isFinite(pid) || pid <= 0) return false;
66
+
67
+ // First, kill all child processes
68
+ try {
69
+ const proc = spawn(['pkill', '-TERM', '-P', String(pid)], {
70
+ stdout: 'pipe',
71
+ stderr: 'pipe',
72
+ });
73
+ await proc.exited;
74
+ } catch {
75
+ // Ignore errors - children may not exist
76
+ }
77
+
78
+ // Then kill the parent
79
+ try {
80
+ process.kill(pid, 'SIGTERM');
81
+ } catch (error) {
82
+ const code = (error as NodeJS.ErrnoException).code;
83
+ if (code === 'ESRCH') return true;
84
+ return false;
85
+ }
86
+
87
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_TERM_WAIT_MS));
88
+
89
+ // Check if parent and children are dead
90
+ if (!isProcessAlive(pid)) return true;
91
+
92
+ // Force kill children
93
+ try {
94
+ const proc = spawn(['pkill', '-KILL', '-P', String(pid)], {
95
+ stdout: 'pipe',
96
+ stderr: 'pipe',
97
+ });
98
+ await proc.exited;
99
+ } catch {
100
+ // Ignore errors
101
+ }
102
+
103
+ // Force kill parent
104
+ try {
105
+ process.kill(pid, 'SIGKILL');
106
+ } catch (error) {
107
+ const code = (error as NodeJS.ErrnoException).code;
108
+ if (code === 'ESRCH') return true;
109
+ return false;
110
+ }
111
+
112
+ return !isProcessAlive(pid);
113
+ }
114
+
23
115
  /**
24
116
  * State for separate-window mode - tracks the dedicated "Agents" window
25
117
  */
26
118
  let agentsWindowId: string | undefined;
27
119
 
120
+ /**
121
+ * Ensure the cache directory exists
122
+ */
123
+ function ensureCacheDir(): void {
124
+ if (!existsSync(CACHE_DIR)) {
125
+ mkdirSync(CACHE_DIR, { recursive: true });
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Persist the agents window ID to disk for crash recovery
131
+ */
132
+ function persistAgentsWindowId(windowId: string): void {
133
+ try {
134
+ ensureCacheDir();
135
+ writeFileSync(AGENTS_WINDOW_FILE, windowId, 'utf-8');
136
+ } catch {
137
+ // Ignore write errors - persistence is best-effort
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Load the agents window ID from disk (for crash recovery)
143
+ */
144
+ function loadPersistedAgentsWindowId(): string | undefined {
145
+ try {
146
+ if (!existsSync(AGENTS_WINDOW_FILE)) return undefined;
147
+ const windowId = readFileSync(AGENTS_WINDOW_FILE, 'utf-8').trim();
148
+ return windowId || undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Clear the persisted agents window ID
156
+ */
157
+ function clearPersistedAgentsWindowId(): void {
158
+ try {
159
+ if (existsSync(AGENTS_WINDOW_FILE)) {
160
+ unlinkSync(AGENTS_WINDOW_FILE);
161
+ }
162
+ } catch {
163
+ // Ignore delete errors
164
+ }
165
+ }
166
+
28
167
  /**
29
168
  * Execute a single pane action
30
169
  *
@@ -77,18 +216,23 @@ export async function executeActions(
77
216
  * Uses: tmux kill-pane -t <paneId>
78
217
  */
79
218
  async function closePane(action: Extract<PaneAction, { type: 'close' }>): Promise<ActionResult> {
80
- const result = await runTmuxCommand(['kill-pane', '-t', action.paneId]);
81
- if (!result.success) {
82
- return { success: false, error: result.output };
83
- }
84
- return { success: true };
219
+ return closePaneById(action.paneId);
85
220
  }
86
221
 
87
222
  /**
88
223
  * Close a pane by its ID
89
224
  * Exported for use by TmuxSessionManager when sessions complete
90
225
  */
91
- export async function closePaneById(paneId: string): Promise<ActionResult> {
226
+ export async function closePaneById(paneId: string, pid?: number): Promise<ActionResult> {
227
+ let resolvedPid = pid;
228
+ if (!resolvedPid) {
229
+ resolvedPid = await getPanePid(paneId);
230
+ }
231
+
232
+ if (resolvedPid) {
233
+ await killProcessByPid(resolvedPid);
234
+ }
235
+
92
236
  const result = await runTmuxCommand(['kill-pane', '-t', paneId]);
93
237
  if (!result.success) {
94
238
  return { success: false, error: result.output };
@@ -113,7 +257,8 @@ async function replacePane(
113
257
  if (!result.success) {
114
258
  return { success: false, error: result.output };
115
259
  }
116
- return { success: true, paneId: action.paneId };
260
+ const pid = await getPanePid(action.paneId);
261
+ return { success: true, paneId: action.paneId, pid };
117
262
  }
118
263
 
119
264
  /**
@@ -186,12 +331,18 @@ async function spawnInAgentsWindow(
186
331
  const [windowId, paneId] = output.split(':');
187
332
  agentsWindowId = windowId;
188
333
 
334
+ // Persist for crash recovery
335
+ if (agentsWindowId) {
336
+ persistAgentsWindowId(agentsWindowId);
337
+ }
338
+
189
339
  // Apply initial layout (useful when more panes are added later)
190
340
  if (agentsWindowId && layout) {
191
341
  await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
192
342
  }
193
343
 
194
- return { success: true, paneId, windowId };
344
+ const pid = paneId ? await getPanePid(paneId) : undefined;
345
+ return { success: true, paneId, windowId, pid };
195
346
  }
196
347
 
197
348
  // Agents window exists - split within it
@@ -240,10 +391,12 @@ async function spawnInAgentsWindow(
240
391
  await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
241
392
  }
242
393
 
394
+ const pid = paneId ? await getPanePid(paneId) : undefined;
243
395
  return {
244
396
  success: true,
245
397
  paneId: paneId || undefined,
246
398
  windowId: agentsWindowId,
399
+ pid,
247
400
  };
248
401
  }
249
402
 
@@ -252,6 +405,7 @@ async function spawnInAgentsWindow(
252
405
  */
253
406
  export function resetAgentsWindow(): void {
254
407
  agentsWindowId = undefined;
408
+ clearPersistedAgentsWindowId();
255
409
  }
256
410
 
257
411
  /**
@@ -259,11 +413,14 @@ export function resetAgentsWindow(): void {
259
413
  * This kills the entire window, which closes all panes within it
260
414
  */
261
415
  export async function closeAgentsWindow(): Promise<void> {
262
- if (!agentsWindowId) return;
416
+ // Try to recover window ID from disk if not in memory
417
+ const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
418
+ if (!windowId) return;
263
419
 
264
420
  // Kill the entire window (closes all panes within it)
265
- await runTmuxCommand(['kill-window', '-t', agentsWindowId]);
421
+ await runTmuxCommand(['kill-window', '-t', windowId]);
266
422
  agentsWindowId = undefined;
423
+ clearPersistedAgentsWindowId();
267
424
  }
268
425
 
269
426
  /**
@@ -271,16 +428,191 @@ export async function closeAgentsWindow(): Promise<void> {
271
428
  * Uses spawnSync to ensure it completes before process exit
272
429
  */
273
430
  export function closeAgentsWindowSync(): void {
274
- if (!agentsWindowId) return;
431
+ // Try to recover window ID from disk if not in memory
432
+ const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
433
+ if (!windowId) return;
275
434
 
276
435
  // Kill the entire window synchronously
277
- runTmuxCommandSync(['kill-window', '-t', agentsWindowId]);
436
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
278
437
  agentsWindowId = undefined;
438
+ clearPersistedAgentsWindowId();
279
439
  }
280
440
 
281
441
  /**
282
442
  * Get the current agents window ID (for testing/debugging)
443
+ * Also checks persisted file for crash recovery
283
444
  */
284
445
  export function getAgentsWindowId(): string | undefined {
285
- return agentsWindowId;
446
+ return agentsWindowId ?? loadPersistedAgentsWindowId();
447
+ }
448
+
449
+ /**
450
+ * Kill all orphaned opencode attach processes for a given server URL.
451
+ * This is a fallback cleanup method when PID-based cleanup fails.
452
+ *
453
+ * @param serverUrl - The server URL to match (optional, kills all if not provided)
454
+ * @param logger - Optional logging function for debug output
455
+ * @returns Number of processes killed
456
+ */
457
+ export async function killOrphanedAttachProcesses(
458
+ serverUrl?: string,
459
+ logger?: (msg: string) => void
460
+ ): Promise<number> {
461
+ const log = logger ?? (() => {});
462
+
463
+ try {
464
+ // Use pkill with pattern matching for opencode attach
465
+ const { spawn } = await import('bun');
466
+
467
+ // First, find matching processes to log what we're killing
468
+ const psProc = spawn(['ps', 'aux'], { stdout: 'pipe', stderr: 'pipe' });
469
+ await psProc.exited;
470
+ const psOutput = await new Response(psProc.stdout).text();
471
+ const lines = psOutput.split('\n');
472
+
473
+ const matchingPids: number[] = [];
474
+ for (const line of lines) {
475
+ if (!line.includes('opencode attach')) continue;
476
+ if (serverUrl && !line.includes(serverUrl)) continue;
477
+ // Don't kill ourselves
478
+ if (line.includes(String(process.pid))) continue;
479
+
480
+ const parts = line.trim().split(/\s+/);
481
+ const pid = Number(parts[1]);
482
+ if (Number.isFinite(pid) && pid > 0) {
483
+ matchingPids.push(pid);
484
+ }
485
+ }
486
+
487
+ if (matchingPids.length === 0) {
488
+ log('No orphaned opencode attach processes found');
489
+ return 0;
490
+ }
491
+
492
+ log(`Found ${matchingPids.length} orphaned processes: ${matchingPids.join(', ')}`);
493
+
494
+ // Kill each process individually for better control
495
+ let killed = 0;
496
+ for (const pid of matchingPids) {
497
+ try {
498
+ // Try SIGTERM first
499
+ process.kill(pid, 'SIGTERM');
500
+ log(`Sent SIGTERM to PID ${pid}`);
501
+ killed++;
502
+ } catch (error) {
503
+ const code = (error as NodeJS.ErrnoException).code;
504
+ if (code !== 'ESRCH') {
505
+ log(`Failed to kill PID ${pid}: ${code}`);
506
+ }
507
+ }
508
+ }
509
+
510
+ // Wait a bit then SIGKILL any survivors
511
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_TERM_WAIT_MS));
512
+
513
+ for (const pid of matchingPids) {
514
+ if (!isProcessAlive(pid)) continue;
515
+ try {
516
+ process.kill(pid, 'SIGKILL');
517
+ log(`Sent SIGKILL to PID ${pid}`);
518
+ } catch {
519
+ // Ignore errors on SIGKILL
520
+ }
521
+ }
522
+
523
+ log(`Cleanup complete: killed ${killed} processes`);
524
+ return killed;
525
+ } catch (error) {
526
+ log(`Fallback cleanup failed: ${error}`);
527
+ return 0;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Synchronous version of killOrphanedAttachProcesses for shutdown handlers.
533
+ * Uses spawnSync to ensure completion before process exit.
534
+ *
535
+ * @param serverUrl - The server URL to match (optional, kills all if not provided)
536
+ * @param logger - Optional logging function for debug output
537
+ * @returns Number of processes killed
538
+ */
539
+ export function killOrphanedAttachProcessesSync(
540
+ serverUrl?: string,
541
+ logger?: (msg: string) => void
542
+ ): number {
543
+ const log = logger ?? (() => {});
544
+
545
+ try {
546
+ // Find matching processes
547
+ const psResult = spawnSync(['ps', 'aux'], { timeout: 2000 });
548
+ if (psResult.exitCode !== 0) {
549
+ log('Failed to list processes');
550
+ return 0;
551
+ }
552
+
553
+ const psOutput = psResult.stdout?.toString() ?? '';
554
+ const lines = psOutput.split('\n');
555
+
556
+ const matchingPids: number[] = [];
557
+ for (const line of lines) {
558
+ if (!line.includes('opencode attach')) continue;
559
+ if (serverUrl && !line.includes(serverUrl)) continue;
560
+ // Don't kill ourselves
561
+ if (line.includes(String(process.pid))) continue;
562
+
563
+ const parts = line.trim().split(/\s+/);
564
+ const pid = Number(parts[1]);
565
+ if (Number.isFinite(pid) && pid > 0) {
566
+ matchingPids.push(pid);
567
+ }
568
+ }
569
+
570
+ if (matchingPids.length === 0) {
571
+ log('No orphaned opencode attach processes found');
572
+ return 0;
573
+ }
574
+
575
+ log(`Found ${matchingPids.length} orphaned processes: ${matchingPids.join(', ')}`);
576
+
577
+ // Kill each process
578
+ let killed = 0;
579
+ for (const pid of matchingPids) {
580
+ try {
581
+ process.kill(pid, 'SIGTERM');
582
+ log(`Sent SIGTERM to PID ${pid}`);
583
+ killed++;
584
+ } catch (error) {
585
+ const code = (error as NodeJS.ErrnoException).code;
586
+ if (code !== 'ESRCH') {
587
+ log(`Failed to kill PID ${pid}: ${code}`);
588
+ }
589
+ }
590
+ }
591
+
592
+ // Brief wait using SharedArrayBuffer (sync sleep)
593
+ try {
594
+ const buffer = new SharedArrayBuffer(4);
595
+ const view = new Int32Array(buffer);
596
+ Atomics.wait(view, 0, 0, PROCESS_TERM_WAIT_MS);
597
+ } catch {
598
+ // Ignore sleep errors
599
+ }
600
+
601
+ // SIGKILL survivors
602
+ for (const pid of matchingPids) {
603
+ if (!isProcessAlive(pid)) continue;
604
+ try {
605
+ process.kill(pid, 'SIGKILL');
606
+ log(`Sent SIGKILL to PID ${pid}`);
607
+ } catch {
608
+ // Ignore errors
609
+ }
610
+ }
611
+
612
+ log(`Cleanup complete: killed ${killed} processes`);
613
+ return killed;
614
+ } catch (error) {
615
+ log(`Fallback cleanup failed: ${error}`);
616
+ return 0;
617
+ }
286
618
  }
package/src/tmux/index.ts CHANGED
@@ -7,5 +7,8 @@ export {
7
7
  executeActions,
8
8
  closeAgentsWindow,
9
9
  closeAgentsWindowSync,
10
+ getAgentsWindowId,
11
+ killOrphanedAttachProcesses,
12
+ killOrphanedAttachProcessesSync,
10
13
  } from './executor';
11
14
  export { TmuxSessionManager } from './manager';