@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,4 +1,5 @@
1
1
  import { runTmuxCommand, runTmuxCommandSync } from './utils';
2
+ import { spawn, spawnSync } from 'bun';
2
3
  /**
3
4
  * Escape a string for safe use in shell commands.
4
5
  * Wraps in single quotes and escapes any internal single quotes.
@@ -9,10 +10,285 @@ function shellEscape(str) {
9
10
  }
10
11
  /** Maximum retries for recursive spawn attempts to prevent infinite loops */
11
12
  const MAX_SPAWN_RETRIES = 3;
13
+ const OPENCODE_TAG = '@agentuity.opencode';
14
+ const OPENCODE_SERVER_TAG = '@agentuity.opencode.server';
15
+ const OPENCODE_OWNER_TAG = '@agentuity.opencode.ownerPid';
16
+ const OPENCODE_INSTANCE_TAG = '@agentuity.opencode.instance';
17
+ const OPENCODE_SESSION_TAG = '@agentuity.opencode.session';
18
+ const PROCESS_TERM_WAIT_MS = 1000;
19
+ function isProcessAlive(pid) {
20
+ try {
21
+ process.kill(pid, 0);
22
+ return true;
23
+ }
24
+ catch (error) {
25
+ const code = error.code;
26
+ return code !== 'ESRCH';
27
+ }
28
+ }
29
+ export async function getPanePid(paneId) {
30
+ if (!paneId)
31
+ return undefined;
32
+ const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{pane_pid}']);
33
+ if (!result.success)
34
+ return undefined;
35
+ const pid = Number(result.output.trim());
36
+ if (!Number.isFinite(pid) || pid <= 0)
37
+ return undefined;
38
+ return pid;
39
+ }
40
+ export function getPanePidSync(paneId) {
41
+ if (!paneId)
42
+ return undefined;
43
+ const result = runTmuxCommandSync(['display', '-p', '-t', paneId, '#{pane_pid}']);
44
+ if (!result.success)
45
+ return undefined;
46
+ const pid = Number(result.output.trim());
47
+ if (!Number.isFinite(pid) || pid <= 0)
48
+ return undefined;
49
+ return pid;
50
+ }
51
+ async function resolvePanePidWithRetry(paneId, maxAttempts = 3, delayMs = 75) {
52
+ let pid = await getPanePid(paneId);
53
+ if (pid)
54
+ return pid;
55
+ for (let attempt = 1; attempt < maxAttempts; attempt += 1) {
56
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
57
+ pid = await getPanePid(paneId);
58
+ if (pid)
59
+ return pid;
60
+ }
61
+ return pid;
62
+ }
63
+ async function setWindowTags(windowId, ownership) {
64
+ await runTmuxCommand(['set-option', '-w', '-t', windowId, OPENCODE_TAG, '1']);
65
+ await runTmuxCommand([
66
+ 'set-option',
67
+ '-w',
68
+ '-t',
69
+ windowId,
70
+ OPENCODE_SERVER_TAG,
71
+ ownership.serverKey,
72
+ ]);
73
+ await runTmuxCommand([
74
+ 'set-option',
75
+ '-w',
76
+ '-t',
77
+ windowId,
78
+ OPENCODE_OWNER_TAG,
79
+ String(ownership.ownerPid),
80
+ ]);
81
+ await runTmuxCommand([
82
+ 'set-option',
83
+ '-w',
84
+ '-t',
85
+ windowId,
86
+ OPENCODE_INSTANCE_TAG,
87
+ ownership.instanceId,
88
+ ]);
89
+ }
90
+ async function setPaneTags(paneId, ownership, sessionId) {
91
+ await runTmuxCommand([
92
+ 'set-option',
93
+ '-p',
94
+ '-t',
95
+ paneId,
96
+ OPENCODE_INSTANCE_TAG,
97
+ ownership.instanceId,
98
+ ]);
99
+ if (sessionId) {
100
+ await runTmuxCommand(['set-option', '-p', '-t', paneId, OPENCODE_SESSION_TAG, sessionId]);
101
+ }
102
+ }
103
+ async function findOwnedAgentsWindow(serverKey, instanceId, tmuxSessionId) {
104
+ const result = await runTmuxCommand([
105
+ 'list-windows',
106
+ '-a',
107
+ '-F',
108
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}\t#{session_id}`,
109
+ ]);
110
+ if (!result.success || !result.output)
111
+ return undefined;
112
+ for (const line of result.output.split('\n')) {
113
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId, sessionId] = line.split('\t');
114
+ if (!windowId)
115
+ continue;
116
+ if (windowName !== 'Agents')
117
+ continue;
118
+ if (isOpencode !== '1')
119
+ continue;
120
+ if (windowServerKey !== serverKey)
121
+ continue;
122
+ if (windowInstanceId !== instanceId)
123
+ continue;
124
+ // If tmuxSessionId is provided, only match windows in that session
125
+ if (tmuxSessionId && sessionId !== tmuxSessionId)
126
+ continue;
127
+ return windowId;
128
+ }
129
+ return undefined;
130
+ }
131
+ function findOwnedAgentsWindowSync(serverKey, instanceId, tmuxSessionId) {
132
+ const result = runTmuxCommandSync([
133
+ 'list-windows',
134
+ '-a',
135
+ '-F',
136
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}\t#{session_id}`,
137
+ ]);
138
+ if (!result.success || !result.output)
139
+ return undefined;
140
+ for (const line of result.output.split('\n')) {
141
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId, sessionId] = line.split('\t');
142
+ if (!windowId)
143
+ continue;
144
+ if (windowName !== 'Agents')
145
+ continue;
146
+ if (isOpencode !== '1')
147
+ continue;
148
+ if (windowServerKey !== serverKey)
149
+ continue;
150
+ if (windowInstanceId !== instanceId)
151
+ continue;
152
+ // If tmuxSessionId is provided, only match windows in that session
153
+ if (tmuxSessionId && sessionId !== tmuxSessionId)
154
+ continue;
155
+ return windowId;
156
+ }
157
+ return undefined;
158
+ }
159
+ export async function findOwnedAgentPanes(serverKey) {
160
+ const windowsResult = await runTmuxCommand([
161
+ 'list-windows',
162
+ '-a',
163
+ '-F',
164
+ `#{window_id}\t#{${OPENCODE_SERVER_TAG}}`,
165
+ ]);
166
+ if (!windowsResult.success || !windowsResult.output)
167
+ return [];
168
+ const serverWindowIds = new Set();
169
+ for (const line of windowsResult.output.split('\n')) {
170
+ const [windowId, windowServerKey] = line.split('\t');
171
+ if (!windowId)
172
+ continue;
173
+ if (windowServerKey !== serverKey)
174
+ continue;
175
+ serverWindowIds.add(windowId);
176
+ }
177
+ if (serverWindowIds.size === 0)
178
+ return [];
179
+ const result = await runTmuxCommand([
180
+ 'list-panes',
181
+ '-a',
182
+ '-F',
183
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}\t#{${OPENCODE_SESSION_TAG}}`,
184
+ ]);
185
+ if (!result.success || !result.output)
186
+ return [];
187
+ const panes = [];
188
+ for (const line of result.output.split('\n')) {
189
+ const [paneId, panePidRaw, windowId, paneInstanceId, paneSessionId] = line.split('\t');
190
+ if (!paneId)
191
+ continue;
192
+ if (!windowId || !serverWindowIds.has(windowId))
193
+ continue;
194
+ const panePid = Number(panePidRaw);
195
+ panes.push({
196
+ paneId,
197
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
198
+ sessionId: paneSessionId || undefined,
199
+ instanceId: paneInstanceId || undefined,
200
+ });
201
+ }
202
+ return panes;
203
+ }
204
+ /**
205
+ * Kill a process and all its children (the entire process tree).
206
+ *
207
+ * This is necessary because we spawn `bash -c "opencode attach ...; tmux kill-pane"`
208
+ * and #{pane_pid} returns the bash PID, not the opencode attach PID.
209
+ * We need to kill the children (opencode attach) not just the parent (bash).
210
+ */
211
+ export async function killProcessByPid(pid) {
212
+ if (!Number.isFinite(pid) || pid <= 0)
213
+ return false;
214
+ // First, kill all child processes
215
+ try {
216
+ const proc = spawn(['pkill', '-TERM', '-P', String(pid)], {
217
+ stdout: 'pipe',
218
+ stderr: 'pipe',
219
+ });
220
+ await proc.exited;
221
+ }
222
+ catch {
223
+ // Ignore errors - children may not exist
224
+ }
225
+ // Then kill the parent
226
+ try {
227
+ process.kill(pid, 'SIGTERM');
228
+ }
229
+ catch (error) {
230
+ const code = error.code;
231
+ if (code === 'ESRCH')
232
+ return true;
233
+ return false;
234
+ }
235
+ await new Promise((resolve) => setTimeout(resolve, PROCESS_TERM_WAIT_MS));
236
+ // Check if parent and children are dead
237
+ if (!isProcessAlive(pid))
238
+ return true;
239
+ // Force kill children
240
+ try {
241
+ const proc = spawn(['pkill', '-KILL', '-P', String(pid)], {
242
+ stdout: 'pipe',
243
+ stderr: 'pipe',
244
+ });
245
+ await proc.exited;
246
+ }
247
+ catch {
248
+ // Ignore errors
249
+ }
250
+ // Force kill parent
251
+ try {
252
+ process.kill(pid, 'SIGKILL');
253
+ }
254
+ catch (error) {
255
+ const code = error.code;
256
+ if (code === 'ESRCH')
257
+ return true;
258
+ return false;
259
+ }
260
+ return !isProcessAlive(pid);
261
+ }
12
262
  /**
13
263
  * State for separate-window mode - tracks the dedicated "Agents" window
264
+ * Keyed by `${serverKey}:${instanceId}:${tmuxSessionId}` to support multiple
265
+ * opencode instances running in different tmux sessions.
266
+ */
267
+ const agentsWindowIdByKey = new Map();
268
+ /**
269
+ * Get the cache key for the agents window
270
+ */
271
+ function getAgentsWindowCacheKey(ownership) {
272
+ return `${ownership.serverKey}:${ownership.instanceId}:${ownership.tmuxSessionId ?? 'default'}`;
273
+ }
274
+ /**
275
+ * Get the cached agents window ID for the given ownership context
276
+ */
277
+ function getCachedAgentsWindowId(ownership) {
278
+ return agentsWindowIdByKey.get(getAgentsWindowCacheKey(ownership));
279
+ }
280
+ /**
281
+ * Set the cached agents window ID for the given ownership context
14
282
  */
15
- let agentsWindowId;
283
+ function setCachedAgentsWindowId(ownership, windowId) {
284
+ const key = getAgentsWindowCacheKey(ownership);
285
+ if (windowId) {
286
+ agentsWindowIdByKey.set(key, windowId);
287
+ }
288
+ else {
289
+ agentsWindowIdByKey.delete(key);
290
+ }
291
+ }
16
292
  /**
17
293
  * Execute a single pane action
18
294
  *
@@ -21,7 +297,10 @@ let agentsWindowId;
21
297
  export async function executeAction(action, ctx) {
22
298
  switch (action.type) {
23
299
  case 'spawn':
24
- return spawnInAgentsWindow(action, { serverUrl: ctx.serverUrl });
300
+ return spawnInAgentsWindow(action, {
301
+ serverUrl: ctx.serverUrl,
302
+ ownership: ctx.ownership,
303
+ });
25
304
  case 'close':
26
305
  return closePane(action);
27
306
  case 'replace':
@@ -51,17 +330,20 @@ export async function executeActions(actions, ctx) {
51
330
  * Uses: tmux kill-pane -t <paneId>
52
331
  */
53
332
  async function closePane(action) {
54
- const result = await runTmuxCommand(['kill-pane', '-t', action.paneId]);
55
- if (!result.success) {
56
- return { success: false, error: result.output };
57
- }
58
- return { success: true };
333
+ return closePaneById(action.paneId);
59
334
  }
60
335
  /**
61
336
  * Close a pane by its ID
62
337
  * Exported for use by TmuxSessionManager when sessions complete
63
338
  */
64
- export async function closePaneById(paneId) {
339
+ export async function closePaneById(paneId, pid) {
340
+ let resolvedPid = pid;
341
+ if (!resolvedPid) {
342
+ resolvedPid = await resolvePanePidWithRetry(paneId);
343
+ }
344
+ if (resolvedPid) {
345
+ await killProcessByPid(resolvedPid);
346
+ }
65
347
  const result = await runTmuxCommand(['kill-pane', '-t', paneId]);
66
348
  if (!result.success) {
67
349
  return { success: false, error: result.output };
@@ -73,16 +355,20 @@ export async function closePaneById(paneId) {
73
355
  * Pane self-destructs when command exits (session complete, server died, etc.)
74
356
  */
75
357
  async function replacePane(action, ctx) {
76
- // Pane kills itself when opencode attach exits (for any reason)
358
+ // Use exec to replace bash with opencode attach directly.
359
+ // This ensures signals go directly to opencode attach (no wrapper process).
360
+ // When opencode attach exits, the pane closes automatically (tmux remain-on-exit off).
77
361
  // Use shellEscape to prevent shell injection via session IDs
78
362
  const escapedServerUrl = shellEscape(ctx.serverUrl);
79
363
  const escapedSessionId = shellEscape(action.newSessionId);
80
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
364
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
81
365
  const result = await runTmuxCommand(['respawn-pane', '-k', '-t', action.paneId, command]);
82
366
  if (!result.success) {
83
367
  return { success: false, error: result.output };
84
368
  }
85
- return { success: true, paneId: action.paneId };
369
+ await setPaneTags(action.paneId, ctx.ownership, action.newSessionId);
370
+ const pid = await resolvePanePidWithRetry(action.paneId);
371
+ return { success: true, paneId: action.paneId, pid };
86
372
  }
87
373
  /**
88
374
  * Spawn agent in a dedicated "Agents" window with tiled grid layout
@@ -104,69 +390,99 @@ async function spawnInAgentsWindow(action, ctx, retryCount = 0) {
104
390
  error: `Failed to spawn agent pane after ${MAX_SPAWN_RETRIES} attempts`,
105
391
  };
106
392
  }
107
- // Pane kills itself when opencode attach exits (session complete, server died, etc.)
393
+ // Use exec to replace bash with opencode attach directly.
394
+ // This ensures signals go directly to opencode attach (no wrapper process).
395
+ // When opencode attach exits, the pane closes automatically (tmux remain-on-exit off).
108
396
  // Use shellEscape to prevent shell injection via session IDs
109
397
  const escapedServerUrl = shellEscape(ctx.serverUrl);
110
398
  const escapedSessionId = shellEscape(action.sessionId);
111
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
399
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
112
400
  const layout = 'tiled'; // Always use tiled layout for grid arrangement
113
401
  // Check if we have a cached agents window ID and if it still exists
114
- if (agentsWindowId) {
402
+ let cachedWindowId = getCachedAgentsWindowId(ctx.ownership);
403
+ if (cachedWindowId) {
115
404
  const checkResult = await runTmuxCommand([
116
- 'list-panes',
405
+ 'display',
406
+ '-p',
117
407
  '-t',
118
- agentsWindowId,
119
- '-F',
120
- '#{pane_id}',
408
+ cachedWindowId,
409
+ `#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
121
410
  ]);
122
- if (!checkResult.success) {
123
- // Window no longer exists, clear the cache
124
- agentsWindowId = undefined;
411
+ if (!checkResult.success || !checkResult.output) {
412
+ setCachedAgentsWindowId(ctx.ownership, undefined);
413
+ cachedWindowId = undefined;
414
+ }
415
+ else {
416
+ const [isOpencode, windowServerKey, windowInstanceId] = checkResult.output
417
+ .trim()
418
+ .split('\t');
419
+ if (isOpencode !== '1' ||
420
+ windowServerKey !== ctx.ownership.serverKey ||
421
+ windowInstanceId !== ctx.ownership.instanceId) {
422
+ setCachedAgentsWindowId(ctx.ownership, undefined);
423
+ cachedWindowId = undefined;
424
+ }
425
+ }
426
+ }
427
+ if (!cachedWindowId) {
428
+ cachedWindowId = await findOwnedAgentsWindow(ctx.ownership.serverKey, ctx.ownership.instanceId, ctx.ownership.tmuxSessionId);
429
+ if (cachedWindowId) {
430
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
125
431
  }
126
432
  }
127
433
  // If no agents window exists, create one
128
- if (!agentsWindowId) {
129
- const createResult = await runTmuxCommand([
130
- 'new-window',
131
- '-d', // Don't switch to new window
132
- '-P',
133
- '-F',
134
- '#{window_id}:#{pane_id}',
135
- '-n',
136
- 'Agents',
137
- command,
138
- ]);
434
+ if (!cachedWindowId) {
435
+ // Build the new-window command args
436
+ // CRITICAL: Use -t <session>: to target the correct tmux session
437
+ // Without this, tmux may create the window in the wrong session when
438
+ // multiple opencode instances run in different tmux sessions
439
+ const newWindowArgs = ['new-window'];
440
+ if (ctx.ownership.tmuxSessionId) {
441
+ // Target the specific tmux session (the colon after session_id is important)
442
+ newWindowArgs.push('-t', `${ctx.ownership.tmuxSessionId}:`);
443
+ }
444
+ newWindowArgs.push('-d', // Don't switch to new window
445
+ '-P', '-F', '#{window_id}:#{pane_id}', '-n', 'Agents', command);
446
+ const createResult = await runTmuxCommand(newWindowArgs);
139
447
  if (!createResult.success) {
140
448
  return { success: false, error: createResult.output };
141
449
  }
142
450
  // Parse window_id:pane_id from output
143
451
  const output = createResult.output?.trim() || '';
144
452
  const [windowId, paneId] = output.split(':');
145
- agentsWindowId = windowId;
453
+ cachedWindowId = windowId;
454
+ if (cachedWindowId) {
455
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
456
+ await setWindowTags(cachedWindowId, ctx.ownership);
457
+ }
146
458
  // Apply initial layout (useful when more panes are added later)
147
- if (agentsWindowId && layout) {
148
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
459
+ if (cachedWindowId && layout) {
460
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
149
461
  }
150
- return { success: true, paneId, windowId };
462
+ if (paneId) {
463
+ await setPaneTags(paneId, ctx.ownership, action.sessionId);
464
+ }
465
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
466
+ return { success: true, paneId, windowId, pid };
151
467
  }
152
468
  // Agents window exists - split within it
153
469
  // First, get the first pane in the agents window to use as split target
154
470
  const listResult = await runTmuxCommand([
155
471
  'list-panes',
156
472
  '-t',
157
- agentsWindowId,
473
+ cachedWindowId,
158
474
  '-F',
159
475
  '#{pane_id}',
160
476
  ]);
161
477
  if (!listResult.success || !listResult.output) {
162
478
  // Fallback: create new window (with retry counter)
163
- agentsWindowId = undefined;
479
+ setCachedAgentsWindowId(ctx.ownership, undefined);
164
480
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
165
481
  }
166
482
  const targetPaneId = listResult.output.split('\n')[0]?.trim();
167
483
  if (!targetPaneId) {
168
484
  // Fallback: create new window (with retry counter)
169
- agentsWindowId = undefined;
485
+ setCachedAgentsWindowId(ctx.ownership, undefined);
170
486
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
171
487
  }
172
488
  // Split within the agents window
@@ -184,48 +500,351 @@ async function spawnInAgentsWindow(action, ctx, retryCount = 0) {
184
500
  return { success: false, error: splitResult.output };
185
501
  }
186
502
  const paneId = splitResult.output?.trim();
503
+ if (cachedWindowId) {
504
+ await setWindowTags(cachedWindowId, ctx.ownership);
505
+ }
506
+ if (paneId) {
507
+ await setPaneTags(paneId, ctx.ownership, action.sessionId);
508
+ }
187
509
  // Apply the configured layout to the agents window (e.g., tiled for grid)
188
- if (agentsWindowId && layout) {
189
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
510
+ if (cachedWindowId && layout) {
511
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
190
512
  }
513
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
191
514
  return {
192
515
  success: true,
193
516
  paneId: paneId || undefined,
194
- windowId: agentsWindowId,
517
+ windowId: cachedWindowId,
518
+ pid,
195
519
  };
196
520
  }
521
+ function killProcessByPidSync(pid) {
522
+ if (!Number.isFinite(pid) || pid <= 0)
523
+ return;
524
+ try {
525
+ spawnSync(['pkill', '-TERM', '-P', String(pid)]);
526
+ }
527
+ catch {
528
+ // Ignore errors - children may not exist
529
+ }
530
+ try {
531
+ process.kill(pid, 'SIGTERM');
532
+ }
533
+ catch (error) {
534
+ const code = error.code;
535
+ if (code === 'ESRCH')
536
+ return;
537
+ }
538
+ try {
539
+ const buffer = new SharedArrayBuffer(4);
540
+ const view = new Int32Array(buffer);
541
+ Atomics.wait(view, 0, 0, PROCESS_TERM_WAIT_MS);
542
+ }
543
+ catch {
544
+ // ignore sleep errors
545
+ }
546
+ try {
547
+ process.kill(pid, 0);
548
+ }
549
+ catch (error) {
550
+ const code = error.code;
551
+ if (code === 'ESRCH')
552
+ return;
553
+ }
554
+ try {
555
+ spawnSync(['pkill', '-KILL', '-P', String(pid)]);
556
+ }
557
+ catch {
558
+ // Ignore errors
559
+ }
560
+ try {
561
+ process.kill(pid, 'SIGKILL');
562
+ }
563
+ catch {
564
+ // ignore errors
565
+ }
566
+ }
197
567
  /**
198
568
  * Reset the agents window state (for cleanup)
199
569
  */
200
- export function resetAgentsWindow() {
201
- agentsWindowId = undefined;
570
+ export function resetAgentsWindow(ownership) {
571
+ if (ownership) {
572
+ setCachedAgentsWindowId(ownership, undefined);
573
+ }
574
+ else {
575
+ agentsWindowIdByKey.clear();
576
+ }
202
577
  }
203
578
  /**
204
579
  * Close the agents window if it exists
205
580
  * This kills the entire window, which closes all panes within it
581
+ *
582
+ * SAFETY: Verifies the window is named "Agents" before killing to prevent
583
+ * accidentally killing user windows if the cached ID is stale.
206
584
  */
207
- export async function closeAgentsWindow() {
208
- if (!agentsWindowId)
585
+ export async function closeAgentsWindow(ownership) {
586
+ // Build a pseudo-ownership context for cache lookup
587
+ const cacheKey = ownership
588
+ ? {
589
+ serverKey: ownership.serverKey,
590
+ instanceId: ownership.instanceId,
591
+ ownerPid: 0,
592
+ tmuxSessionId: ownership.tmuxSessionId,
593
+ }
594
+ : undefined;
595
+ const cachedId = cacheKey ? getCachedAgentsWindowId(cacheKey) : undefined;
596
+ const windowId = cachedId ??
597
+ (ownership
598
+ ? await findOwnedAgentsWindow(ownership.serverKey, ownership.instanceId, ownership.tmuxSessionId)
599
+ : undefined);
600
+ if (!windowId)
601
+ return;
602
+ const checkFormat = ownership
603
+ ? `#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`
604
+ : '#{window_name}';
605
+ const checkResult = await runTmuxCommand(['display', '-p', '-t', windowId, checkFormat]);
606
+ if (!checkResult.success) {
607
+ if (cacheKey)
608
+ setCachedAgentsWindowId(cacheKey, undefined);
209
609
  return;
210
- // Kill the entire window (closes all panes within it)
211
- await runTmuxCommand(['kill-window', '-t', agentsWindowId]);
212
- agentsWindowId = undefined;
610
+ }
611
+ const parts = checkResult.output?.trim().split('\t') ?? [];
612
+ const windowName = parts[0];
613
+ if (windowName !== 'Agents') {
614
+ if (cacheKey)
615
+ setCachedAgentsWindowId(cacheKey, undefined);
616
+ return;
617
+ }
618
+ if (ownership) {
619
+ const [, isOpencode, windowServerKey, windowInstanceId] = parts;
620
+ if (isOpencode !== '1' ||
621
+ windowServerKey !== ownership.serverKey ||
622
+ windowInstanceId !== ownership.instanceId) {
623
+ if (cacheKey)
624
+ setCachedAgentsWindowId(cacheKey, undefined);
625
+ return;
626
+ }
627
+ }
628
+ await runTmuxCommand(['kill-window', '-t', windowId]);
629
+ if (cacheKey)
630
+ setCachedAgentsWindowId(cacheKey, undefined);
213
631
  }
214
632
  /**
215
633
  * Synchronously close the agents window (for shutdown)
216
- * Uses spawnSync to ensure it completes before process exit
634
+ * Uses runTmuxCommandSync to ensure it completes before process exit
635
+ *
636
+ * SAFETY: Verifies the window is named "Agents" before killing to prevent
637
+ * accidentally killing user windows if the cached ID is stale.
217
638
  */
218
- export function closeAgentsWindowSync() {
219
- if (!agentsWindowId)
639
+ export function closeAgentsWindowSync(ownership) {
640
+ // Build a pseudo-ownership context for cache lookup
641
+ const cacheKey = ownership
642
+ ? {
643
+ serverKey: ownership.serverKey,
644
+ instanceId: ownership.instanceId,
645
+ ownerPid: 0,
646
+ tmuxSessionId: ownership.tmuxSessionId,
647
+ }
648
+ : undefined;
649
+ const cachedId = cacheKey ? getCachedAgentsWindowId(cacheKey) : undefined;
650
+ const windowId = cachedId ??
651
+ (ownership
652
+ ? findOwnedAgentsWindowSync(ownership.serverKey, ownership.instanceId, ownership.tmuxSessionId)
653
+ : undefined);
654
+ if (!windowId)
655
+ return;
656
+ const checkFormat = ownership
657
+ ? `#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`
658
+ : '#{window_name}';
659
+ const checkResult = runTmuxCommandSync(['display', '-p', '-t', windowId, checkFormat]);
660
+ if (!checkResult.success) {
661
+ if (cacheKey)
662
+ setCachedAgentsWindowId(cacheKey, undefined);
220
663
  return;
221
- // Kill the entire window synchronously
222
- runTmuxCommandSync(['kill-window', '-t', agentsWindowId]);
223
- agentsWindowId = undefined;
664
+ }
665
+ const parts = checkResult.output?.trim().split('\t') ?? [];
666
+ const windowName = parts[0];
667
+ if (windowName !== 'Agents') {
668
+ if (cacheKey)
669
+ setCachedAgentsWindowId(cacheKey, undefined);
670
+ return;
671
+ }
672
+ if (ownership) {
673
+ const [, isOpencode, windowServerKey, windowInstanceId] = parts;
674
+ if (isOpencode !== '1' ||
675
+ windowServerKey !== ownership.serverKey ||
676
+ windowInstanceId !== ownership.instanceId) {
677
+ if (cacheKey)
678
+ setCachedAgentsWindowId(cacheKey, undefined);
679
+ return;
680
+ }
681
+ }
682
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
683
+ if (cacheKey)
684
+ setCachedAgentsWindowId(cacheKey, undefined);
224
685
  }
225
686
  /**
226
687
  * Get the current agents window ID (for testing/debugging)
227
688
  */
228
- export function getAgentsWindowId() {
229
- return agentsWindowId;
689
+ export function getAgentsWindowId(ownership) {
690
+ if (ownership) {
691
+ return getCachedAgentsWindowId(ownership);
692
+ }
693
+ // Return first cached window ID (for backwards compatibility in tests)
694
+ const values = agentsWindowIdByKey.values();
695
+ const first = values.next();
696
+ return first.done ? undefined : first.value;
697
+ }
698
+ /**
699
+ * Clean up owned tmux windows/panes using ownership tags.
700
+ */
701
+ export async function cleanupOwnedResources(serverKey, instanceId) {
702
+ if (!instanceId)
703
+ return { panesClosed: 0, windowClosed: false };
704
+ const windowsResult = await runTmuxCommand([
705
+ 'list-windows',
706
+ '-a',
707
+ '-F',
708
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
709
+ ]);
710
+ const windowsToClose = new Set();
711
+ const serverWindowIds = new Set();
712
+ if (windowsResult.success && windowsResult.output) {
713
+ for (const line of windowsResult.output.split('\n')) {
714
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId] = line.split('\t');
715
+ if (!windowId)
716
+ continue;
717
+ if (windowServerKey === serverKey) {
718
+ serverWindowIds.add(windowId);
719
+ }
720
+ if (windowName !== 'Agents')
721
+ continue;
722
+ if (isOpencode !== '1')
723
+ continue;
724
+ if (windowServerKey !== serverKey)
725
+ continue;
726
+ if (windowInstanceId !== instanceId)
727
+ continue;
728
+ windowsToClose.add(windowId);
729
+ }
730
+ }
731
+ const panesResult = await runTmuxCommand([
732
+ 'list-panes',
733
+ '-a',
734
+ '-F',
735
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}`,
736
+ ]);
737
+ const panesToClose = [];
738
+ if (panesResult.success && panesResult.output) {
739
+ for (const line of panesResult.output.split('\n')) {
740
+ const [paneId, panePidRaw, windowId, paneInstanceId] = line.split('\t');
741
+ if (!paneId)
742
+ continue;
743
+ if (!windowId || !serverWindowIds.has(windowId))
744
+ continue;
745
+ if (paneInstanceId !== instanceId)
746
+ continue;
747
+ const panePid = Number(panePidRaw);
748
+ panesToClose.push({
749
+ paneId,
750
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
751
+ windowId,
752
+ });
753
+ }
754
+ }
755
+ for (const pane of panesToClose) {
756
+ if (pane.panePid) {
757
+ await killProcessByPid(pane.panePid);
758
+ }
759
+ }
760
+ let windowClosed = false;
761
+ for (const windowId of windowsToClose) {
762
+ await runTmuxCommand(['kill-window', '-t', windowId]);
763
+ windowClosed = true;
764
+ }
765
+ let panesClosed = 0;
766
+ for (const pane of panesToClose) {
767
+ if (windowsToClose.has(pane.windowId))
768
+ continue;
769
+ await runTmuxCommand(['kill-pane', '-t', pane.paneId]);
770
+ panesClosed += 1;
771
+ }
772
+ return { panesClosed, windowClosed };
773
+ }
774
+ /**
775
+ * Synchronous cleanup for owned tmux windows/panes.
776
+ */
777
+ export function cleanupOwnedResourcesSync(serverKey, instanceId) {
778
+ if (!instanceId)
779
+ return { panesClosed: 0, windowClosed: false };
780
+ const windowsResult = runTmuxCommandSync([
781
+ 'list-windows',
782
+ '-a',
783
+ '-F',
784
+ `#{window_id}\t#{window_name}\t#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
785
+ ]);
786
+ const windowsToClose = new Set();
787
+ const serverWindowIds = new Set();
788
+ if (windowsResult.success && windowsResult.output) {
789
+ for (const line of windowsResult.output.split('\n')) {
790
+ const [windowId, windowName, isOpencode, windowServerKey, windowInstanceId] = line.split('\t');
791
+ if (!windowId)
792
+ continue;
793
+ if (windowServerKey === serverKey) {
794
+ serverWindowIds.add(windowId);
795
+ }
796
+ if (windowName !== 'Agents')
797
+ continue;
798
+ if (isOpencode !== '1')
799
+ continue;
800
+ if (windowServerKey !== serverKey)
801
+ continue;
802
+ if (windowInstanceId !== instanceId)
803
+ continue;
804
+ windowsToClose.add(windowId);
805
+ }
806
+ }
807
+ const panesResult = runTmuxCommandSync([
808
+ 'list-panes',
809
+ '-a',
810
+ '-F',
811
+ `#{pane_id}\t#{pane_pid}\t#{window_id}\t#{${OPENCODE_INSTANCE_TAG}}`,
812
+ ]);
813
+ const panesToClose = [];
814
+ if (panesResult.success && panesResult.output) {
815
+ for (const line of panesResult.output.split('\n')) {
816
+ const [paneId, panePidRaw, windowId, paneInstanceId] = line.split('\t');
817
+ if (!paneId)
818
+ continue;
819
+ if (!windowId || !serverWindowIds.has(windowId))
820
+ continue;
821
+ if (paneInstanceId !== instanceId)
822
+ continue;
823
+ const panePid = Number(panePidRaw);
824
+ panesToClose.push({
825
+ paneId,
826
+ panePid: Number.isFinite(panePid) && panePid > 0 ? panePid : undefined,
827
+ windowId,
828
+ });
829
+ }
830
+ }
831
+ for (const pane of panesToClose) {
832
+ if (pane.panePid) {
833
+ killProcessByPidSync(pane.panePid);
834
+ }
835
+ }
836
+ let windowClosed = false;
837
+ for (const windowId of windowsToClose) {
838
+ runTmuxCommandSync(['kill-window', '-t', windowId]);
839
+ windowClosed = true;
840
+ }
841
+ let panesClosed = 0;
842
+ for (const pane of panesToClose) {
843
+ if (windowsToClose.has(pane.windowId))
844
+ continue;
845
+ runTmuxCommandSync(['kill-pane', '-t', pane.paneId]);
846
+ panesClosed += 1;
847
+ }
848
+ return { panesClosed, windowClosed };
230
849
  }
231
850
  //# sourceMappingURL=executor.js.map