@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/index.ts
CHANGED
|
@@ -7,5 +7,11 @@ export {
|
|
|
7
7
|
executeActions,
|
|
8
8
|
closeAgentsWindow,
|
|
9
9
|
closeAgentsWindowSync,
|
|
10
|
+
getAgentsWindowId,
|
|
11
|
+
cleanupOwnedResources,
|
|
12
|
+
cleanupOwnedResourcesSync,
|
|
13
|
+
findOwnedAgentPanes,
|
|
14
|
+
getPanePid,
|
|
15
|
+
getPanePidSync,
|
|
10
16
|
} from './executor';
|
|
11
17
|
export { TmuxSessionManager } from './manager';
|
package/src/tmux/manager.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
import { spawnSync } from 'bun';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
2
4
|
import type {
|
|
3
5
|
PaneAction,
|
|
4
6
|
TmuxConfig,
|
|
@@ -8,7 +10,13 @@ import type {
|
|
|
8
10
|
SessionMapping,
|
|
9
11
|
} from './types';
|
|
10
12
|
import { POLL_INTERVAL_MS, SESSION_MISSING_GRACE_MS, SESSION_TIMEOUT_MS } from './types';
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
canonicalizeServerUrl,
|
|
15
|
+
getCurrentPaneId,
|
|
16
|
+
getTmuxPath,
|
|
17
|
+
getTmuxSessionId,
|
|
18
|
+
isInsideTmux,
|
|
19
|
+
} from './utils';
|
|
12
20
|
import { queryWindowState } from './state-query';
|
|
13
21
|
import { decideSpawnActions } from './decision-engine';
|
|
14
22
|
import {
|
|
@@ -16,6 +24,12 @@ import {
|
|
|
16
24
|
closeAgentsWindow,
|
|
17
25
|
closeAgentsWindowSync,
|
|
18
26
|
closePaneById,
|
|
27
|
+
killProcessByPid,
|
|
28
|
+
getPanePid,
|
|
29
|
+
getPanePidSync,
|
|
30
|
+
cleanupOwnedResources,
|
|
31
|
+
cleanupOwnedResourcesSync,
|
|
32
|
+
findOwnedAgentPanes,
|
|
19
33
|
} from './executor';
|
|
20
34
|
|
|
21
35
|
/**
|
|
@@ -51,6 +65,16 @@ export class TmuxSessionManager {
|
|
|
51
65
|
private pendingSessions = new Set<string>();
|
|
52
66
|
private pollInterval?: ReturnType<typeof setInterval>;
|
|
53
67
|
private sourcePaneId: string | undefined;
|
|
68
|
+
private tmuxSessionId: string | undefined;
|
|
69
|
+
private instanceId = randomUUID().slice(0, 8);
|
|
70
|
+
private ownerPid = process.pid;
|
|
71
|
+
private serverKey: string | undefined;
|
|
72
|
+
private statusMissingSince = new Map<string, number>();
|
|
73
|
+
/**
|
|
74
|
+
* Operation queue to serialize tmux mutations.
|
|
75
|
+
* This prevents race conditions when multiple sessions are created rapidly.
|
|
76
|
+
*/
|
|
77
|
+
private tmuxOpQueue: Promise<void> = Promise.resolve();
|
|
54
78
|
|
|
55
79
|
constructor(
|
|
56
80
|
private ctx: PluginInput,
|
|
@@ -60,6 +84,20 @@ export class TmuxSessionManager {
|
|
|
60
84
|
this.sourcePaneId = getCurrentPaneId();
|
|
61
85
|
}
|
|
62
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Enqueue a tmux operation to ensure sequential execution.
|
|
89
|
+
* This prevents race conditions when multiple sessions are created/deleted rapidly.
|
|
90
|
+
*/
|
|
91
|
+
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
92
|
+
const result = this.tmuxOpQueue.then(fn, fn); // Run even if previous failed
|
|
93
|
+
// Update queue but don't propagate errors to next operation
|
|
94
|
+
this.tmuxOpQueue = result.then(
|
|
95
|
+
() => {},
|
|
96
|
+
() => {}
|
|
97
|
+
);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
63
101
|
/**
|
|
64
102
|
* Check if tmux integration is enabled and available
|
|
65
103
|
*/
|
|
@@ -70,14 +108,38 @@ export class TmuxSessionManager {
|
|
|
70
108
|
/**
|
|
71
109
|
* Handle a new background session being created
|
|
72
110
|
* This is called by BackgroundManager when a background task starts
|
|
111
|
+
*
|
|
112
|
+
* Operations are queued to prevent race conditions when multiple sessions
|
|
113
|
+
* are created rapidly.
|
|
73
114
|
*/
|
|
74
115
|
async onSessionCreated(event: {
|
|
75
116
|
sessionId: string;
|
|
76
117
|
parentId: string;
|
|
77
118
|
title: string;
|
|
78
119
|
}): Promise<void> {
|
|
79
|
-
|
|
80
|
-
|
|
120
|
+
return this.enqueue(() => this.doSessionCreated(event));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Internal implementation of session creation (runs within the queue)
|
|
125
|
+
*/
|
|
126
|
+
private async doSessionCreated(event: {
|
|
127
|
+
sessionId: string;
|
|
128
|
+
parentId: string;
|
|
129
|
+
title: string;
|
|
130
|
+
}): Promise<void> {
|
|
131
|
+
this.log(`onSessionCreated called for ${event.sessionId} (${event.title})`);
|
|
132
|
+
|
|
133
|
+
if (!this.isEnabled()) {
|
|
134
|
+
this.log(
|
|
135
|
+
`Skipping - tmux not enabled (config: ${this.config.enabled}, insideTmux: ${isInsideTmux()})`
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (this.pendingSessions.has(event.sessionId) || this.sessions.has(event.sessionId)) {
|
|
140
|
+
this.log(`Skipping - session ${event.sessionId} already pending or tracked`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
81
143
|
this.pendingSessions.add(event.sessionId);
|
|
82
144
|
|
|
83
145
|
try {
|
|
@@ -96,6 +158,14 @@ export class TmuxSessionManager {
|
|
|
96
158
|
return;
|
|
97
159
|
}
|
|
98
160
|
|
|
161
|
+
// Get the tmux session ID for this pane (cached after first lookup)
|
|
162
|
+
if (!this.tmuxSessionId) {
|
|
163
|
+
this.tmuxSessionId = await getTmuxSessionId(this.sourcePaneId);
|
|
164
|
+
if (this.tmuxSessionId) {
|
|
165
|
+
this.log(`Resolved tmux session ID: ${this.tmuxSessionId}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
99
169
|
const state = await queryWindowState(this.sourcePaneId);
|
|
100
170
|
if (!state) {
|
|
101
171
|
this.log('Failed to query tmux window state.');
|
|
@@ -136,10 +206,19 @@ export class TmuxSessionManager {
|
|
|
136
206
|
return;
|
|
137
207
|
}
|
|
138
208
|
|
|
209
|
+
const serverKey = canonicalizeServerUrl(serverUrl);
|
|
210
|
+
this.serverKey = serverKey;
|
|
211
|
+
|
|
139
212
|
const result = await executeActions(decision.actions, {
|
|
140
213
|
config: this.config,
|
|
141
214
|
serverUrl,
|
|
142
215
|
windowState: state,
|
|
216
|
+
ownership: {
|
|
217
|
+
instanceId: this.instanceId,
|
|
218
|
+
ownerPid: this.ownerPid,
|
|
219
|
+
serverKey,
|
|
220
|
+
tmuxSessionId: this.tmuxSessionId,
|
|
221
|
+
},
|
|
143
222
|
});
|
|
144
223
|
|
|
145
224
|
if (!result.success) {
|
|
@@ -147,7 +226,11 @@ export class TmuxSessionManager {
|
|
|
147
226
|
return;
|
|
148
227
|
}
|
|
149
228
|
|
|
150
|
-
this.applyActionResults(decision.actions, result.
|
|
229
|
+
this.applyActionResults(decision.actions, result.results);
|
|
230
|
+
await this.refreshMissingPids();
|
|
231
|
+
this.log(
|
|
232
|
+
`Successfully spawned pane for ${event.sessionId}. Tracking ${this.sessions.size} sessions. PIDs: ${this.getTrackedPids().join(', ') || 'none'}`
|
|
233
|
+
);
|
|
151
234
|
if (this.sessions.size > 0) {
|
|
152
235
|
this.startPolling();
|
|
153
236
|
}
|
|
@@ -156,28 +239,62 @@ export class TmuxSessionManager {
|
|
|
156
239
|
}
|
|
157
240
|
}
|
|
158
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Get all tracked PIDs for logging
|
|
244
|
+
*/
|
|
245
|
+
private getTrackedPids(): number[] {
|
|
246
|
+
return Array.from(this.sessions.values())
|
|
247
|
+
.map((s) => s.pid)
|
|
248
|
+
.filter((pid): pid is number => pid !== undefined);
|
|
249
|
+
}
|
|
250
|
+
|
|
159
251
|
/**
|
|
160
252
|
* Handle a session being deleted
|
|
161
253
|
*
|
|
162
254
|
* Explicitly kills the pane when a background session completes.
|
|
163
255
|
* We can't rely on `opencode attach` exiting because it's an interactive
|
|
164
256
|
* terminal that keeps running even after the session goes idle.
|
|
257
|
+
*
|
|
258
|
+
* Operations are queued to prevent race conditions.
|
|
165
259
|
*/
|
|
166
260
|
async onSessionDeleted(event: { sessionId: string }): Promise<void> {
|
|
167
|
-
|
|
261
|
+
return this.enqueue(() => this.doSessionDeleted(event));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Internal implementation of session deletion (runs within the queue)
|
|
266
|
+
*/
|
|
267
|
+
private async doSessionDeleted(event: { sessionId: string }): Promise<void> {
|
|
268
|
+
this.log(`onSessionDeleted called for ${event.sessionId}`);
|
|
269
|
+
|
|
270
|
+
if (!this.isEnabled()) {
|
|
271
|
+
this.log(`Skipping delete - tmux not enabled`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
168
274
|
|
|
169
275
|
// Find the session in our mappings
|
|
170
276
|
const session = this.sessions.get(event.sessionId);
|
|
171
|
-
if (!session)
|
|
277
|
+
if (!session) {
|
|
278
|
+
this.log(`Session ${event.sessionId} not found in tracked sessions`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.log(
|
|
283
|
+
`Closing pane ${session.paneId} (PID: ${session.pid}) for session ${event.sessionId}`
|
|
284
|
+
);
|
|
172
285
|
|
|
173
286
|
// Kill the pane explicitly - opencode attach won't exit on its own
|
|
174
|
-
const result = await closePaneById(session.paneId);
|
|
287
|
+
const result = await closePaneById(session.paneId, session.pid);
|
|
175
288
|
if (!result.success) {
|
|
176
289
|
this.log(`Failed to close pane ${session.paneId}: ${result.error}`);
|
|
290
|
+
} else {
|
|
291
|
+
this.log(`Successfully closed pane ${session.paneId}`);
|
|
177
292
|
}
|
|
178
293
|
|
|
179
294
|
// Update internal state
|
|
180
295
|
this.sessions.delete(event.sessionId);
|
|
296
|
+
this.statusMissingSince.delete(event.sessionId);
|
|
297
|
+
this.log(`Removed session from tracking. Now tracking ${this.sessions.size} sessions.`);
|
|
181
298
|
|
|
182
299
|
if (this.sessions.size === 0) {
|
|
183
300
|
this.stopPolling();
|
|
@@ -188,13 +305,38 @@ export class TmuxSessionManager {
|
|
|
188
305
|
* Clean up all panes on shutdown
|
|
189
306
|
*
|
|
190
307
|
* Kills the entire "Agents" window, which closes all agent panes at once.
|
|
308
|
+
* Falls back to pkill if PID-based cleanup fails.
|
|
191
309
|
*/
|
|
192
310
|
async cleanup(): Promise<void> {
|
|
311
|
+
this.log('Starting cleanup...');
|
|
193
312
|
this.stopPolling();
|
|
194
313
|
|
|
195
|
-
|
|
196
|
-
|
|
314
|
+
for (const session of this.sessions.values()) {
|
|
315
|
+
let pid = session.pid;
|
|
316
|
+
if (!pid) {
|
|
317
|
+
pid = await getPanePid(session.paneId);
|
|
318
|
+
if (pid) {
|
|
319
|
+
session.pid = pid;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!pid) continue;
|
|
323
|
+
this.log(`Killing process ${pid} for session ${session.sessionId}`);
|
|
324
|
+
const success = await killProcessByPid(pid);
|
|
325
|
+
if (!success) {
|
|
326
|
+
this.log(`Failed to kill process ${pid} for session ${session.sessionId}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const serverKey = this.getServerKey();
|
|
331
|
+
if (serverKey) {
|
|
332
|
+
await cleanupOwnedResources(serverKey, this.instanceId);
|
|
333
|
+
} else {
|
|
334
|
+
await closeAgentsWindow();
|
|
335
|
+
}
|
|
197
336
|
this.sessions.clear();
|
|
337
|
+
this.statusMissingSince.clear();
|
|
338
|
+
|
|
339
|
+
this.log('Cleanup complete');
|
|
198
340
|
}
|
|
199
341
|
|
|
200
342
|
/**
|
|
@@ -204,11 +346,32 @@ export class TmuxSessionManager {
|
|
|
204
346
|
* process exits, which is necessary for signal handlers.
|
|
205
347
|
*/
|
|
206
348
|
cleanupSync(): void {
|
|
349
|
+
this.log('Starting sync cleanup...');
|
|
207
350
|
this.stopPolling();
|
|
208
351
|
|
|
209
|
-
|
|
210
|
-
|
|
352
|
+
for (const session of this.sessions.values()) {
|
|
353
|
+
let pid = session.pid;
|
|
354
|
+
if (!pid) {
|
|
355
|
+
pid = getPanePidSync(session.paneId);
|
|
356
|
+
if (pid) {
|
|
357
|
+
session.pid = pid;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!pid) continue;
|
|
361
|
+
this.log(`Killing process ${pid} for session ${session.sessionId}`);
|
|
362
|
+
this.killProcessByPidSync(pid);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const serverKey = this.getServerKey();
|
|
366
|
+
if (serverKey) {
|
|
367
|
+
cleanupOwnedResourcesSync(serverKey, this.instanceId);
|
|
368
|
+
} else {
|
|
369
|
+
closeAgentsWindowSync();
|
|
370
|
+
}
|
|
211
371
|
this.sessions.clear();
|
|
372
|
+
this.statusMissingSince.clear();
|
|
373
|
+
|
|
374
|
+
this.log('Sync cleanup complete');
|
|
212
375
|
}
|
|
213
376
|
|
|
214
377
|
/**
|
|
@@ -234,32 +397,75 @@ export class TmuxSessionManager {
|
|
|
234
397
|
* Poll active sessions for status changes
|
|
235
398
|
*/
|
|
236
399
|
private async pollSessions(): Promise<void> {
|
|
400
|
+
return this.enqueue(() => this.doPollSessions());
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Poll active sessions for status changes
|
|
405
|
+
*/
|
|
406
|
+
private async doPollSessions(): Promise<void> {
|
|
237
407
|
if (!this.isEnabled()) return;
|
|
238
408
|
if (!this.sourcePaneId) return;
|
|
239
409
|
|
|
240
410
|
const state = await queryWindowState(this.sourcePaneId);
|
|
241
411
|
if (!state) return;
|
|
412
|
+
const statusMap = await this.fetchSessionStatuses();
|
|
242
413
|
|
|
243
414
|
const now = Date.now();
|
|
415
|
+
const sessionsToClose: TrackedSession[] = [];
|
|
244
416
|
for (const session of this.sessions.values()) {
|
|
245
417
|
const pane = findPane(state, session.paneId);
|
|
246
418
|
if (pane) {
|
|
247
419
|
session.lastSeenAt = new Date();
|
|
248
|
-
|
|
420
|
+
if (!session.pid) {
|
|
421
|
+
const pid = await getPanePid(session.paneId);
|
|
422
|
+
if (pid) {
|
|
423
|
+
session.pid = pid;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
const missingFor = now - session.lastSeenAt.getTime();
|
|
428
|
+
if (missingFor > SESSION_MISSING_GRACE_MS) {
|
|
429
|
+
if (session.pid) {
|
|
430
|
+
await killProcessByPid(session.pid);
|
|
431
|
+
}
|
|
432
|
+
this.sessions.delete(session.sessionId);
|
|
433
|
+
this.statusMissingSince.delete(session.sessionId);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
249
436
|
}
|
|
250
437
|
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
253
|
-
this.
|
|
254
|
-
|
|
438
|
+
const status = statusMap[session.sessionId];
|
|
439
|
+
if (status) {
|
|
440
|
+
this.statusMissingSince.delete(session.sessionId);
|
|
441
|
+
} else if (!this.statusMissingSince.has(session.sessionId)) {
|
|
442
|
+
this.statusMissingSince.set(session.sessionId, now);
|
|
255
443
|
}
|
|
256
444
|
|
|
445
|
+
const statusMissingAt = this.statusMissingSince.get(session.sessionId);
|
|
446
|
+
const missingTooLong =
|
|
447
|
+
statusMissingAt !== undefined && now - statusMissingAt > SESSION_MISSING_GRACE_MS;
|
|
257
448
|
const age = now - session.createdAt.getTime();
|
|
258
|
-
|
|
259
|
-
|
|
449
|
+
const isTimedOut = age > SESSION_TIMEOUT_MS;
|
|
450
|
+
const isIdle = status?.type === 'idle';
|
|
451
|
+
|
|
452
|
+
if (isIdle || missingTooLong || isTimedOut) {
|
|
453
|
+
sessionsToClose.push(session);
|
|
260
454
|
}
|
|
261
455
|
}
|
|
262
456
|
|
|
457
|
+
for (const session of sessionsToClose) {
|
|
458
|
+
this.log(`Closing idle session ${session.sessionId} (pane: ${session.paneId})`);
|
|
459
|
+
const result = await closePaneById(session.paneId, session.pid);
|
|
460
|
+
if (!result.success) {
|
|
461
|
+
this.log(
|
|
462
|
+
`Failed to close pane ${session.paneId} for session ${session.sessionId}: ${result.error}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
this.sessions.delete(session.sessionId);
|
|
466
|
+
this.statusMissingSince.delete(session.sessionId);
|
|
467
|
+
}
|
|
468
|
+
|
|
263
469
|
if (this.sessions.size === 0) {
|
|
264
470
|
this.stopPolling();
|
|
265
471
|
}
|
|
@@ -287,29 +493,50 @@ export class TmuxSessionManager {
|
|
|
287
493
|
return typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
|
|
288
494
|
}
|
|
289
495
|
|
|
290
|
-
private
|
|
496
|
+
private getServerKey(): string | undefined {
|
|
497
|
+
if (this.serverKey) return this.serverKey;
|
|
498
|
+
const serverUrl = this.getServerUrl();
|
|
499
|
+
if (!serverUrl) return undefined;
|
|
500
|
+
try {
|
|
501
|
+
const serverKey = canonicalizeServerUrl(serverUrl);
|
|
502
|
+
this.serverKey = serverKey;
|
|
503
|
+
return serverKey;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
this.log(`Failed to canonicalize server URL "${serverUrl}": ${error}`);
|
|
506
|
+
return undefined;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private applyActionResults(
|
|
511
|
+
actions: PaneAction[],
|
|
512
|
+
results: Array<{ action: PaneAction; result: { paneId?: string; pid?: number } }>
|
|
513
|
+
): void {
|
|
291
514
|
const now = new Date();
|
|
292
|
-
for (const action of actions) {
|
|
515
|
+
for (const [index, action] of actions.entries()) {
|
|
516
|
+
const actionResult = results[index]?.result;
|
|
293
517
|
switch (action.type) {
|
|
294
518
|
case 'close':
|
|
295
519
|
this.sessions.delete(action.sessionId);
|
|
296
520
|
break;
|
|
297
521
|
case 'replace':
|
|
298
522
|
this.sessions.delete(action.oldSessionId);
|
|
523
|
+
this.statusMissingSince.delete(action.oldSessionId);
|
|
299
524
|
this.sessions.set(action.newSessionId, {
|
|
300
525
|
sessionId: action.newSessionId,
|
|
301
526
|
paneId: action.paneId,
|
|
527
|
+
pid: actionResult?.pid,
|
|
302
528
|
description: action.description,
|
|
303
529
|
createdAt: now,
|
|
304
530
|
lastSeenAt: now,
|
|
305
531
|
});
|
|
306
532
|
break;
|
|
307
533
|
case 'spawn': {
|
|
308
|
-
const paneId =
|
|
534
|
+
const paneId = actionResult?.paneId;
|
|
309
535
|
if (!paneId) break;
|
|
310
536
|
this.sessions.set(action.sessionId, {
|
|
311
537
|
sessionId: action.sessionId,
|
|
312
538
|
paneId,
|
|
539
|
+
pid: actionResult?.pid,
|
|
313
540
|
description: action.description,
|
|
314
541
|
createdAt: now,
|
|
315
542
|
lastSeenAt: now,
|
|
@@ -320,12 +547,174 @@ export class TmuxSessionManager {
|
|
|
320
547
|
}
|
|
321
548
|
}
|
|
322
549
|
|
|
550
|
+
private async refreshMissingPids(): Promise<void> {
|
|
551
|
+
for (const session of this.sessions.values()) {
|
|
552
|
+
if (session.pid) continue;
|
|
553
|
+
const pid = await getPanePid(session.paneId);
|
|
554
|
+
if (pid) {
|
|
555
|
+
session.pid = pid;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private async fetchSessionStatuses(): Promise<Record<string, { type?: string }>> {
|
|
561
|
+
try {
|
|
562
|
+
const result = await this.ctx.client.session.status({
|
|
563
|
+
path: undefined,
|
|
564
|
+
throwOnError: false,
|
|
565
|
+
});
|
|
566
|
+
const data = unwrapResponse<Record<string, { type?: string }>>(result);
|
|
567
|
+
if (!data || typeof data !== 'object') return {};
|
|
568
|
+
return data;
|
|
569
|
+
} catch {
|
|
570
|
+
return {};
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Find and report orphaned processes (does NOT kill them by default).
|
|
576
|
+
* Call this manually if you need to identify orphaned processes after a crash.
|
|
577
|
+
*
|
|
578
|
+
* Note: This method only reports - it does not kill processes because we cannot
|
|
579
|
+
* reliably distinguish between processes we spawned vs user-initiated sessions.
|
|
580
|
+
* The shutdown cleanup (cleanup/cleanupSync) is safe because it only kills PIDs
|
|
581
|
+
* we explicitly tracked during this session.
|
|
582
|
+
*/
|
|
583
|
+
async reportOrphanedProcesses(): Promise<number[]> {
|
|
584
|
+
if (!this.isEnabled()) return [];
|
|
585
|
+
const serverKey = this.getServerKey();
|
|
586
|
+
if (!serverKey) return [];
|
|
587
|
+
|
|
588
|
+
const trackedSessionIds = new Set(this.sessions.keys());
|
|
589
|
+
const panes = await findOwnedAgentPanes(serverKey);
|
|
590
|
+
const orphanedPanes = panes.filter((pane) => {
|
|
591
|
+
if (!pane.sessionId) return false;
|
|
592
|
+
return !trackedSessionIds.has(pane.sessionId);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (orphanedPanes.length > 0) {
|
|
596
|
+
const sessionIds = orphanedPanes
|
|
597
|
+
.map((pane) => pane.sessionId)
|
|
598
|
+
.filter((sessionId): sessionId is string => !!sessionId);
|
|
599
|
+
this.log(
|
|
600
|
+
`Found ${orphanedPanes.length} potentially orphaned sessions: ${sessionIds.join(', ')}`
|
|
601
|
+
);
|
|
602
|
+
this.log(
|
|
603
|
+
'These may be user-initiated sessions. Close their tmux panes manually if needed.'
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return orphanedPanes
|
|
608
|
+
.map((pane) => pane.panePid)
|
|
609
|
+
.filter((pid): pid is number => typeof pid === 'number');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Kill a process and all its children synchronously.
|
|
614
|
+
*
|
|
615
|
+
* This is necessary because we spawn `bash -c "opencode attach ...; tmux kill-pane"`
|
|
616
|
+
* and #{pane_pid} returns the bash PID, not the opencode attach PID.
|
|
617
|
+
*/
|
|
618
|
+
private killProcessByPidSync(pid: number): void {
|
|
619
|
+
if (!Number.isFinite(pid) || pid <= 0) return;
|
|
620
|
+
|
|
621
|
+
// First, kill all child processes
|
|
622
|
+
try {
|
|
623
|
+
spawnSync(['pkill', '-TERM', '-P', String(pid)]);
|
|
624
|
+
} catch {
|
|
625
|
+
// Ignore errors - children may not exist
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Then kill the parent
|
|
629
|
+
try {
|
|
630
|
+
process.kill(pid, 'SIGTERM');
|
|
631
|
+
} catch (error) {
|
|
632
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
633
|
+
if (code === 'ESRCH') return;
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Wait for processes to die
|
|
638
|
+
try {
|
|
639
|
+
const buffer = new SharedArrayBuffer(4);
|
|
640
|
+
const view = new Int32Array(buffer);
|
|
641
|
+
Atomics.wait(view, 0, 0, 1000);
|
|
642
|
+
} catch {
|
|
643
|
+
// ignore sleep errors
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Check if parent is dead
|
|
647
|
+
try {
|
|
648
|
+
process.kill(pid, 0);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
651
|
+
if (code === 'ESRCH') return; // Dead, good
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Force kill children
|
|
655
|
+
try {
|
|
656
|
+
spawnSync(['pkill', '-KILL', '-P', String(pid)]);
|
|
657
|
+
} catch {
|
|
658
|
+
// Ignore errors
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Force kill parent
|
|
662
|
+
try {
|
|
663
|
+
process.kill(pid, 'SIGKILL');
|
|
664
|
+
} catch {
|
|
665
|
+
// ignore errors
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
323
669
|
private log(message: string): void {
|
|
324
670
|
this.callbacks?.onLog?.(`[tmux] ${message}`);
|
|
325
671
|
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Static method to clean up orphaned processes without needing an instance.
|
|
675
|
+
* This is useful for manual cleanup commands.
|
|
676
|
+
*
|
|
677
|
+
* @param serverUrl - Optional server URL to filter processes
|
|
678
|
+
* @param logger - Optional logging function
|
|
679
|
+
* @param instanceId - Ownership instance id to target cleanup
|
|
680
|
+
* @returns Object with cleanup results
|
|
681
|
+
*/
|
|
682
|
+
static async cleanupOrphans(
|
|
683
|
+
serverUrl?: string,
|
|
684
|
+
logger?: (msg: string) => void,
|
|
685
|
+
instanceId?: string
|
|
686
|
+
): Promise<{ killed: number; windowClosed: boolean }> {
|
|
687
|
+
const log = logger ?? (() => {});
|
|
688
|
+
|
|
689
|
+
log('Starting orphan cleanup...');
|
|
690
|
+
|
|
691
|
+
let serverKey: string | undefined;
|
|
692
|
+
try {
|
|
693
|
+
serverKey = serverUrl ? canonicalizeServerUrl(serverUrl) : undefined;
|
|
694
|
+
} catch {
|
|
695
|
+
serverKey = undefined;
|
|
696
|
+
}
|
|
697
|
+
if (!serverKey || !instanceId) {
|
|
698
|
+
log('No server URL or instance ID provided; skipping ownership cleanup.');
|
|
699
|
+
return { killed: 0, windowClosed: false };
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const result = await cleanupOwnedResources(serverKey, instanceId);
|
|
703
|
+
log(
|
|
704
|
+
`Orphan cleanup complete: ${result.panesClosed} panes closed, window closed: ${result.windowClosed}`
|
|
705
|
+
);
|
|
706
|
+
return { killed: result.panesClosed, windowClosed: result.windowClosed };
|
|
707
|
+
}
|
|
326
708
|
}
|
|
327
709
|
|
|
328
710
|
function findPane(state: WindowState, paneId: string): TmuxPaneInfo | undefined {
|
|
329
711
|
if (state.mainPane?.paneId === paneId) return state.mainPane;
|
|
330
712
|
return state.agentPanes.find((pane) => pane.paneId === paneId);
|
|
331
713
|
}
|
|
714
|
+
|
|
715
|
+
function unwrapResponse<T>(result: unknown): T | undefined {
|
|
716
|
+
if (typeof result === 'object' && result !== null && 'data' in result) {
|
|
717
|
+
return (result as { data?: T }).data;
|
|
718
|
+
}
|
|
719
|
+
return result as T;
|
|
720
|
+
}
|
package/src/tmux/state-query.ts
CHANGED
|
@@ -32,6 +32,9 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|
|
32
32
|
const parts = line.split(',');
|
|
33
33
|
if (parts.length < 9) continue;
|
|
34
34
|
|
|
35
|
+
const paneId = parts[0];
|
|
36
|
+
if (!paneId) continue;
|
|
37
|
+
|
|
35
38
|
const windowWidthValue = Number(parts[parts.length - 2]);
|
|
36
39
|
const windowHeightValue = Number(parts[parts.length - 1]);
|
|
37
40
|
const isActiveValue = parts[parts.length - 3] === '1';
|
|
@@ -46,7 +49,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const paneInfo: TmuxPaneInfo = {
|
|
49
|
-
paneId
|
|
52
|
+
paneId,
|
|
50
53
|
width,
|
|
51
54
|
height,
|
|
52
55
|
left,
|
package/src/tmux/types.ts
CHANGED
|
@@ -11,6 +11,17 @@ export interface TmuxPaneInfo {
|
|
|
11
11
|
isActive: boolean; // Is this the active pane?
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Ownership tags stored in tmux user options
|
|
16
|
+
*/
|
|
17
|
+
export interface TmuxOwnershipTags {
|
|
18
|
+
isOpencode: boolean; // @agentuity.opencode = "1"
|
|
19
|
+
serverKey: string; // @agentuity.opencode.server
|
|
20
|
+
ownerPid: number; // @agentuity.opencode.ownerPid
|
|
21
|
+
instanceId: string; // @agentuity.opencode.instance
|
|
22
|
+
sessionId?: string; // @agentuity.opencode.session (pane-level)
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
/**
|
|
15
26
|
* Current state of the tmux window
|
|
16
27
|
*/
|
|
@@ -27,6 +38,7 @@ export interface WindowState {
|
|
|
27
38
|
export interface TrackedSession {
|
|
28
39
|
sessionId: string; // OpenCode session ID
|
|
29
40
|
paneId: string; // Tmux pane ID
|
|
41
|
+
pid?: number; // Process ID for direct killing
|
|
30
42
|
description: string; // Task description
|
|
31
43
|
createdAt: Date;
|
|
32
44
|
lastSeenAt: Date;
|
package/src/tmux/utils.ts
CHANGED
|
@@ -83,3 +83,42 @@ export function getTmuxPathSync(): string | null {
|
|
|
83
83
|
return null;
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the tmux session ID for a given pane.
|
|
89
|
+
* This is used to ensure windows are created in the correct tmux session
|
|
90
|
+
* when multiple opencode instances run in different sessions.
|
|
91
|
+
*/
|
|
92
|
+
export async function getTmuxSessionId(paneId: string): Promise<string | undefined> {
|
|
93
|
+
const result = await runTmuxCommand(['display', '-p', '-t', paneId, '#{session_id}']);
|
|
94
|
+
if (!result.success || !result.output) return undefined;
|
|
95
|
+
return result.output.trim() || undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get the tmux session ID synchronously (for shutdown scenarios)
|
|
100
|
+
*/
|
|
101
|
+
export function getTmuxSessionIdSync(paneId: string): string | undefined {
|
|
102
|
+
const result = runTmuxCommandSync(['display', '-p', '-t', paneId, '#{session_id}']);
|
|
103
|
+
if (!result.success || !result.output) return undefined;
|
|
104
|
+
return result.output.trim() || undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Canonicalize server URL for consistent ownership tagging.
|
|
109
|
+
* Normalizes loopback addresses (127.0.0.1, ::1) to localhost.
|
|
110
|
+
*
|
|
111
|
+
* @throws Error if URL is invalid
|
|
112
|
+
*/
|
|
113
|
+
export function canonicalizeServerUrl(url: string): string {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
let host = parsed.hostname.toLowerCase();
|
|
117
|
+
if (host === '127.0.0.1' || host === '::1') {
|
|
118
|
+
host = 'localhost';
|
|
119
|
+
}
|
|
120
|
+
return `${parsed.protocol}//${host}:${parsed.port}`;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new Error(`Invalid server URL: ${url}`, { cause: error });
|
|
123
|
+
}
|
|
124
|
+
}
|