@agentuity/opencode 0.1.42 → 0.1.43

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.
@@ -1,18 +1,7 @@
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
3
  import { spawn, spawnSync } from 'bun';
7
4
 
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');
15
-
16
5
  /**
17
6
  * Escape a string for safe use in shell commands.
18
7
  * Wraps in single quotes and escapes any internal single quotes.
@@ -25,6 +14,25 @@ function shellEscape(str: string): string {
25
14
  /** Maximum retries for recursive spawn attempts to prevent infinite loops */
26
15
  const MAX_SPAWN_RETRIES = 3;
27
16
 
17
+ const OPENCODE_TAG = '@agentuity.opencode';
18
+ const OPENCODE_SERVER_TAG = '@agentuity.opencode.server';
19
+ const OPENCODE_OWNER_TAG = '@agentuity.opencode.ownerPid';
20
+ const OPENCODE_INSTANCE_TAG = '@agentuity.opencode.instance';
21
+ const OPENCODE_SESSION_TAG = '@agentuity.opencode.session';
22
+
23
+ type OwnershipContext = {
24
+ instanceId: string;
25
+ ownerPid: number;
26
+ serverKey: string;
27
+ tmuxSessionId?: string;
28
+ };
29
+
30
+ type OwnershipLookup = {
31
+ serverKey: string;
32
+ instanceId: string;
33
+ tmuxSessionId?: string;
34
+ };
35
+
28
36
  export interface ActionResult {
29
37
  success: boolean;
30
38
  paneId?: string;
@@ -45,7 +53,7 @@ function isProcessAlive(pid: number): boolean {
45
53
  }
46
54
  }
47
55
 
48
- async function getPanePid(paneId: string): Promise<number | undefined> {
56
+ export async function getPanePid(paneId: string): Promise<number | undefined> {
49
57
  if (!paneId) return undefined;
50
58
  const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{pane_pid}']);
51
59
  if (!result.success) return undefined;
@@ -54,6 +62,185 @@ async function getPanePid(paneId: string): Promise<number | undefined> {
54
62
  return pid;
55
63
  }
56
64
 
65
+ export function getPanePidSync(paneId: string): number | undefined {
66
+ if (!paneId) return undefined;
67
+ const result = runTmuxCommandSync(['display', '-p', '-t', paneId, '#{pane_pid}']);
68
+ if (!result.success) return undefined;
69
+ const pid = Number(result.output.trim());
70
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
71
+ return pid;
72
+ }
73
+
74
+ async function resolvePanePidWithRetry(
75
+ paneId: string,
76
+ maxAttempts = 3,
77
+ delayMs = 75
78
+ ): Promise<number | undefined> {
79
+ let pid = await getPanePid(paneId);
80
+ if (pid) return pid;
81
+ for (let attempt = 1; attempt < maxAttempts; attempt += 1) {
82
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
83
+ pid = await getPanePid(paneId);
84
+ if (pid) return pid;
85
+ }
86
+ return pid;
87
+ }
88
+
89
+ async function setWindowTags(windowId: string, ownership: OwnershipContext): Promise<void> {
90
+ await runTmuxCommand(['set-option', '-w', '-t', windowId, OPENCODE_TAG, '1']);
91
+ await runTmuxCommand([
92
+ 'set-option',
93
+ '-w',
94
+ '-t',
95
+ windowId,
96
+ OPENCODE_SERVER_TAG,
97
+ ownership.serverKey,
98
+ ]);
99
+ await runTmuxCommand([
100
+ 'set-option',
101
+ '-w',
102
+ '-t',
103
+ windowId,
104
+ OPENCODE_OWNER_TAG,
105
+ String(ownership.ownerPid),
106
+ ]);
107
+ await runTmuxCommand([
108
+ 'set-option',
109
+ '-w',
110
+ '-t',
111
+ windowId,
112
+ OPENCODE_INSTANCE_TAG,
113
+ ownership.instanceId,
114
+ ]);
115
+ }
116
+
117
+ async function setPaneTags(
118
+ paneId: string,
119
+ ownership: OwnershipContext,
120
+ sessionId?: string
121
+ ): Promise<void> {
122
+ await runTmuxCommand([
123
+ 'set-option',
124
+ '-p',
125
+ '-t',
126
+ paneId,
127
+ OPENCODE_INSTANCE_TAG,
128
+ ownership.instanceId,
129
+ ]);
130
+ if (sessionId) {
131
+ await runTmuxCommand(['set-option', '-p', '-t', paneId, OPENCODE_SESSION_TAG, sessionId]);
132
+ }
133
+ }
134
+
135
+ async function findOwnedAgentsWindow(
136
+ serverKey: string,
137
+ instanceId: string,
138
+ tmuxSessionId?: string
139
+ ): Promise<string | undefined> {
140
+ const result = await runTmuxCommand([
141
+ 'list-windows',
142
+ '-a',
143
+ '-F',
144
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}\t#{session_id}`,
145
+ ]);
146
+ if (!result.success || !result.output) return undefined;
147
+
148
+ for (const line of result.output.split('\n')) {
149
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId, sessionId] =
150
+ line.split('\t');
151
+ if (!windowId) continue;
152
+ if (windowName !== 'Agents') continue;
153
+ if (isOpencode !== '1') continue;
154
+ if (windowServerKey !== serverKey) continue;
155
+ if (windowInstanceId !== instanceId) continue;
156
+ // If tmuxSessionId is provided, only match windows in that session
157
+ if (tmuxSessionId && sessionId !== tmuxSessionId) continue;
158
+ return windowId;
159
+ }
160
+
161
+ return undefined;
162
+ }
163
+
164
+ function findOwnedAgentsWindowSync(
165
+ serverKey: string,
166
+ instanceId: string,
167
+ tmuxSessionId?: string
168
+ ): string | undefined {
169
+ const result = runTmuxCommandSync([
170
+ 'list-windows',
171
+ '-a',
172
+ '-F',
173
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}\t#{session_id}`,
174
+ ]);
175
+ if (!result.success || !result.output) return undefined;
176
+
177
+ for (const line of result.output.split('\n')) {
178
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId, sessionId] =
179
+ line.split('\t');
180
+ if (!windowId) continue;
181
+ if (windowName !== 'Agents') continue;
182
+ if (isOpencode !== '1') continue;
183
+ if (windowServerKey !== serverKey) continue;
184
+ if (windowInstanceId !== instanceId) continue;
185
+ // If tmuxSessionId is provided, only match windows in that session
186
+ if (tmuxSessionId && sessionId !== tmuxSessionId) continue;
187
+ return windowId;
188
+ }
189
+
190
+ return undefined;
191
+ }
192
+
193
+ export async function findOwnedAgentPanes(
194
+ serverKey: string
195
+ ): Promise<Array<{ paneId: string; panePid?: number; sessionId?: string; instanceId?: string }>> {
196
+ const windowsResult = await runTmuxCommand([
197
+ 'list-windows',
198
+ '-a',
199
+ '-F',
200
+ `#{window_id}\t#{${OPENCODE_SERVER_TAG}}`,
201
+ ]);
202
+ if (!windowsResult.success || !windowsResult.output) return [];
203
+
204
+ const serverWindowIds = new Set<string>();
205
+ for (const line of windowsResult.output.split('\n')) {
206
+ const [windowId, windowServerKey] = line.split('\t');
207
+ if (!windowId) continue;
208
+ if (windowServerKey !== serverKey) continue;
209
+ serverWindowIds.add(windowId);
210
+ }
211
+ if (serverWindowIds.size === 0) return [];
212
+
213
+ const result = await runTmuxCommand([
214
+ 'list-panes',
215
+ '-a',
216
+ '-F',
217
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}\t#{${OPENCODE_SESSION_TAG}}`,
218
+ ]);
219
+ if (!result.success || !result.output) return [];
220
+
221
+ const panes: Array<{
222
+ paneId: string;
223
+ panePid?: number;
224
+ sessionId?: string;
225
+ instanceId?: string;
226
+ }> = [];
227
+
228
+ for (const line of result.output.split('\n')) {
229
+ const [paneId, panePidRaw, windowId, paneInstanceId, paneSessionId] = line.split('\t');
230
+ if (!paneId) continue;
231
+ if (!windowId || !serverWindowIds.has(windowId)) continue;
232
+ const panePid = Number(panePidRaw);
233
+ panes.push({
234
+ paneId,
235
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
236
+ sessionId: paneSessionId || undefined,
237
+ instanceId: paneInstanceId || undefined,
238
+ });
239
+ }
240
+
241
+ return panes;
242
+ }
243
+
57
244
  /**
58
245
  * Kill a process and all its children (the entire process tree).
59
246
  *
@@ -114,53 +301,34 @@ export async function killProcessByPid(pid: number): Promise<boolean> {
114
301
 
115
302
  /**
116
303
  * State for separate-window mode - tracks the dedicated "Agents" window
304
+ * Keyed by `${serverKey}:${instanceId}:${tmuxSessionId}` to support multiple
305
+ * opencode instances running in different tmux sessions.
117
306
  */
118
- let agentsWindowId: string | undefined;
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
- }
307
+ const agentsWindowIdByKey = new Map<string, string>();
128
308
 
129
309
  /**
130
- * Persist the agents window ID to disk for crash recovery
310
+ * Get the cache key for the agents window
131
311
  */
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
- }
312
+ function getAgentsWindowCacheKey(ownership: OwnershipContext): string {
313
+ return `${ownership.serverKey}:${ownership.instanceId}:${ownership.tmuxSessionId ?? 'default'}`;
139
314
  }
140
315
 
141
316
  /**
142
- * Load the agents window ID from disk (for crash recovery)
317
+ * Get the cached agents window ID for the given ownership context
143
318
  */
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
- }
319
+ function getCachedAgentsWindowId(ownership: OwnershipContext): string | undefined {
320
+ return agentsWindowIdByKey.get(getAgentsWindowCacheKey(ownership));
152
321
  }
153
322
 
154
323
  /**
155
- * Clear the persisted agents window ID
324
+ * Set the cached agents window ID for the given ownership context
156
325
  */
157
- function clearPersistedAgentsWindowId(): void {
158
- try {
159
- if (existsSync(AGENTS_WINDOW_FILE)) {
160
- unlinkSync(AGENTS_WINDOW_FILE);
161
- }
162
- } catch {
163
- // Ignore delete errors
326
+ function setCachedAgentsWindowId(ownership: OwnershipContext, windowId: string | undefined): void {
327
+ const key = getAgentsWindowCacheKey(ownership);
328
+ if (windowId) {
329
+ agentsWindowIdByKey.set(key, windowId);
330
+ } else {
331
+ agentsWindowIdByKey.delete(key);
164
332
  }
165
333
  }
166
334
 
@@ -171,11 +339,19 @@ function clearPersistedAgentsWindowId(): void {
171
339
  */
172
340
  export async function executeAction(
173
341
  action: PaneAction,
174
- ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
342
+ ctx: {
343
+ config: TmuxConfig;
344
+ serverUrl: string;
345
+ windowState: WindowState;
346
+ ownership: OwnershipContext;
347
+ }
175
348
  ): Promise<ActionResult> {
176
349
  switch (action.type) {
177
350
  case 'spawn':
178
- return spawnInAgentsWindow(action, { serverUrl: ctx.serverUrl });
351
+ return spawnInAgentsWindow(action, {
352
+ serverUrl: ctx.serverUrl,
353
+ ownership: ctx.ownership,
354
+ });
179
355
  case 'close':
180
356
  return closePane(action);
181
357
  case 'replace':
@@ -188,7 +364,12 @@ export async function executeAction(
188
364
  */
189
365
  export async function executeActions(
190
366
  actions: PaneAction[],
191
- ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
367
+ ctx: {
368
+ config: TmuxConfig;
369
+ serverUrl: string;
370
+ windowState: WindowState;
371
+ ownership: OwnershipContext;
372
+ }
192
373
  ): Promise<{
193
374
  success: boolean;
194
375
  spawnedPaneId?: string;
@@ -226,7 +407,7 @@ async function closePane(action: Extract<PaneAction, { type: 'close' }>): Promis
226
407
  export async function closePaneById(paneId: string, pid?: number): Promise<ActionResult> {
227
408
  let resolvedPid = pid;
228
409
  if (!resolvedPid) {
229
- resolvedPid = await getPanePid(paneId);
410
+ resolvedPid = await resolvePanePidWithRetry(paneId);
230
411
  }
231
412
 
232
413
  if (resolvedPid) {
@@ -246,18 +427,21 @@ export async function closePaneById(paneId: string, pid?: number): Promise<Actio
246
427
  */
247
428
  async function replacePane(
248
429
  action: Extract<PaneAction, { type: 'replace' }>,
249
- ctx: { serverUrl: string }
430
+ ctx: { serverUrl: string; ownership: OwnershipContext }
250
431
  ): Promise<ActionResult> {
251
- // Pane kills itself when opencode attach exits (for any reason)
432
+ // Use exec to replace bash with opencode attach directly.
433
+ // This ensures signals go directly to opencode attach (no wrapper process).
434
+ // When opencode attach exits, the pane closes automatically (tmux remain-on-exit off).
252
435
  // Use shellEscape to prevent shell injection via session IDs
253
436
  const escapedServerUrl = shellEscape(ctx.serverUrl);
254
437
  const escapedSessionId = shellEscape(action.newSessionId);
255
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
438
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
256
439
  const result = await runTmuxCommand(['respawn-pane', '-k', '-t', action.paneId, command]);
257
440
  if (!result.success) {
258
441
  return { success: false, error: result.output };
259
442
  }
260
- const pid = await getPanePid(action.paneId);
443
+ await setPaneTags(action.paneId, ctx.ownership, action.newSessionId);
444
+ const pid = await resolvePanePidWithRetry(action.paneId);
261
445
  return { success: true, paneId: action.paneId, pid };
262
446
  }
263
447
 
@@ -275,7 +459,7 @@ async function replacePane(
275
459
  */
276
460
  async function spawnInAgentsWindow(
277
461
  action: Extract<PaneAction, { type: 'spawn' }>,
278
- ctx: { serverUrl: string },
462
+ ctx: { serverUrl: string; ownership: OwnershipContext },
279
463
  retryCount = 0
280
464
  ): Promise<ActionResult> {
281
465
  // Prevent infinite recursion if tmux keeps failing
@@ -286,41 +470,77 @@ async function spawnInAgentsWindow(
286
470
  };
287
471
  }
288
472
 
289
- // Pane kills itself when opencode attach exits (session complete, server died, etc.)
473
+ // Use exec to replace bash with opencode attach directly.
474
+ // This ensures signals go directly to opencode attach (no wrapper process).
475
+ // When opencode attach exits, the pane closes automatically (tmux remain-on-exit off).
290
476
  // Use shellEscape to prevent shell injection via session IDs
291
477
  const escapedServerUrl = shellEscape(ctx.serverUrl);
292
478
  const escapedSessionId = shellEscape(action.sessionId);
293
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
479
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
294
480
  const layout = 'tiled'; // Always use tiled layout for grid arrangement
295
481
 
296
482
  // Check if we have a cached agents window ID and if it still exists
297
- if (agentsWindowId) {
483
+ let cachedWindowId = getCachedAgentsWindowId(ctx.ownership);
484
+ if (cachedWindowId) {
298
485
  const checkResult = await runTmuxCommand([
299
- 'list-panes',
486
+ 'display',
487
+ '-p',
300
488
  '-t',
301
- agentsWindowId,
302
- '-F',
303
- '#{pane_id}',
489
+ cachedWindowId,
490
+ `#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
304
491
  ]);
305
492
 
306
- if (!checkResult.success) {
307
- // Window no longer exists, clear the cache
308
- agentsWindowId = undefined;
493
+ if (!checkResult.success || !checkResult.output) {
494
+ setCachedAgentsWindowId(ctx.ownership, undefined);
495
+ cachedWindowId = undefined;
496
+ } else {
497
+ const [isOpencode, windowServerKey, windowInstanceId] = checkResult.output
498
+ .trim()
499
+ .split('\t');
500
+ if (
501
+ isOpencode !== '1' ||
502
+ windowServerKey !== ctx.ownership.serverKey ||
503
+ windowInstanceId !== ctx.ownership.instanceId
504
+ ) {
505
+ setCachedAgentsWindowId(ctx.ownership, undefined);
506
+ cachedWindowId = undefined;
507
+ }
508
+ }
509
+ }
510
+
511
+ if (!cachedWindowId) {
512
+ cachedWindowId = await findOwnedAgentsWindow(
513
+ ctx.ownership.serverKey,
514
+ ctx.ownership.instanceId,
515
+ ctx.ownership.tmuxSessionId
516
+ );
517
+ if (cachedWindowId) {
518
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
309
519
  }
310
520
  }
311
521
 
312
522
  // If no agents window exists, create one
313
- if (!agentsWindowId) {
314
- const createResult = await runTmuxCommand([
315
- 'new-window',
523
+ if (!cachedWindowId) {
524
+ // Build the new-window command args
525
+ // CRITICAL: Use -t <session>: to target the correct tmux session
526
+ // Without this, tmux may create the window in the wrong session when
527
+ // multiple opencode instances run in different tmux sessions
528
+ const newWindowArgs = ['new-window'];
529
+ if (ctx.ownership.tmuxSessionId) {
530
+ // Target the specific tmux session (the colon after session_id is important)
531
+ newWindowArgs.push('-t', `${ctx.ownership.tmuxSessionId}:`);
532
+ }
533
+ newWindowArgs.push(
316
534
  '-d', // Don't switch to new window
317
535
  '-P',
318
536
  '-F',
319
537
  '#{window_id}:#{pane_id}',
320
538
  '-n',
321
539
  'Agents',
322
- command,
323
- ]);
540
+ command
541
+ );
542
+
543
+ const createResult = await runTmuxCommand(newWindowArgs);
324
544
 
325
545
  if (!createResult.success) {
326
546
  return { success: false, error: createResult.output };
@@ -329,19 +549,23 @@ async function spawnInAgentsWindow(
329
549
  // Parse window_id:pane_id from output
330
550
  const output = createResult.output?.trim() || '';
331
551
  const [windowId, paneId] = output.split(':');
332
- agentsWindowId = windowId;
552
+ cachedWindowId = windowId;
333
553
 
334
- // Persist for crash recovery
335
- if (agentsWindowId) {
336
- persistAgentsWindowId(agentsWindowId);
554
+ if (cachedWindowId) {
555
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
556
+ await setWindowTags(cachedWindowId, ctx.ownership);
337
557
  }
338
558
 
339
559
  // Apply initial layout (useful when more panes are added later)
340
- if (agentsWindowId && layout) {
341
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
560
+ if (cachedWindowId && layout) {
561
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
562
+ }
563
+
564
+ if (paneId) {
565
+ await setPaneTags(paneId, ctx.ownership, action.sessionId);
342
566
  }
343
567
 
344
- const pid = paneId ? await getPanePid(paneId) : undefined;
568
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
345
569
  return { success: true, paneId, windowId, pid };
346
570
  }
347
571
 
@@ -350,21 +574,21 @@ async function spawnInAgentsWindow(
350
574
  const listResult = await runTmuxCommand([
351
575
  'list-panes',
352
576
  '-t',
353
- agentsWindowId,
577
+ cachedWindowId,
354
578
  '-F',
355
579
  '#{pane_id}',
356
580
  ]);
357
581
 
358
582
  if (!listResult.success || !listResult.output) {
359
583
  // Fallback: create new window (with retry counter)
360
- agentsWindowId = undefined;
584
+ setCachedAgentsWindowId(ctx.ownership, undefined);
361
585
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
362
586
  }
363
587
 
364
588
  const targetPaneId = listResult.output.split('\n')[0]?.trim();
365
589
  if (!targetPaneId) {
366
590
  // Fallback: create new window (with retry counter)
367
- agentsWindowId = undefined;
591
+ setCachedAgentsWindowId(ctx.ownership, undefined);
368
592
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
369
593
  }
370
594
 
@@ -385,234 +609,371 @@ async function spawnInAgentsWindow(
385
609
  }
386
610
 
387
611
  const paneId = splitResult.output?.trim();
612
+ if (cachedWindowId) {
613
+ await setWindowTags(cachedWindowId, ctx.ownership);
614
+ }
615
+ if (paneId) {
616
+ await setPaneTags(paneId, ctx.ownership, action.sessionId);
617
+ }
388
618
 
389
619
  // Apply the configured layout to the agents window (e.g., tiled for grid)
390
- if (agentsWindowId && layout) {
391
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
620
+ if (cachedWindowId && layout) {
621
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
392
622
  }
393
623
 
394
- const pid = paneId ? await getPanePid(paneId) : undefined;
624
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
395
625
  return {
396
626
  success: true,
397
627
  paneId: paneId || undefined,
398
- windowId: agentsWindowId,
628
+ windowId: cachedWindowId,
399
629
  pid,
400
630
  };
401
631
  }
402
632
 
633
+ function killProcessByPidSync(pid: number): void {
634
+ if (!Number.isFinite(pid) || pid <= 0) return;
635
+
636
+ try {
637
+ spawnSync(['pkill', '-TERM', '-P', String(pid)]);
638
+ } catch {
639
+ // Ignore errors - children may not exist
640
+ }
641
+
642
+ try {
643
+ process.kill(pid, 'SIGTERM');
644
+ } catch (error) {
645
+ const code = (error as NodeJS.ErrnoException).code;
646
+ if (code === 'ESRCH') return;
647
+ }
648
+
649
+ try {
650
+ const buffer = new SharedArrayBuffer(4);
651
+ const view = new Int32Array(buffer);
652
+ Atomics.wait(view, 0, 0, PROCESS_TERM_WAIT_MS);
653
+ } catch {
654
+ // ignore sleep errors
655
+ }
656
+
657
+ try {
658
+ process.kill(pid, 0);
659
+ } catch (error) {
660
+ const code = (error as NodeJS.ErrnoException).code;
661
+ if (code === 'ESRCH') return;
662
+ }
663
+
664
+ try {
665
+ spawnSync(['pkill', '-KILL', '-P', String(pid)]);
666
+ } catch {
667
+ // Ignore errors
668
+ }
669
+
670
+ try {
671
+ process.kill(pid, 'SIGKILL');
672
+ } catch {
673
+ // ignore errors
674
+ }
675
+ }
676
+
403
677
  /**
404
678
  * Reset the agents window state (for cleanup)
405
679
  */
406
- export function resetAgentsWindow(): void {
407
- agentsWindowId = undefined;
408
- clearPersistedAgentsWindowId();
680
+ export function resetAgentsWindow(ownership?: OwnershipContext): void {
681
+ if (ownership) {
682
+ setCachedAgentsWindowId(ownership, undefined);
683
+ } else {
684
+ agentsWindowIdByKey.clear();
685
+ }
409
686
  }
410
687
 
411
688
  /**
412
689
  * Close the agents window if it exists
413
690
  * This kills the entire window, which closes all panes within it
691
+ *
692
+ * SAFETY: Verifies the window is named "Agents" before killing to prevent
693
+ * accidentally killing user windows if the cached ID is stale.
414
694
  */
415
- export async function closeAgentsWindow(): Promise<void> {
416
- // Try to recover window ID from disk if not in memory
417
- const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
695
+ export async function closeAgentsWindow(ownership?: OwnershipLookup): Promise<void> {
696
+ // Build a pseudo-ownership context for cache lookup
697
+ const cacheKey = ownership
698
+ ? {
699
+ serverKey: ownership.serverKey,
700
+ instanceId: ownership.instanceId,
701
+ ownerPid: 0,
702
+ tmuxSessionId: ownership.tmuxSessionId,
703
+ }
704
+ : undefined;
705
+ const cachedId = cacheKey ? getCachedAgentsWindowId(cacheKey) : undefined;
706
+ const windowId =
707
+ cachedId ??
708
+ (ownership
709
+ ? await findOwnedAgentsWindow(
710
+ ownership.serverKey,
711
+ ownership.instanceId,
712
+ ownership.tmuxSessionId
713
+ )
714
+ : undefined);
418
715
  if (!windowId) return;
419
716
 
420
- // Kill the entire window (closes all panes within it)
717
+ const checkFormat = ownership
718
+ ? `#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`
719
+ : '#{window_name}';
720
+ const checkResult = await runTmuxCommand(['display', '-p', '-t', windowId, checkFormat]);
721
+
722
+ if (!checkResult.success) {
723
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
724
+ return;
725
+ }
726
+
727
+ const parts = checkResult.output?.trim().split('\t') ?? [];
728
+ const windowName = parts[0];
729
+ if (windowName !== 'Agents') {
730
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
731
+ return;
732
+ }
733
+ if (ownership) {
734
+ const [, isOpencode, windowServerKey, windowInstanceId] = parts;
735
+ if (
736
+ isOpencode !== '1' ||
737
+ windowServerKey !== ownership.serverKey ||
738
+ windowInstanceId !== ownership.instanceId
739
+ ) {
740
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
741
+ return;
742
+ }
743
+ }
744
+
421
745
  await runTmuxCommand(['kill-window', '-t', windowId]);
422
- agentsWindowId = undefined;
423
- clearPersistedAgentsWindowId();
746
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
424
747
  }
425
748
 
426
749
  /**
427
750
  * Synchronously close the agents window (for shutdown)
428
- * Uses spawnSync to ensure it completes before process exit
751
+ * Uses runTmuxCommandSync to ensure it completes before process exit
752
+ *
753
+ * SAFETY: Verifies the window is named "Agents" before killing to prevent
754
+ * accidentally killing user windows if the cached ID is stale.
429
755
  */
430
- export function closeAgentsWindowSync(): void {
431
- // Try to recover window ID from disk if not in memory
432
- const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
756
+ export function closeAgentsWindowSync(ownership?: OwnershipLookup): void {
757
+ // Build a pseudo-ownership context for cache lookup
758
+ const cacheKey = ownership
759
+ ? {
760
+ serverKey: ownership.serverKey,
761
+ instanceId: ownership.instanceId,
762
+ ownerPid: 0,
763
+ tmuxSessionId: ownership.tmuxSessionId,
764
+ }
765
+ : undefined;
766
+ const cachedId = cacheKey ? getCachedAgentsWindowId(cacheKey) : undefined;
767
+ const windowId =
768
+ cachedId ??
769
+ (ownership
770
+ ? findOwnedAgentsWindowSync(
771
+ ownership.serverKey,
772
+ ownership.instanceId,
773
+ ownership.tmuxSessionId
774
+ )
775
+ : undefined);
433
776
  if (!windowId) return;
434
777
 
435
- // Kill the entire window synchronously
778
+ const checkFormat = ownership
779
+ ? `#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`
780
+ : '#{window_name}';
781
+ const checkResult = runTmuxCommandSync(['display', '-p', '-t', windowId, checkFormat]);
782
+
783
+ if (!checkResult.success) {
784
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
785
+ return;
786
+ }
787
+
788
+ const parts = checkResult.output?.trim().split('\t') ?? [];
789
+ const windowName = parts[0];
790
+ if (windowName !== 'Agents') {
791
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
792
+ return;
793
+ }
794
+ if (ownership) {
795
+ const [, isOpencode, windowServerKey, windowInstanceId] = parts;
796
+ if (
797
+ isOpencode !== '1' ||
798
+ windowServerKey !== ownership.serverKey ||
799
+ windowInstanceId !== ownership.instanceId
800
+ ) {
801
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
802
+ return;
803
+ }
804
+ }
805
+
436
806
  runTmuxCommandSync(['kill-window', '-t', windowId]);
437
- agentsWindowId = undefined;
438
- clearPersistedAgentsWindowId();
807
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
439
808
  }
440
809
 
441
810
  /**
442
811
  * Get the current agents window ID (for testing/debugging)
443
- * Also checks persisted file for crash recovery
444
812
  */
445
- export function getAgentsWindowId(): string | undefined {
446
- return agentsWindowId ?? loadPersistedAgentsWindowId();
813
+ export function getAgentsWindowId(ownership?: OwnershipContext): string | undefined {
814
+ if (ownership) {
815
+ return getCachedAgentsWindowId(ownership);
816
+ }
817
+ // Return first cached window ID (for backwards compatibility in tests)
818
+ const values = agentsWindowIdByKey.values();
819
+ const first = values.next();
820
+ return first.done ? undefined : first.value;
447
821
  }
448
822
 
449
823
  /**
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
824
+ * Clean up owned tmux windows/panes using ownership tags.
456
825
  */
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);
826
+ export async function cleanupOwnedResources(
827
+ serverKey: string,
828
+ instanceId: string
829
+ ): Promise<{ panesClosed: number; windowClosed: boolean }> {
830
+ if (!instanceId) return { panesClosed: 0, windowClosed: false };
831
+
832
+ const windowsResult = await runTmuxCommand([
833
+ 'list-windows',
834
+ '-a',
835
+ '-F',
836
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
837
+ ]);
838
+ const windowsToClose = new Set<string>();
839
+ const serverWindowIds = new Set<string>();
840
+
841
+ if (windowsResult.success && windowsResult.output) {
842
+ for (const line of windowsResult.output.split('\n')) {
843
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId] =
844
+ line.split('\t');
845
+ if (!windowId) continue;
846
+ if (windowServerKey === serverKey) {
847
+ serverWindowIds.add(windowId);
484
848
  }
849
+ if (windowName !== 'Agents') continue;
850
+ if (isOpencode !== '1') continue;
851
+ if (windowServerKey !== serverKey) continue;
852
+ if (windowInstanceId !== instanceId) continue;
853
+ windowsToClose.add(windowId);
485
854
  }
855
+ }
486
856
 
487
- if (matchingPids.length === 0) {
488
- log('No orphaned opencode attach processes found');
489
- return 0;
490
- }
857
+ const panesResult = await runTmuxCommand([
858
+ 'list-panes',
859
+ '-a',
860
+ '-F',
861
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}`,
862
+ ]);
491
863
 
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
- }
864
+ const panesToClose: Array<{ paneId: string; panePid?: number; windowId: string }> = [];
865
+ if (panesResult.success && panesResult.output) {
866
+ for (const line of panesResult.output.split('\n')) {
867
+ const [paneId, panePidRaw, windowId, paneInstanceId] = line.split('\t');
868
+ if (!paneId) continue;
869
+ if (!windowId || !serverWindowIds.has(windowId)) continue;
870
+ if (paneInstanceId !== instanceId) continue;
871
+ const panePid = Number(panePidRaw);
872
+ panesToClose.push({
873
+ paneId,
874
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
875
+ windowId,
876
+ });
508
877
  }
878
+ }
509
879
 
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
- }
880
+ for (const pane of panesToClose) {
881
+ if (pane.panePid) {
882
+ await killProcessByPid(pane.panePid);
521
883
  }
884
+ }
522
885
 
523
- log(`Cleanup complete: killed ${killed} processes`);
524
- return killed;
525
- } catch (error) {
526
- log(`Fallback cleanup failed: ${error}`);
527
- return 0;
886
+ let windowClosed = false;
887
+ for (const windowId of windowsToClose) {
888
+ await runTmuxCommand(['kill-window', '-t', windowId]);
889
+ windowClosed = true;
528
890
  }
891
+
892
+ let panesClosed = 0;
893
+ for (const pane of panesToClose) {
894
+ if (windowsToClose.has(pane.windowId)) continue;
895
+ await runTmuxCommand(['kill-pane', '-t', pane.paneId]);
896
+ panesClosed += 1;
897
+ }
898
+
899
+ return { panesClosed, windowClosed };
529
900
  }
530
901
 
531
902
  /**
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
903
+ * Synchronous cleanup for owned tmux windows/panes.
538
904
  */
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);
905
+ export function cleanupOwnedResourcesSync(
906
+ serverKey: string,
907
+ instanceId: string
908
+ ): { panesClosed: number; windowClosed: boolean } {
909
+ if (!instanceId) return { panesClosed: 0, windowClosed: false };
910
+
911
+ const windowsResult = runTmuxCommandSync([
912
+ 'list-windows',
913
+ '-a',
914
+ '-F',
915
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
916
+ ]);
917
+ const windowsToClose = new Set<string>();
918
+ const serverWindowIds = new Set<string>();
919
+
920
+ if (windowsResult.success && windowsResult.output) {
921
+ for (const line of windowsResult.output.split('\n')) {
922
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId] =
923
+ line.split('\t');
924
+ if (!windowId) continue;
925
+ if (windowServerKey === serverKey) {
926
+ serverWindowIds.add(windowId);
567
927
  }
928
+ if (windowName !== 'Agents') continue;
929
+ if (isOpencode !== '1') continue;
930
+ if (windowServerKey !== serverKey) continue;
931
+ if (windowInstanceId !== instanceId) continue;
932
+ windowsToClose.add(windowId);
568
933
  }
934
+ }
569
935
 
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
- }
936
+ const panesResult = runTmuxCommandSync([
937
+ 'list-panes',
938
+ '-a',
939
+ '-F',
940
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}`,
941
+ ]);
942
+ const panesToClose: Array<{ paneId: string; panePid?: number; windowId: string }> = [];
943
+
944
+ if (panesResult.success && panesResult.output) {
945
+ for (const line of panesResult.output.split('\n')) {
946
+ const [paneId, panePidRaw, windowId, paneInstanceId] = line.split('\t');
947
+ if (!paneId) continue;
948
+ if (!windowId || !serverWindowIds.has(windowId)) continue;
949
+ if (paneInstanceId !== instanceId) continue;
950
+ const panePid = Number(panePidRaw);
951
+ panesToClose.push({
952
+ paneId,
953
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
954
+ windowId,
955
+ });
590
956
  }
957
+ }
591
958
 
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
959
+ for (const pane of panesToClose) {
960
+ if (pane.panePid) {
961
+ killProcessByPidSync(pane.panePid);
599
962
  }
963
+ }
600
964
 
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
- }
965
+ let windowClosed = false;
966
+ for (const windowId of windowsToClose) {
967
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
968
+ windowClosed = true;
969
+ }
611
970
 
612
- log(`Cleanup complete: killed ${killed} processes`);
613
- return killed;
614
- } catch (error) {
615
- log(`Fallback cleanup failed: ${error}`);
616
- return 0;
971
+ let panesClosed = 0;
972
+ for (const pane of panesToClose) {
973
+ if (windowsToClose.has(pane.windowId)) continue;
974
+ runTmuxCommandSync(['kill-pane', '-t', pane.paneId]);
975
+ panesClosed += 1;
617
976
  }
977
+
978
+ return { panesClosed, windowClosed };
618
979
  }