@agentuity/opencode 0.1.41 → 0.1.42
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/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 +49 -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 +29 -1
- package/dist/tmux/executor.d.ts.map +1 -1
- package/dist/tmux/executor.js +328 -13
- 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 +36 -0
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +222 -10
- 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 +1 -0
- package/dist/tmux/types.d.ts.map +1 -1
- package/dist/tmux/types.js.map +1 -1
- package/package.json +3 -3
- package/src/agents/lead.ts +2 -3
- package/src/plugin/hooks/cadence.ts +2 -1
- package/src/plugin/plugin.ts +62 -11
- package/src/skills/frontmatter.ts +1 -1
- package/src/tmux/executor.ts +345 -13
- package/src/tmux/index.ts +3 -0
- package/src/tmux/manager.ts +255 -9
- package/src/tmux/state-query.ts +4 -1
- package/src/tmux/types.ts +1 -0
package/src/tmux/manager.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
import { spawn, spawnSync } from 'bun';
|
|
2
3
|
import type {
|
|
3
4
|
PaneAction,
|
|
4
5
|
TmuxConfig,
|
|
@@ -16,6 +17,9 @@ import {
|
|
|
16
17
|
closeAgentsWindow,
|
|
17
18
|
closeAgentsWindowSync,
|
|
18
19
|
closePaneById,
|
|
20
|
+
killProcessByPid,
|
|
21
|
+
killOrphanedAttachProcesses,
|
|
22
|
+
killOrphanedAttachProcessesSync,
|
|
19
23
|
} from './executor';
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -76,8 +80,18 @@ export class TmuxSessionManager {
|
|
|
76
80
|
parentId: string;
|
|
77
81
|
title: string;
|
|
78
82
|
}): Promise<void> {
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
this.log(`onSessionCreated called for ${event.sessionId} (${event.title})`);
|
|
84
|
+
|
|
85
|
+
if (!this.isEnabled()) {
|
|
86
|
+
this.log(
|
|
87
|
+
`Skipping - tmux not enabled (config: ${this.config.enabled}, insideTmux: ${isInsideTmux()})`
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (this.pendingSessions.has(event.sessionId) || this.sessions.has(event.sessionId)) {
|
|
92
|
+
this.log(`Skipping - session ${event.sessionId} already pending or tracked`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
81
95
|
this.pendingSessions.add(event.sessionId);
|
|
82
96
|
|
|
83
97
|
try {
|
|
@@ -147,7 +161,10 @@ export class TmuxSessionManager {
|
|
|
147
161
|
return;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
this.applyActionResults(decision.actions, result.
|
|
164
|
+
this.applyActionResults(decision.actions, result.results);
|
|
165
|
+
this.log(
|
|
166
|
+
`Successfully spawned pane for ${event.sessionId}. Tracking ${this.sessions.size} sessions. PIDs: ${this.getTrackedPids().join(', ') || 'none'}`
|
|
167
|
+
);
|
|
151
168
|
if (this.sessions.size > 0) {
|
|
152
169
|
this.startPolling();
|
|
153
170
|
}
|
|
@@ -156,6 +173,15 @@ export class TmuxSessionManager {
|
|
|
156
173
|
}
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Get all tracked PIDs for logging
|
|
178
|
+
*/
|
|
179
|
+
private getTrackedPids(): number[] {
|
|
180
|
+
return Array.from(this.sessions.values())
|
|
181
|
+
.map((s) => s.pid)
|
|
182
|
+
.filter((pid): pid is number => pid !== undefined);
|
|
183
|
+
}
|
|
184
|
+
|
|
159
185
|
/**
|
|
160
186
|
* Handle a session being deleted
|
|
161
187
|
*
|
|
@@ -164,20 +190,35 @@ export class TmuxSessionManager {
|
|
|
164
190
|
* terminal that keeps running even after the session goes idle.
|
|
165
191
|
*/
|
|
166
192
|
async onSessionDeleted(event: { sessionId: string }): Promise<void> {
|
|
167
|
-
|
|
193
|
+
this.log(`onSessionDeleted called for ${event.sessionId}`);
|
|
194
|
+
|
|
195
|
+
if (!this.isEnabled()) {
|
|
196
|
+
this.log(`Skipping delete - tmux not enabled`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
168
199
|
|
|
169
200
|
// Find the session in our mappings
|
|
170
201
|
const session = this.sessions.get(event.sessionId);
|
|
171
|
-
if (!session)
|
|
202
|
+
if (!session) {
|
|
203
|
+
this.log(`Session ${event.sessionId} not found in tracked sessions`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
this.log(
|
|
208
|
+
`Closing pane ${session.paneId} (PID: ${session.pid}) for session ${event.sessionId}`
|
|
209
|
+
);
|
|
172
210
|
|
|
173
211
|
// Kill the pane explicitly - opencode attach won't exit on its own
|
|
174
|
-
const result = await closePaneById(session.paneId);
|
|
212
|
+
const result = await closePaneById(session.paneId, session.pid);
|
|
175
213
|
if (!result.success) {
|
|
176
214
|
this.log(`Failed to close pane ${session.paneId}: ${result.error}`);
|
|
215
|
+
} else {
|
|
216
|
+
this.log(`Successfully closed pane ${session.paneId}`);
|
|
177
217
|
}
|
|
178
218
|
|
|
179
219
|
// Update internal state
|
|
180
220
|
this.sessions.delete(event.sessionId);
|
|
221
|
+
this.log(`Removed session from tracking. Now tracking ${this.sessions.size} sessions.`);
|
|
181
222
|
|
|
182
223
|
if (this.sessions.size === 0) {
|
|
183
224
|
this.stopPolling();
|
|
@@ -188,13 +229,35 @@ export class TmuxSessionManager {
|
|
|
188
229
|
* Clean up all panes on shutdown
|
|
189
230
|
*
|
|
190
231
|
* Kills the entire "Agents" window, which closes all agent panes at once.
|
|
232
|
+
* Falls back to pkill if PID-based cleanup fails.
|
|
191
233
|
*/
|
|
192
234
|
async cleanup(): Promise<void> {
|
|
235
|
+
this.log('Starting cleanup...');
|
|
193
236
|
this.stopPolling();
|
|
194
237
|
|
|
238
|
+
let pidCleanupFailed = false;
|
|
239
|
+
for (const session of this.sessions.values()) {
|
|
240
|
+
if (!session.pid) continue;
|
|
241
|
+
this.log(`Killing process ${session.pid} for session ${session.sessionId}`);
|
|
242
|
+
const success = await killProcessByPid(session.pid);
|
|
243
|
+
if (!success) {
|
|
244
|
+
this.log(`Failed to kill process ${session.pid} for session ${session.sessionId}`);
|
|
245
|
+
pidCleanupFailed = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
195
249
|
// Kill the entire agents window - this closes all panes at once
|
|
196
250
|
await closeAgentsWindow();
|
|
197
251
|
this.sessions.clear();
|
|
252
|
+
|
|
253
|
+
// Fallback: if PID-based cleanup failed, use pkill to catch any orphans
|
|
254
|
+
if (pidCleanupFailed) {
|
|
255
|
+
this.log('PID-based cleanup had failures, running fallback cleanup...');
|
|
256
|
+
const serverUrl = this.getServerUrl();
|
|
257
|
+
await killOrphanedAttachProcesses(serverUrl, (msg) => this.log(msg));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.log('Cleanup complete');
|
|
198
261
|
}
|
|
199
262
|
|
|
200
263
|
/**
|
|
@@ -204,11 +267,35 @@ export class TmuxSessionManager {
|
|
|
204
267
|
* process exits, which is necessary for signal handlers.
|
|
205
268
|
*/
|
|
206
269
|
cleanupSync(): void {
|
|
270
|
+
this.log('Starting sync cleanup...');
|
|
207
271
|
this.stopPolling();
|
|
208
272
|
|
|
273
|
+
let pidCleanupFailed = false;
|
|
274
|
+
for (const session of this.sessions.values()) {
|
|
275
|
+
if (!session.pid) continue;
|
|
276
|
+
this.log(`Killing process ${session.pid} for session ${session.sessionId}`);
|
|
277
|
+
this.killProcessByPidSync(session.pid);
|
|
278
|
+
// Check if process is still alive after kill attempt
|
|
279
|
+
try {
|
|
280
|
+
process.kill(session.pid, 0);
|
|
281
|
+
pidCleanupFailed = true; // Process still exists
|
|
282
|
+
} catch {
|
|
283
|
+
// Process is dead, good
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
209
287
|
// Kill the entire agents window synchronously
|
|
210
288
|
closeAgentsWindowSync();
|
|
211
289
|
this.sessions.clear();
|
|
290
|
+
|
|
291
|
+
// Fallback: if PID-based cleanup failed, use pkill to catch any orphans
|
|
292
|
+
if (pidCleanupFailed) {
|
|
293
|
+
this.log('PID-based cleanup had failures, running fallback cleanup...');
|
|
294
|
+
const serverUrl = this.getServerUrl();
|
|
295
|
+
killOrphanedAttachProcessesSync(serverUrl, (msg) => this.log(msg));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.log('Sync cleanup complete');
|
|
212
299
|
}
|
|
213
300
|
|
|
214
301
|
/**
|
|
@@ -287,9 +374,13 @@ export class TmuxSessionManager {
|
|
|
287
374
|
return typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
|
|
288
375
|
}
|
|
289
376
|
|
|
290
|
-
private applyActionResults(
|
|
377
|
+
private applyActionResults(
|
|
378
|
+
actions: PaneAction[],
|
|
379
|
+
results: Array<{ action: PaneAction; result: { paneId?: string; pid?: number } }>
|
|
380
|
+
): void {
|
|
291
381
|
const now = new Date();
|
|
292
|
-
for (const action of actions) {
|
|
382
|
+
for (const [index, action] of actions.entries()) {
|
|
383
|
+
const actionResult = results[index]?.result;
|
|
293
384
|
switch (action.type) {
|
|
294
385
|
case 'close':
|
|
295
386
|
this.sessions.delete(action.sessionId);
|
|
@@ -299,17 +390,19 @@ export class TmuxSessionManager {
|
|
|
299
390
|
this.sessions.set(action.newSessionId, {
|
|
300
391
|
sessionId: action.newSessionId,
|
|
301
392
|
paneId: action.paneId,
|
|
393
|
+
pid: actionResult?.pid,
|
|
302
394
|
description: action.description,
|
|
303
395
|
createdAt: now,
|
|
304
396
|
lastSeenAt: now,
|
|
305
397
|
});
|
|
306
398
|
break;
|
|
307
399
|
case 'spawn': {
|
|
308
|
-
const paneId =
|
|
400
|
+
const paneId = actionResult?.paneId;
|
|
309
401
|
if (!paneId) break;
|
|
310
402
|
this.sessions.set(action.sessionId, {
|
|
311
403
|
sessionId: action.sessionId,
|
|
312
404
|
paneId,
|
|
405
|
+
pid: actionResult?.pid,
|
|
313
406
|
description: action.description,
|
|
314
407
|
createdAt: now,
|
|
315
408
|
lastSeenAt: now,
|
|
@@ -320,9 +413,162 @@ export class TmuxSessionManager {
|
|
|
320
413
|
}
|
|
321
414
|
}
|
|
322
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Find and report orphaned processes (does NOT kill them by default).
|
|
418
|
+
* Call this manually if you need to identify orphaned processes after a crash.
|
|
419
|
+
*
|
|
420
|
+
* Note: This method only reports - it does not kill processes because we cannot
|
|
421
|
+
* reliably distinguish between processes we spawned vs user-initiated sessions.
|
|
422
|
+
* The shutdown cleanup (cleanup/cleanupSync) is safe because it only kills PIDs
|
|
423
|
+
* we explicitly tracked during this session.
|
|
424
|
+
*/
|
|
425
|
+
async reportOrphanedProcesses(): Promise<number[]> {
|
|
426
|
+
if (!this.isEnabled()) return [];
|
|
427
|
+
const serverUrl = this.getServerUrl();
|
|
428
|
+
if (!serverUrl) return [];
|
|
429
|
+
|
|
430
|
+
const trackedSessionIds = new Set(this.sessions.keys());
|
|
431
|
+
const orphanedPids = await this.findOrphanedAttachPids(serverUrl, trackedSessionIds);
|
|
432
|
+
|
|
433
|
+
if (orphanedPids.length > 0) {
|
|
434
|
+
this.log(
|
|
435
|
+
`Found ${orphanedPids.length} potentially orphaned processes: ${orphanedPids.join(', ')}`
|
|
436
|
+
);
|
|
437
|
+
this.log(
|
|
438
|
+
'These may be user-initiated sessions. Run "pkill -f opencode\\ attach" to clean them up manually if needed.'
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return orphanedPids;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private async findOrphanedAttachPids(
|
|
446
|
+
serverUrl: string,
|
|
447
|
+
trackedSessionIds: Set<string>
|
|
448
|
+
): Promise<number[]> {
|
|
449
|
+
try {
|
|
450
|
+
const proc = spawn(['ps', 'aux'], { stdout: 'pipe', stderr: 'pipe' });
|
|
451
|
+
await proc.exited;
|
|
452
|
+
const output = await new Response(proc.stdout).text();
|
|
453
|
+
const lines = output.split('\n');
|
|
454
|
+
const matches: number[] = [];
|
|
455
|
+
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
if (!line.includes('opencode attach')) continue;
|
|
458
|
+
if (!line.includes(serverUrl)) continue;
|
|
459
|
+
const parts = line.trim().split(/\s+/);
|
|
460
|
+
const pid = Number(parts[1]);
|
|
461
|
+
if (!Number.isFinite(pid) || pid <= 0) continue;
|
|
462
|
+
if (pid === process.pid) continue;
|
|
463
|
+
const sessionId = this.extractSessionId(line);
|
|
464
|
+
if (sessionId && trackedSessionIds.has(sessionId)) continue;
|
|
465
|
+
matches.push(pid);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return matches;
|
|
469
|
+
} catch {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private extractSessionId(line: string): string | undefined {
|
|
475
|
+
const match = line.match(/--session\s+(['"]?)([^'";\s]+)\1/);
|
|
476
|
+
return match?.[2];
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Kill a process and all its children synchronously.
|
|
481
|
+
*
|
|
482
|
+
* This is necessary because we spawn `bash -c "opencode attach ...; tmux kill-pane"`
|
|
483
|
+
* and #{pane_pid} returns the bash PID, not the opencode attach PID.
|
|
484
|
+
*/
|
|
485
|
+
private killProcessByPidSync(pid: number): void {
|
|
486
|
+
if (!Number.isFinite(pid) || pid <= 0) return;
|
|
487
|
+
|
|
488
|
+
// First, kill all child processes
|
|
489
|
+
try {
|
|
490
|
+
spawnSync(['pkill', '-TERM', '-P', String(pid)]);
|
|
491
|
+
} catch {
|
|
492
|
+
// Ignore errors - children may not exist
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Then kill the parent
|
|
496
|
+
try {
|
|
497
|
+
process.kill(pid, 'SIGTERM');
|
|
498
|
+
} catch (error) {
|
|
499
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
500
|
+
if (code === 'ESRCH') return;
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Wait for processes to die
|
|
505
|
+
try {
|
|
506
|
+
const buffer = new SharedArrayBuffer(4);
|
|
507
|
+
const view = new Int32Array(buffer);
|
|
508
|
+
Atomics.wait(view, 0, 0, 1000);
|
|
509
|
+
} catch {
|
|
510
|
+
// ignore sleep errors
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Check if parent is dead
|
|
514
|
+
try {
|
|
515
|
+
process.kill(pid, 0);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
518
|
+
if (code === 'ESRCH') return; // Dead, good
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Force kill children
|
|
522
|
+
try {
|
|
523
|
+
spawnSync(['pkill', '-KILL', '-P', String(pid)]);
|
|
524
|
+
} catch {
|
|
525
|
+
// Ignore errors
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Force kill parent
|
|
529
|
+
try {
|
|
530
|
+
process.kill(pid, 'SIGKILL');
|
|
531
|
+
} catch {
|
|
532
|
+
// ignore errors
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
323
536
|
private log(message: string): void {
|
|
324
537
|
this.callbacks?.onLog?.(`[tmux] ${message}`);
|
|
325
538
|
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Static method to clean up orphaned processes without needing an instance.
|
|
542
|
+
* This is useful for manual cleanup commands.
|
|
543
|
+
*
|
|
544
|
+
* @param serverUrl - Optional server URL to filter processes
|
|
545
|
+
* @param logger - Optional logging function
|
|
546
|
+
* @returns Object with cleanup results
|
|
547
|
+
*/
|
|
548
|
+
static async cleanupOrphans(
|
|
549
|
+
serverUrl?: string,
|
|
550
|
+
logger?: (msg: string) => void
|
|
551
|
+
): Promise<{ killed: number; windowClosed: boolean }> {
|
|
552
|
+
const log = logger ?? (() => {});
|
|
553
|
+
|
|
554
|
+
log('Starting orphan cleanup...');
|
|
555
|
+
|
|
556
|
+
// First, try to close the agents window (recovers from persisted file)
|
|
557
|
+
let windowClosed = false;
|
|
558
|
+
try {
|
|
559
|
+
await closeAgentsWindow();
|
|
560
|
+
windowClosed = true;
|
|
561
|
+
log('Closed agents window');
|
|
562
|
+
} catch {
|
|
563
|
+
log('No agents window to close');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Then kill any orphaned processes
|
|
567
|
+
const killed = await killOrphanedAttachProcesses(serverUrl, log);
|
|
568
|
+
|
|
569
|
+
log(`Orphan cleanup complete: ${killed} processes killed, window closed: ${windowClosed}`);
|
|
570
|
+
return { killed, windowClosed };
|
|
571
|
+
}
|
|
326
572
|
}
|
|
327
573
|
|
|
328
574
|
function findPane(state: WindowState, paneId: string): TmuxPaneInfo | undefined {
|
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
|
@@ -27,6 +27,7 @@ export interface WindowState {
|
|
|
27
27
|
export interface TrackedSession {
|
|
28
28
|
sessionId: string; // OpenCode session ID
|
|
29
29
|
paneId: string; // Tmux pane ID
|
|
30
|
+
pid?: number; // Process ID for direct killing
|
|
30
31
|
description: string; // Task description
|
|
31
32
|
createdAt: Date;
|
|
32
33
|
lastSeenAt: Date;
|