@agentuity/opencode 0.1.41 → 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.
Files changed (51) 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/background/manager.d.ts +1 -0
  7. package/dist/background/manager.d.ts.map +1 -1
  8. package/dist/background/manager.js +6 -0
  9. package/dist/background/manager.js.map +1 -1
  10. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  11. package/dist/plugin/hooks/cadence.js +3 -1
  12. package/dist/plugin/hooks/cadence.js.map +1 -1
  13. package/dist/plugin/plugin.d.ts.map +1 -1
  14. package/dist/plugin/plugin.js +54 -11
  15. package/dist/plugin/plugin.js.map +1 -1
  16. package/dist/skills/frontmatter.js +1 -1
  17. package/dist/skills/frontmatter.js.map +1 -1
  18. package/dist/tmux/executor.d.ts +57 -6
  19. package/dist/tmux/executor.d.ts.map +1 -1
  20. package/dist/tmux/executor.js +676 -57
  21. package/dist/tmux/executor.js.map +1 -1
  22. package/dist/tmux/index.d.ts +1 -1
  23. package/dist/tmux/index.d.ts.map +1 -1
  24. package/dist/tmux/index.js +1 -1
  25. package/dist/tmux/index.js.map +1 -1
  26. package/dist/tmux/manager.d.ts +70 -0
  27. package/dist/tmux/manager.d.ts.map +1 -1
  28. package/dist/tmux/manager.js +357 -22
  29. package/dist/tmux/manager.js.map +1 -1
  30. package/dist/tmux/state-query.d.ts.map +1 -1
  31. package/dist/tmux/state-query.js +4 -1
  32. package/dist/tmux/state-query.js.map +1 -1
  33. package/dist/tmux/types.d.ts +11 -0
  34. package/dist/tmux/types.d.ts.map +1 -1
  35. package/dist/tmux/types.js.map +1 -1
  36. package/dist/tmux/utils.d.ts +17 -0
  37. package/dist/tmux/utils.d.ts.map +1 -1
  38. package/dist/tmux/utils.js +39 -0
  39. package/dist/tmux/utils.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/agents/lead.ts +2 -3
  42. package/src/background/manager.ts +6 -0
  43. package/src/plugin/hooks/cadence.ts +2 -1
  44. package/src/plugin/plugin.ts +67 -11
  45. package/src/skills/frontmatter.ts +1 -1
  46. package/src/tmux/executor.ts +748 -55
  47. package/src/tmux/index.ts +6 -0
  48. package/src/tmux/manager.ts +410 -21
  49. package/src/tmux/state-query.ts +4 -1
  50. package/src/tmux/types.ts +12 -0
  51. package/src/tmux/utils.ts +39 -0
@@ -1,5 +1,6 @@
1
1
  import type { PaneAction, WindowState, TmuxConfig } from './types';
2
2
  import { runTmuxCommand, runTmuxCommandSync } from './utils';
3
+ import { spawn, spawnSync } from 'bun';
3
4
 
4
5
  /**
5
6
  * Escape a string for safe use in shell commands.
@@ -13,17 +14,323 @@ function shellEscape(str: string): string {
13
14
  /** Maximum retries for recursive spawn attempts to prevent infinite loops */
14
15
  const MAX_SPAWN_RETRIES = 3;
15
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
+
16
36
  export interface ActionResult {
17
37
  success: boolean;
18
38
  paneId?: string;
19
39
  windowId?: string;
40
+ pid?: number;
20
41
  error?: string;
21
42
  }
22
43
 
44
+ const PROCESS_TERM_WAIT_MS = 1000;
45
+
46
+ function isProcessAlive(pid: number): boolean {
47
+ try {
48
+ process.kill(pid, 0);
49
+ return true;
50
+ } catch (error) {
51
+ const code = (error as NodeJS.ErrnoException).code;
52
+ return code !== 'ESRCH';
53
+ }
54
+ }
55
+
56
+ export async function getPanePid(paneId: string): Promise<number | undefined> {
57
+ if (!paneId) return undefined;
58
+ const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{pane_pid}']);
59
+ if (!result.success) return undefined;
60
+ const pid = Number(result.output.trim());
61
+ if (!Number.isFinite(pid) || pid <= 0) return undefined;
62
+ return pid;
63
+ }
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
+
244
+ /**
245
+ * Kill a process and all its children (the entire process tree).
246
+ *
247
+ * This is necessary because we spawn `bash -c "opencode attach ...; tmux kill-pane"`
248
+ * and #{pane_pid} returns the bash PID, not the opencode attach PID.
249
+ * We need to kill the children (opencode attach) not just the parent (bash).
250
+ */
251
+ export async function killProcessByPid(pid: number): Promise<boolean> {
252
+ if (!Number.isFinite(pid) || pid <= 0) return false;
253
+
254
+ // First, kill all child processes
255
+ try {
256
+ const proc = spawn(['pkill', '-TERM', '-P', String(pid)], {
257
+ stdout: 'pipe',
258
+ stderr: 'pipe',
259
+ });
260
+ await proc.exited;
261
+ } catch {
262
+ // Ignore errors - children may not exist
263
+ }
264
+
265
+ // Then kill the parent
266
+ try {
267
+ process.kill(pid, 'SIGTERM');
268
+ } catch (error) {
269
+ const code = (error as NodeJS.ErrnoException).code;
270
+ if (code === 'ESRCH') return true;
271
+ return false;
272
+ }
273
+
274
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_TERM_WAIT_MS));
275
+
276
+ // Check if parent and children are dead
277
+ if (!isProcessAlive(pid)) return true;
278
+
279
+ // Force kill children
280
+ try {
281
+ const proc = spawn(['pkill', '-KILL', '-P', String(pid)], {
282
+ stdout: 'pipe',
283
+ stderr: 'pipe',
284
+ });
285
+ await proc.exited;
286
+ } catch {
287
+ // Ignore errors
288
+ }
289
+
290
+ // Force kill parent
291
+ try {
292
+ process.kill(pid, 'SIGKILL');
293
+ } catch (error) {
294
+ const code = (error as NodeJS.ErrnoException).code;
295
+ if (code === 'ESRCH') return true;
296
+ return false;
297
+ }
298
+
299
+ return !isProcessAlive(pid);
300
+ }
301
+
23
302
  /**
24
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.
25
306
  */
26
- let agentsWindowId: string | undefined;
307
+ const agentsWindowIdByKey = new Map<string, string>();
308
+
309
+ /**
310
+ * Get the cache key for the agents window
311
+ */
312
+ function getAgentsWindowCacheKey(ownership: OwnershipContext): string {
313
+ return `${ownership.serverKey}:${ownership.instanceId}:${ownership.tmuxSessionId ?? 'default'}`;
314
+ }
315
+
316
+ /**
317
+ * Get the cached agents window ID for the given ownership context
318
+ */
319
+ function getCachedAgentsWindowId(ownership: OwnershipContext): string | undefined {
320
+ return agentsWindowIdByKey.get(getAgentsWindowCacheKey(ownership));
321
+ }
322
+
323
+ /**
324
+ * Set the cached agents window ID for the given ownership context
325
+ */
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);
332
+ }
333
+ }
27
334
 
28
335
  /**
29
336
  * Execute a single pane action
@@ -32,11 +339,19 @@ let agentsWindowId: string | undefined;
32
339
  */
33
340
  export async function executeAction(
34
341
  action: PaneAction,
35
- ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
342
+ ctx: {
343
+ config: TmuxConfig;
344
+ serverUrl: string;
345
+ windowState: WindowState;
346
+ ownership: OwnershipContext;
347
+ }
36
348
  ): Promise<ActionResult> {
37
349
  switch (action.type) {
38
350
  case 'spawn':
39
- return spawnInAgentsWindow(action, { serverUrl: ctx.serverUrl });
351
+ return spawnInAgentsWindow(action, {
352
+ serverUrl: ctx.serverUrl,
353
+ ownership: ctx.ownership,
354
+ });
40
355
  case 'close':
41
356
  return closePane(action);
42
357
  case 'replace':
@@ -49,7 +364,12 @@ export async function executeAction(
49
364
  */
50
365
  export async function executeActions(
51
366
  actions: PaneAction[],
52
- ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
367
+ ctx: {
368
+ config: TmuxConfig;
369
+ serverUrl: string;
370
+ windowState: WindowState;
371
+ ownership: OwnershipContext;
372
+ }
53
373
  ): Promise<{
54
374
  success: boolean;
55
375
  spawnedPaneId?: string;
@@ -77,18 +397,23 @@ export async function executeActions(
77
397
  * Uses: tmux kill-pane -t <paneId>
78
398
  */
79
399
  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 };
400
+ return closePaneById(action.paneId);
85
401
  }
86
402
 
87
403
  /**
88
404
  * Close a pane by its ID
89
405
  * Exported for use by TmuxSessionManager when sessions complete
90
406
  */
91
- export async function closePaneById(paneId: string): Promise<ActionResult> {
407
+ export async function closePaneById(paneId: string, pid?: number): Promise<ActionResult> {
408
+ let resolvedPid = pid;
409
+ if (!resolvedPid) {
410
+ resolvedPid = await resolvePanePidWithRetry(paneId);
411
+ }
412
+
413
+ if (resolvedPid) {
414
+ await killProcessByPid(resolvedPid);
415
+ }
416
+
92
417
  const result = await runTmuxCommand(['kill-pane', '-t', paneId]);
93
418
  if (!result.success) {
94
419
  return { success: false, error: result.output };
@@ -102,18 +427,22 @@ export async function closePaneById(paneId: string): Promise<ActionResult> {
102
427
  */
103
428
  async function replacePane(
104
429
  action: Extract<PaneAction, { type: 'replace' }>,
105
- ctx: { serverUrl: string }
430
+ ctx: { serverUrl: string; ownership: OwnershipContext }
106
431
  ): Promise<ActionResult> {
107
- // 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).
108
435
  // Use shellEscape to prevent shell injection via session IDs
109
436
  const escapedServerUrl = shellEscape(ctx.serverUrl);
110
437
  const escapedSessionId = shellEscape(action.newSessionId);
111
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
438
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
112
439
  const result = await runTmuxCommand(['respawn-pane', '-k', '-t', action.paneId, command]);
113
440
  if (!result.success) {
114
441
  return { success: false, error: result.output };
115
442
  }
116
- return { success: true, paneId: action.paneId };
443
+ await setPaneTags(action.paneId, ctx.ownership, action.newSessionId);
444
+ const pid = await resolvePanePidWithRetry(action.paneId);
445
+ return { success: true, paneId: action.paneId, pid };
117
446
  }
118
447
 
119
448
  /**
@@ -130,7 +459,7 @@ async function replacePane(
130
459
  */
131
460
  async function spawnInAgentsWindow(
132
461
  action: Extract<PaneAction, { type: 'spawn' }>,
133
- ctx: { serverUrl: string },
462
+ ctx: { serverUrl: string; ownership: OwnershipContext },
134
463
  retryCount = 0
135
464
  ): Promise<ActionResult> {
136
465
  // Prevent infinite recursion if tmux keeps failing
@@ -141,41 +470,77 @@ async function spawnInAgentsWindow(
141
470
  };
142
471
  }
143
472
 
144
- // 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).
145
476
  // Use shellEscape to prevent shell injection via session IDs
146
477
  const escapedServerUrl = shellEscape(ctx.serverUrl);
147
478
  const escapedSessionId = shellEscape(action.sessionId);
148
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
479
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
149
480
  const layout = 'tiled'; // Always use tiled layout for grid arrangement
150
481
 
151
482
  // Check if we have a cached agents window ID and if it still exists
152
- if (agentsWindowId) {
483
+ let cachedWindowId = getCachedAgentsWindowId(ctx.ownership);
484
+ if (cachedWindowId) {
153
485
  const checkResult = await runTmuxCommand([
154
- 'list-panes',
486
+ 'display',
487
+ '-p',
155
488
  '-t',
156
- agentsWindowId,
157
- '-F',
158
- '#{pane_id}',
489
+ cachedWindowId,
490
+ `#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
159
491
  ]);
160
492
 
161
- if (!checkResult.success) {
162
- // Window no longer exists, clear the cache
163
- 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);
164
519
  }
165
520
  }
166
521
 
167
522
  // If no agents window exists, create one
168
- if (!agentsWindowId) {
169
- const createResult = await runTmuxCommand([
170
- '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(
171
534
  '-d', // Don't switch to new window
172
535
  '-P',
173
536
  '-F',
174
537
  '#{window_id}:#{pane_id}',
175
538
  '-n',
176
539
  'Agents',
177
- command,
178
- ]);
540
+ command
541
+ );
542
+
543
+ const createResult = await runTmuxCommand(newWindowArgs);
179
544
 
180
545
  if (!createResult.success) {
181
546
  return { success: false, error: createResult.output };
@@ -184,14 +549,24 @@ async function spawnInAgentsWindow(
184
549
  // Parse window_id:pane_id from output
185
550
  const output = createResult.output?.trim() || '';
186
551
  const [windowId, paneId] = output.split(':');
187
- agentsWindowId = windowId;
552
+ cachedWindowId = windowId;
553
+
554
+ if (cachedWindowId) {
555
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
556
+ await setWindowTags(cachedWindowId, ctx.ownership);
557
+ }
188
558
 
189
559
  // Apply initial layout (useful when more panes are added later)
190
- if (agentsWindowId && layout) {
191
- 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);
192
566
  }
193
567
 
194
- return { success: true, paneId, windowId };
568
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
569
+ return { success: true, paneId, windowId, pid };
195
570
  }
196
571
 
197
572
  // Agents window exists - split within it
@@ -199,21 +574,21 @@ async function spawnInAgentsWindow(
199
574
  const listResult = await runTmuxCommand([
200
575
  'list-panes',
201
576
  '-t',
202
- agentsWindowId,
577
+ cachedWindowId,
203
578
  '-F',
204
579
  '#{pane_id}',
205
580
  ]);
206
581
 
207
582
  if (!listResult.success || !listResult.output) {
208
583
  // Fallback: create new window (with retry counter)
209
- agentsWindowId = undefined;
584
+ setCachedAgentsWindowId(ctx.ownership, undefined);
210
585
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
211
586
  }
212
587
 
213
588
  const targetPaneId = listResult.output.split('\n')[0]?.trim();
214
589
  if (!targetPaneId) {
215
590
  // Fallback: create new window (with retry counter)
216
- agentsWindowId = undefined;
591
+ setCachedAgentsWindowId(ctx.ownership, undefined);
217
592
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
218
593
  }
219
594
 
@@ -234,53 +609,371 @@ async function spawnInAgentsWindow(
234
609
  }
235
610
 
236
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
+ }
237
618
 
238
619
  // Apply the configured layout to the agents window (e.g., tiled for grid)
239
- if (agentsWindowId && layout) {
240
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
620
+ if (cachedWindowId && layout) {
621
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
241
622
  }
242
623
 
624
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
243
625
  return {
244
626
  success: true,
245
627
  paneId: paneId || undefined,
246
- windowId: agentsWindowId,
628
+ windowId: cachedWindowId,
629
+ pid,
247
630
  };
248
631
  }
249
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
+
250
677
  /**
251
678
  * Reset the agents window state (for cleanup)
252
679
  */
253
- export function resetAgentsWindow(): void {
254
- agentsWindowId = undefined;
680
+ export function resetAgentsWindow(ownership?: OwnershipContext): void {
681
+ if (ownership) {
682
+ setCachedAgentsWindowId(ownership, undefined);
683
+ } else {
684
+ agentsWindowIdByKey.clear();
685
+ }
255
686
  }
256
687
 
257
688
  /**
258
689
  * Close the agents window if it exists
259
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.
260
694
  */
261
- export async function closeAgentsWindow(): Promise<void> {
262
- if (!agentsWindowId) return;
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);
715
+ if (!windowId) return;
716
+
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
+ }
263
726
 
264
- // Kill the entire window (closes all panes within it)
265
- await runTmuxCommand(['kill-window', '-t', agentsWindowId]);
266
- agentsWindowId = undefined;
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
+
745
+ await runTmuxCommand(['kill-window', '-t', windowId]);
746
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
267
747
  }
268
748
 
269
749
  /**
270
750
  * Synchronously close the agents window (for shutdown)
271
- * 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.
272
755
  */
273
- export function closeAgentsWindowSync(): void {
274
- if (!agentsWindowId) return;
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);
776
+ if (!windowId) return;
777
+
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
+ }
275
805
 
276
- // Kill the entire window synchronously
277
- runTmuxCommandSync(['kill-window', '-t', agentsWindowId]);
278
- agentsWindowId = undefined;
806
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
807
+ if (cacheKey) setCachedAgentsWindowId(cacheKey, undefined);
279
808
  }
280
809
 
281
810
  /**
282
811
  * Get the current agents window ID (for testing/debugging)
283
812
  */
284
- export function getAgentsWindowId(): string | undefined {
285
- return agentsWindowId;
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;
821
+ }
822
+
823
+ /**
824
+ * Clean up owned tmux windows/panes using ownership tags.
825
+ */
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);
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);
854
+ }
855
+ }
856
+
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
+ ]);
863
+
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
+ });
877
+ }
878
+ }
879
+
880
+ for (const pane of panesToClose) {
881
+ if (pane.panePid) {
882
+ await killProcessByPid(pane.panePid);
883
+ }
884
+ }
885
+
886
+ let windowClosed = false;
887
+ for (const windowId of windowsToClose) {
888
+ await runTmuxCommand(['kill-window', '-t', windowId]);
889
+ windowClosed = true;
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 };
900
+ }
901
+
902
+ /**
903
+ * Synchronous cleanup for owned tmux windows/panes.
904
+ */
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);
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);
933
+ }
934
+ }
935
+
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
+ });
956
+ }
957
+ }
958
+
959
+ for (const pane of panesToClose) {
960
+ if (pane.panePid) {
961
+ killProcessByPidSync(pane.panePid);
962
+ }
963
+ }
964
+
965
+ let windowClosed = false;
966
+ for (const windowId of windowsToClose) {
967
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
968
+ windowClosed = true;
969
+ }
970
+
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;
976
+ }
977
+
978
+ return { panesClosed, windowClosed };
286
979
  }