@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.
- package/README.md +3 -10
- package/dist/agents/lead.d.ts +1 -1
- package/dist/agents/lead.d.ts.map +1 -1
- package/dist/agents/lead.js +2 -3
- package/dist/agents/lead.js.map +1 -1
- package/dist/background/manager.d.ts +1 -0
- package/dist/background/manager.d.ts.map +1 -1
- package/dist/background/manager.js +6 -0
- package/dist/background/manager.js.map +1 -1
- package/dist/plugin/hooks/cadence.d.ts.map +1 -1
- package/dist/plugin/hooks/cadence.js +3 -1
- package/dist/plugin/hooks/cadence.js.map +1 -1
- package/dist/plugin/plugin.d.ts.map +1 -1
- package/dist/plugin/plugin.js +54 -11
- package/dist/plugin/plugin.js.map +1 -1
- package/dist/skills/frontmatter.js +1 -1
- package/dist/skills/frontmatter.js.map +1 -1
- package/dist/tmux/executor.d.ts +57 -6
- package/dist/tmux/executor.d.ts.map +1 -1
- package/dist/tmux/executor.js +676 -57
- package/dist/tmux/executor.js.map +1 -1
- package/dist/tmux/index.d.ts +1 -1
- package/dist/tmux/index.d.ts.map +1 -1
- package/dist/tmux/index.js +1 -1
- package/dist/tmux/index.js.map +1 -1
- package/dist/tmux/manager.d.ts +70 -0
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +357 -22
- package/dist/tmux/manager.js.map +1 -1
- package/dist/tmux/state-query.d.ts.map +1 -1
- package/dist/tmux/state-query.js +4 -1
- package/dist/tmux/state-query.js.map +1 -1
- package/dist/tmux/types.d.ts +11 -0
- package/dist/tmux/types.d.ts.map +1 -1
- package/dist/tmux/types.js.map +1 -1
- package/dist/tmux/utils.d.ts +17 -0
- package/dist/tmux/utils.d.ts.map +1 -1
- package/dist/tmux/utils.js +39 -0
- package/dist/tmux/utils.js.map +1 -1
- package/package.json +3 -3
- package/src/agents/lead.ts +2 -3
- package/src/background/manager.ts +6 -0
- package/src/plugin/hooks/cadence.ts +2 -1
- package/src/plugin/plugin.ts +67 -11
- package/src/skills/frontmatter.ts +1 -1
- package/src/tmux/executor.ts +748 -55
- package/src/tmux/index.ts +6 -0
- package/src/tmux/manager.ts +410 -21
- package/src/tmux/state-query.ts +4 -1
- package/src/tmux/types.ts +12 -0
- package/src/tmux/utils.ts +39 -0
package/src/tmux/executor.ts
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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, {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
|
|
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
|
-
//
|
|
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}
|
|
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
|
-
|
|
483
|
+
let cachedWindowId = getCachedAgentsWindowId(ctx.ownership);
|
|
484
|
+
if (cachedWindowId) {
|
|
153
485
|
const checkResult = await runTmuxCommand([
|
|
154
|
-
'
|
|
486
|
+
'display',
|
|
487
|
+
'-p',
|
|
155
488
|
'-t',
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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 (!
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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 (
|
|
191
|
-
await runTmuxCommand(['select-layout', '-t',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
240
|
-
await runTmuxCommand(['select-layout', '-t',
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
}
|