@agentuity/opencode 0.1.42 → 0.1.44

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 (122) hide show
  1. package/README.md +9 -9
  2. package/dist/agents/architect.d.ts +1 -1
  3. package/dist/agents/architect.d.ts.map +1 -1
  4. package/dist/agents/architect.js +4 -0
  5. package/dist/agents/architect.js.map +1 -1
  6. package/dist/agents/builder.d.ts +1 -1
  7. package/dist/agents/builder.d.ts.map +1 -1
  8. package/dist/agents/builder.js +4 -0
  9. package/dist/agents/builder.js.map +1 -1
  10. package/dist/agents/expert.d.ts +1 -1
  11. package/dist/agents/expert.d.ts.map +1 -1
  12. package/dist/agents/expert.js +2 -2
  13. package/dist/agents/index.d.ts.map +1 -1
  14. package/dist/agents/index.js +4 -0
  15. package/dist/agents/index.js.map +1 -1
  16. package/dist/agents/lead.d.ts +1 -1
  17. package/dist/agents/lead.d.ts.map +1 -1
  18. package/dist/agents/lead.js +94 -11
  19. package/dist/agents/lead.js.map +1 -1
  20. package/dist/agents/memory/entities.d.ts +32 -0
  21. package/dist/agents/memory/entities.d.ts.map +1 -0
  22. package/dist/agents/memory/entities.js +168 -0
  23. package/dist/agents/memory/entities.js.map +1 -0
  24. package/dist/agents/memory/index.d.ts +4 -0
  25. package/dist/agents/memory/index.d.ts.map +1 -0
  26. package/dist/agents/memory/index.js +2 -0
  27. package/dist/agents/memory/index.js.map +1 -0
  28. package/dist/agents/memory/types.d.ts +71 -0
  29. package/dist/agents/memory/types.d.ts.map +1 -0
  30. package/dist/agents/memory/types.js +2 -0
  31. package/dist/agents/memory/types.js.map +1 -0
  32. package/dist/agents/memory.d.ts +1 -1
  33. package/dist/agents/memory.d.ts.map +1 -1
  34. package/dist/agents/memory.js +344 -7
  35. package/dist/agents/memory.js.map +1 -1
  36. package/dist/agents/product.d.ts +4 -0
  37. package/dist/agents/product.d.ts.map +1 -0
  38. package/dist/agents/product.js +333 -0
  39. package/dist/agents/product.js.map +1 -0
  40. package/dist/agents/reasoner.d.ts +16 -0
  41. package/dist/agents/reasoner.d.ts.map +1 -0
  42. package/dist/agents/reasoner.js +160 -0
  43. package/dist/agents/reasoner.js.map +1 -0
  44. package/dist/agents/reviewer.d.ts +1 -1
  45. package/dist/agents/reviewer.d.ts.map +1 -1
  46. package/dist/agents/reviewer.js +9 -0
  47. package/dist/agents/reviewer.js.map +1 -1
  48. package/dist/background/manager.d.ts +1 -0
  49. package/dist/background/manager.d.ts.map +1 -1
  50. package/dist/background/manager.js +7 -1
  51. package/dist/background/manager.js.map +1 -1
  52. package/dist/plugin/hooks/index.d.ts +2 -0
  53. package/dist/plugin/hooks/index.d.ts.map +1 -0
  54. package/dist/plugin/hooks/index.js +2 -0
  55. package/dist/plugin/hooks/index.js.map +1 -0
  56. package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
  57. package/dist/plugin/hooks/session-memory.js +5 -0
  58. package/dist/plugin/hooks/session-memory.js.map +1 -1
  59. package/dist/plugin/hooks/tools.d.ts +11 -0
  60. package/dist/plugin/hooks/tools.d.ts.map +1 -1
  61. package/dist/plugin/hooks/tools.js +18 -1
  62. package/dist/plugin/hooks/tools.js.map +1 -1
  63. package/dist/plugin/plugin.d.ts.map +1 -1
  64. package/dist/plugin/plugin.js +203 -12
  65. package/dist/plugin/plugin.js.map +1 -1
  66. package/dist/tmux/executor.d.ts +43 -20
  67. package/dist/tmux/executor.d.ts.map +1 -1
  68. package/dist/tmux/executor.js +547 -243
  69. package/dist/tmux/executor.js.map +1 -1
  70. package/dist/tmux/index.d.ts +1 -1
  71. package/dist/tmux/index.d.ts.map +1 -1
  72. package/dist/tmux/index.js +1 -1
  73. package/dist/tmux/index.js.map +1 -1
  74. package/dist/tmux/manager.d.ts +37 -3
  75. package/dist/tmux/manager.d.ts.map +1 -1
  76. package/dist/tmux/manager.js +219 -96
  77. package/dist/tmux/manager.js.map +1 -1
  78. package/dist/tmux/types.d.ts +10 -0
  79. package/dist/tmux/types.d.ts.map +1 -1
  80. package/dist/tmux/types.js.map +1 -1
  81. package/dist/tmux/utils.d.ts +17 -0
  82. package/dist/tmux/utils.d.ts.map +1 -1
  83. package/dist/tmux/utils.js +39 -0
  84. package/dist/tmux/utils.js.map +1 -1
  85. package/dist/tools/background.d.ts +2 -0
  86. package/dist/tools/background.d.ts.map +1 -1
  87. package/dist/tools/background.js +3 -3
  88. package/dist/tools/background.js.map +1 -1
  89. package/dist/tools/delegate.d.ts +4 -0
  90. package/dist/tools/delegate.d.ts.map +1 -1
  91. package/dist/tools/delegate.js +18 -3
  92. package/dist/tools/delegate.js.map +1 -1
  93. package/dist/types.d.ts +2 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +2 -0
  96. package/dist/types.js.map +1 -1
  97. package/package.json +3 -3
  98. package/src/agents/architect.ts +4 -0
  99. package/src/agents/builder.ts +4 -0
  100. package/src/agents/expert.ts +2 -2
  101. package/src/agents/index.ts +4 -0
  102. package/src/agents/lead.ts +94 -11
  103. package/src/agents/memory/entities.ts +220 -0
  104. package/src/agents/memory/index.ts +22 -0
  105. package/src/agents/memory/types.ts +76 -0
  106. package/src/agents/memory.ts +344 -7
  107. package/src/agents/product.ts +336 -0
  108. package/src/agents/reasoner.ts +182 -0
  109. package/src/agents/reviewer.ts +9 -0
  110. package/src/background/manager.ts +7 -1
  111. package/src/plugin/hooks/index.ts +1 -0
  112. package/src/plugin/hooks/session-memory.ts +5 -0
  113. package/src/plugin/hooks/tools.ts +24 -1
  114. package/src/plugin/plugin.ts +228 -12
  115. package/src/tmux/executor.ts +610 -249
  116. package/src/tmux/index.ts +5 -2
  117. package/src/tmux/manager.ts +241 -98
  118. package/src/tmux/types.ts +11 -0
  119. package/src/tmux/utils.ts +39 -0
  120. package/src/tools/background.ts +3 -3
  121. package/src/tools/delegate.ts +18 -3
  122. package/src/types.ts +2 -0
@@ -1,15 +1,5 @@
1
1
  import { runTmuxCommand, runTmuxCommandSync } from './utils';
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
- import { homedir } from 'node:os';
4
- import { join } from 'node:path';
5
2
  import { spawn, spawnSync } from 'bun';
6
- /**
7
- * Path to persist the agents window ID for crash recovery.
8
- * Uses ~/.config/agentuity/coder/cache/ which is consistent with other Agentuity paths
9
- * and likely exists for any Agentuity user.
10
- */
11
- const CACHE_DIR = join(homedir(), '.config', 'agentuity', 'coder', 'cache');
12
- const AGENTS_WINDOW_FILE = join(CACHE_DIR, 'agents-window-id');
13
3
  /**
14
4
  * Escape a string for safe use in shell commands.
15
5
  * Wraps in single quotes and escapes any internal single quotes.
@@ -20,6 +10,11 @@ function shellEscape(str) {
20
10
  }
21
11
  /** Maximum retries for recursive spawn attempts to prevent infinite loops */
22
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';
23
18
  const PROCESS_TERM_WAIT_MS = 1000;
24
19
  function isProcessAlive(pid) {
25
20
  try {
@@ -31,7 +26,7 @@ function isProcessAlive(pid) {
31
26
  return code !== 'ESRCH';
32
27
  }
33
28
  }
34
- async function getPanePid(paneId) {
29
+ export async function getPanePid(paneId) {
35
30
  if (!paneId)
36
31
  return undefined;
37
32
  const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{pane_pid}']);
@@ -42,6 +37,170 @@ async function getPanePid(paneId) {
42
37
  return undefined;
43
38
  return pid;
44
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
+ }
45
204
  /**
46
205
  * Kill a process and all its children (the entire process tree).
47
206
  *
@@ -102,53 +261,32 @@ export async function killProcessByPid(pid) {
102
261
  }
103
262
  /**
104
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.
105
266
  */
106
- let agentsWindowId;
107
- /**
108
- * Ensure the cache directory exists
109
- */
110
- function ensureCacheDir() {
111
- if (!existsSync(CACHE_DIR)) {
112
- mkdirSync(CACHE_DIR, { recursive: true });
113
- }
114
- }
267
+ const agentsWindowIdByKey = new Map();
115
268
  /**
116
- * Persist the agents window ID to disk for crash recovery
269
+ * Get the cache key for the agents window
117
270
  */
118
- function persistAgentsWindowId(windowId) {
119
- try {
120
- ensureCacheDir();
121
- writeFileSync(AGENTS_WINDOW_FILE, windowId, 'utf-8');
122
- }
123
- catch {
124
- // Ignore write errors - persistence is best-effort
125
- }
271
+ function getAgentsWindowCacheKey(ownership) {
272
+ return `${ownership.serverKey}:${ownership.instanceId}:${ownership.tmuxSessionId ?? 'default'}`;
126
273
  }
127
274
  /**
128
- * Load the agents window ID from disk (for crash recovery)
275
+ * Get the cached agents window ID for the given ownership context
129
276
  */
130
- function loadPersistedAgentsWindowId() {
131
- try {
132
- if (!existsSync(AGENTS_WINDOW_FILE))
133
- return undefined;
134
- const windowId = readFileSync(AGENTS_WINDOW_FILE, 'utf-8').trim();
135
- return windowId || undefined;
136
- }
137
- catch {
138
- return undefined;
139
- }
277
+ function getCachedAgentsWindowId(ownership) {
278
+ return agentsWindowIdByKey.get(getAgentsWindowCacheKey(ownership));
140
279
  }
141
280
  /**
142
- * Clear the persisted agents window ID
281
+ * Set the cached agents window ID for the given ownership context
143
282
  */
144
- function clearPersistedAgentsWindowId() {
145
- try {
146
- if (existsSync(AGENTS_WINDOW_FILE)) {
147
- unlinkSync(AGENTS_WINDOW_FILE);
148
- }
283
+ function setCachedAgentsWindowId(ownership, windowId) {
284
+ const key = getAgentsWindowCacheKey(ownership);
285
+ if (windowId) {
286
+ agentsWindowIdByKey.set(key, windowId);
149
287
  }
150
- catch {
151
- // Ignore delete errors
288
+ else {
289
+ agentsWindowIdByKey.delete(key);
152
290
  }
153
291
  }
154
292
  /**
@@ -159,7 +297,10 @@ function clearPersistedAgentsWindowId() {
159
297
  export async function executeAction(action, ctx) {
160
298
  switch (action.type) {
161
299
  case 'spawn':
162
- return spawnInAgentsWindow(action, { serverUrl: ctx.serverUrl });
300
+ return spawnInAgentsWindow(action, {
301
+ serverUrl: ctx.serverUrl,
302
+ ownership: ctx.ownership,
303
+ });
163
304
  case 'close':
164
305
  return closePane(action);
165
306
  case 'replace':
@@ -198,7 +339,7 @@ async function closePane(action) {
198
339
  export async function closePaneById(paneId, pid) {
199
340
  let resolvedPid = pid;
200
341
  if (!resolvedPid) {
201
- resolvedPid = await getPanePid(paneId);
342
+ resolvedPid = await resolvePanePidWithRetry(paneId);
202
343
  }
203
344
  if (resolvedPid) {
204
345
  await killProcessByPid(resolvedPid);
@@ -214,16 +355,19 @@ export async function closePaneById(paneId, pid) {
214
355
  * Pane self-destructs when command exits (session complete, server died, etc.)
215
356
  */
216
357
  async function replacePane(action, ctx) {
217
- // 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).
218
361
  // Use shellEscape to prevent shell injection via session IDs
219
362
  const escapedServerUrl = shellEscape(ctx.serverUrl);
220
363
  const escapedSessionId = shellEscape(action.newSessionId);
221
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
364
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
222
365
  const result = await runTmuxCommand(['respawn-pane', '-k', '-t', action.paneId, command]);
223
366
  if (!result.success) {
224
367
  return { success: false, error: result.output };
225
368
  }
226
- const pid = await getPanePid(action.paneId);
369
+ await setPaneTags(action.paneId, ctx.ownership, action.newSessionId);
370
+ const pid = await resolvePanePidWithRetry(action.paneId);
227
371
  return { success: true, paneId: action.paneId, pid };
228
372
  }
229
373
  /**
@@ -246,54 +390,79 @@ async function spawnInAgentsWindow(action, ctx, retryCount = 0) {
246
390
  error: `Failed to spawn agent pane after ${MAX_SPAWN_RETRIES} attempts`,
247
391
  };
248
392
  }
249
- // 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).
250
396
  // Use shellEscape to prevent shell injection via session IDs
251
397
  const escapedServerUrl = shellEscape(ctx.serverUrl);
252
398
  const escapedSessionId = shellEscape(action.sessionId);
253
- const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
399
+ const command = `exec opencode attach ${escapedServerUrl} --session ${escapedSessionId}`;
254
400
  const layout = 'tiled'; // Always use tiled layout for grid arrangement
255
401
  // Check if we have a cached agents window ID and if it still exists
256
- if (agentsWindowId) {
402
+ let cachedWindowId = getCachedAgentsWindowId(ctx.ownership);
403
+ if (cachedWindowId) {
257
404
  const checkResult = await runTmuxCommand([
258
- 'list-panes',
405
+ 'display',
406
+ '-p',
259
407
  '-t',
260
- agentsWindowId,
261
- '-F',
262
- '#{pane_id}',
408
+ cachedWindowId,
409
+ `#{${OPENCODE_TAG}}\t#{${OPENCODE_SERVER_TAG}}\t#{${OPENCODE_INSTANCE_TAG}}`,
263
410
  ]);
264
- if (!checkResult.success) {
265
- // Window no longer exists, clear the cache
266
- 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);
267
431
  }
268
432
  }
269
433
  // If no agents window exists, create one
270
- if (!agentsWindowId) {
271
- const createResult = await runTmuxCommand([
272
- 'new-window',
273
- '-d', // Don't switch to new window
274
- '-P',
275
- '-F',
276
- '#{window_id}:#{pane_id}',
277
- '-n',
278
- 'Agents',
279
- command,
280
- ]);
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);
281
447
  if (!createResult.success) {
282
448
  return { success: false, error: createResult.output };
283
449
  }
284
450
  // Parse window_id:pane_id from output
285
451
  const output = createResult.output?.trim() || '';
286
452
  const [windowId, paneId] = output.split(':');
287
- agentsWindowId = windowId;
288
- // Persist for crash recovery
289
- if (agentsWindowId) {
290
- persistAgentsWindowId(agentsWindowId);
453
+ cachedWindowId = windowId;
454
+ if (cachedWindowId) {
455
+ setCachedAgentsWindowId(ctx.ownership, cachedWindowId);
456
+ await setWindowTags(cachedWindowId, ctx.ownership);
291
457
  }
292
458
  // Apply initial layout (useful when more panes are added later)
293
- if (agentsWindowId && layout) {
294
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
459
+ if (cachedWindowId && layout) {
460
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
295
461
  }
296
- const pid = paneId ? await getPanePid(paneId) : undefined;
462
+ if (paneId) {
463
+ await setPaneTags(paneId, ctx.ownership, action.sessionId);
464
+ }
465
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
297
466
  return { success: true, paneId, windowId, pid };
298
467
  }
299
468
  // Agents window exists - split within it
@@ -301,19 +470,19 @@ async function spawnInAgentsWindow(action, ctx, retryCount = 0) {
301
470
  const listResult = await runTmuxCommand([
302
471
  'list-panes',
303
472
  '-t',
304
- agentsWindowId,
473
+ cachedWindowId,
305
474
  '-F',
306
475
  '#{pane_id}',
307
476
  ]);
308
477
  if (!listResult.success || !listResult.output) {
309
478
  // Fallback: create new window (with retry counter)
310
- agentsWindowId = undefined;
479
+ setCachedAgentsWindowId(ctx.ownership, undefined);
311
480
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
312
481
  }
313
482
  const targetPaneId = listResult.output.split('\n')[0]?.trim();
314
483
  if (!targetPaneId) {
315
484
  // Fallback: create new window (with retry counter)
316
- agentsWindowId = undefined;
485
+ setCachedAgentsWindowId(ctx.ownership, undefined);
317
486
  return spawnInAgentsWindow(action, ctx, retryCount + 1);
318
487
  }
319
488
  // Split within the agents window
@@ -331,216 +500,351 @@ async function spawnInAgentsWindow(action, ctx, retryCount = 0) {
331
500
  return { success: false, error: splitResult.output };
332
501
  }
333
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
+ }
334
509
  // Apply the configured layout to the agents window (e.g., tiled for grid)
335
- if (agentsWindowId && layout) {
336
- await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
510
+ if (cachedWindowId && layout) {
511
+ await runTmuxCommand(['select-layout', '-t', cachedWindowId, layout]);
337
512
  }
338
- const pid = paneId ? await getPanePid(paneId) : undefined;
513
+ const pid = paneId ? await resolvePanePidWithRetry(paneId) : undefined;
339
514
  return {
340
515
  success: true,
341
516
  paneId: paneId || undefined,
342
- windowId: agentsWindowId,
517
+ windowId: cachedWindowId,
343
518
  pid,
344
519
  };
345
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
+ }
346
567
  /**
347
568
  * Reset the agents window state (for cleanup)
348
569
  */
349
- export function resetAgentsWindow() {
350
- agentsWindowId = undefined;
351
- clearPersistedAgentsWindowId();
570
+ export function resetAgentsWindow(ownership) {
571
+ if (ownership) {
572
+ setCachedAgentsWindowId(ownership, undefined);
573
+ }
574
+ else {
575
+ agentsWindowIdByKey.clear();
576
+ }
352
577
  }
353
578
  /**
354
579
  * Close the agents window if it exists
355
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.
356
584
  */
357
- export async function closeAgentsWindow() {
358
- // Try to recover window ID from disk if not in memory
359
- const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
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);
360
600
  if (!windowId)
361
601
  return;
362
- // Kill the entire window (closes all panes within it)
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);
609
+ return;
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
+ }
363
628
  await runTmuxCommand(['kill-window', '-t', windowId]);
364
- agentsWindowId = undefined;
365
- clearPersistedAgentsWindowId();
629
+ if (cacheKey)
630
+ setCachedAgentsWindowId(cacheKey, undefined);
366
631
  }
367
632
  /**
368
633
  * Synchronously close the agents window (for shutdown)
369
- * 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.
370
638
  */
371
- export function closeAgentsWindowSync() {
372
- // Try to recover window ID from disk if not in memory
373
- const windowId = agentsWindowId ?? loadPersistedAgentsWindowId();
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);
374
654
  if (!windowId)
375
655
  return;
376
- // Kill the entire window synchronously
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);
663
+ return;
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
+ }
377
682
  runTmuxCommandSync(['kill-window', '-t', windowId]);
378
- agentsWindowId = undefined;
379
- clearPersistedAgentsWindowId();
683
+ if (cacheKey)
684
+ setCachedAgentsWindowId(cacheKey, undefined);
380
685
  }
381
686
  /**
382
687
  * Get the current agents window ID (for testing/debugging)
383
- * Also checks persisted file for crash recovery
384
688
  */
385
- export function getAgentsWindowId() {
386
- return agentsWindowId ?? loadPersistedAgentsWindowId();
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;
387
697
  }
388
698
  /**
389
- * Kill all orphaned opencode attach processes for a given server URL.
390
- * This is a fallback cleanup method when PID-based cleanup fails.
391
- *
392
- * @param serverUrl - The server URL to match (optional, kills all if not provided)
393
- * @param logger - Optional logging function for debug output
394
- * @returns Number of processes killed
699
+ * Clean up owned tmux windows/panes using ownership tags.
395
700
  */
396
- export async function killOrphanedAttachProcesses(serverUrl, logger) {
397
- const log = logger ?? (() => { });
398
- try {
399
- // Use pkill with pattern matching for opencode attach
400
- const { spawn } = await import('bun');
401
- // First, find matching processes to log what we're killing
402
- const psProc = spawn(['ps', 'aux'], { stdout: 'pipe', stderr: 'pipe' });
403
- await psProc.exited;
404
- const psOutput = await new Response(psProc.stdout).text();
405
- const lines = psOutput.split('\n');
406
- const matchingPids = [];
407
- for (const line of lines) {
408
- if (!line.includes('opencode attach'))
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)
409
716
  continue;
410
- if (serverUrl && !line.includes(serverUrl))
717
+ if (windowServerKey === serverKey) {
718
+ serverWindowIds.add(windowId);
719
+ }
720
+ if (windowName !== 'Agents')
411
721
  continue;
412
- // Don't kill ourselves
413
- if (line.includes(String(process.pid)))
722
+ if (isOpencode !== '1')
414
723
  continue;
415
- const parts = line.trim().split(/\s+/);
416
- const pid = Number(parts[1]);
417
- if (Number.isFinite(pid) && pid > 0) {
418
- matchingPids.push(pid);
419
- }
420
- }
421
- if (matchingPids.length === 0) {
422
- log('No orphaned opencode attach processes found');
423
- return 0;
424
- }
425
- log(`Found ${matchingPids.length} orphaned processes: ${matchingPids.join(', ')}`);
426
- // Kill each process individually for better control
427
- let killed = 0;
428
- for (const pid of matchingPids) {
429
- try {
430
- // Try SIGTERM first
431
- process.kill(pid, 'SIGTERM');
432
- log(`Sent SIGTERM to PID ${pid}`);
433
- killed++;
434
- }
435
- catch (error) {
436
- const code = error.code;
437
- if (code !== 'ESRCH') {
438
- log(`Failed to kill PID ${pid}: ${code}`);
439
- }
440
- }
724
+ if (windowServerKey !== serverKey)
725
+ continue;
726
+ if (windowInstanceId !== instanceId)
727
+ continue;
728
+ windowsToClose.add(windowId);
441
729
  }
442
- // Wait a bit then SIGKILL any survivors
443
- await new Promise((resolve) => setTimeout(resolve, PROCESS_TERM_WAIT_MS));
444
- for (const pid of matchingPids) {
445
- if (!isProcessAlive(pid))
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)
446
742
  continue;
447
- try {
448
- process.kill(pid, 'SIGKILL');
449
- log(`Sent SIGKILL to PID ${pid}`);
450
- }
451
- catch {
452
- // Ignore errors on SIGKILL
453
- }
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
+ });
454
753
  }
455
- log(`Cleanup complete: killed ${killed} processes`);
456
- return killed;
457
754
  }
458
- catch (error) {
459
- log(`Fallback cleanup failed: ${error}`);
460
- return 0;
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;
461
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 };
462
773
  }
463
774
  /**
464
- * Synchronous version of killOrphanedAttachProcesses for shutdown handlers.
465
- * Uses spawnSync to ensure completion before process exit.
466
- *
467
- * @param serverUrl - The server URL to match (optional, kills all if not provided)
468
- * @param logger - Optional logging function for debug output
469
- * @returns Number of processes killed
775
+ * Synchronous cleanup for owned tmux windows/panes.
470
776
  */
471
- export function killOrphanedAttachProcessesSync(serverUrl, logger) {
472
- const log = logger ?? (() => { });
473
- try {
474
- // Find matching processes
475
- const psResult = spawnSync(['ps', 'aux'], { timeout: 2000 });
476
- if (psResult.exitCode !== 0) {
477
- log('Failed to list processes');
478
- return 0;
479
- }
480
- const psOutput = psResult.stdout?.toString() ?? '';
481
- const lines = psOutput.split('\n');
482
- const matchingPids = [];
483
- for (const line of lines) {
484
- if (!line.includes('opencode attach'))
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')
485
797
  continue;
486
- if (serverUrl && !line.includes(serverUrl))
798
+ if (isOpencode !== '1')
487
799
  continue;
488
- // Don't kill ourselves
489
- if (line.includes(String(process.pid)))
800
+ if (windowServerKey !== serverKey)
490
801
  continue;
491
- const parts = line.trim().split(/\s+/);
492
- const pid = Number(parts[1]);
493
- if (Number.isFinite(pid) && pid > 0) {
494
- matchingPids.push(pid);
495
- }
496
- }
497
- if (matchingPids.length === 0) {
498
- log('No orphaned opencode attach processes found');
499
- return 0;
500
- }
501
- log(`Found ${matchingPids.length} orphaned processes: ${matchingPids.join(', ')}`);
502
- // Kill each process
503
- let killed = 0;
504
- for (const pid of matchingPids) {
505
- try {
506
- process.kill(pid, 'SIGTERM');
507
- log(`Sent SIGTERM to PID ${pid}`);
508
- killed++;
509
- }
510
- catch (error) {
511
- const code = error.code;
512
- if (code !== 'ESRCH') {
513
- log(`Failed to kill PID ${pid}: ${code}`);
514
- }
515
- }
516
- }
517
- // Brief wait using SharedArrayBuffer (sync sleep)
518
- try {
519
- const buffer = new SharedArrayBuffer(4);
520
- const view = new Int32Array(buffer);
521
- Atomics.wait(view, 0, 0, PROCESS_TERM_WAIT_MS);
522
- }
523
- catch {
524
- // Ignore sleep errors
802
+ if (windowInstanceId !== instanceId)
803
+ continue;
804
+ windowsToClose.add(windowId);
525
805
  }
526
- // SIGKILL survivors
527
- for (const pid of matchingPids) {
528
- if (!isProcessAlive(pid))
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)
529
818
  continue;
530
- try {
531
- process.kill(pid, 'SIGKILL');
532
- log(`Sent SIGKILL to PID ${pid}`);
533
- }
534
- catch {
535
- // Ignore errors
536
- }
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
+ });
537
829
  }
538
- log(`Cleanup complete: killed ${killed} processes`);
539
- return killed;
540
830
  }
541
- catch (error) {
542
- log(`Fallback cleanup failed: ${error}`);
543
- return 0;
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;
544
847
  }
848
+ return { panesClosed, windowClosed };
545
849
  }
546
850
  //# sourceMappingURL=executor.js.map