@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.
Files changed (41) hide show
  1. package/README.md +3 -10
  2. package/dist/agents/lead.d.ts +1 -1
  3. package/dist/agents/lead.d.ts.map +1 -1
  4. package/dist/agents/lead.js +2 -3
  5. package/dist/agents/lead.js.map +1 -1
  6. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  7. package/dist/plugin/hooks/cadence.js +3 -1
  8. package/dist/plugin/hooks/cadence.js.map +1 -1
  9. package/dist/plugin/plugin.d.ts.map +1 -1
  10. package/dist/plugin/plugin.js +49 -11
  11. package/dist/plugin/plugin.js.map +1 -1
  12. package/dist/skills/frontmatter.js +1 -1
  13. package/dist/skills/frontmatter.js.map +1 -1
  14. package/dist/tmux/executor.d.ts +29 -1
  15. package/dist/tmux/executor.d.ts.map +1 -1
  16. package/dist/tmux/executor.js +328 -13
  17. package/dist/tmux/executor.js.map +1 -1
  18. package/dist/tmux/index.d.ts +1 -1
  19. package/dist/tmux/index.d.ts.map +1 -1
  20. package/dist/tmux/index.js +1 -1
  21. package/dist/tmux/index.js.map +1 -1
  22. package/dist/tmux/manager.d.ts +36 -0
  23. package/dist/tmux/manager.d.ts.map +1 -1
  24. package/dist/tmux/manager.js +222 -10
  25. package/dist/tmux/manager.js.map +1 -1
  26. package/dist/tmux/state-query.d.ts.map +1 -1
  27. package/dist/tmux/state-query.js +4 -1
  28. package/dist/tmux/state-query.js.map +1 -1
  29. package/dist/tmux/types.d.ts +1 -0
  30. package/dist/tmux/types.d.ts.map +1 -1
  31. package/dist/tmux/types.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/agents/lead.ts +2 -3
  34. package/src/plugin/hooks/cadence.ts +2 -1
  35. package/src/plugin/plugin.ts +62 -11
  36. package/src/skills/frontmatter.ts +1 -1
  37. package/src/tmux/executor.ts +345 -13
  38. package/src/tmux/index.ts +3 -0
  39. package/src/tmux/manager.ts +255 -9
  40. package/src/tmux/state-query.ts +4 -1
  41. package/src/tmux/types.ts +1 -0
@@ -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
- if (!this.isEnabled()) return;
80
- if (this.pendingSessions.has(event.sessionId) || this.sessions.has(event.sessionId)) return;
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.spawnedPaneId);
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
- if (!this.isEnabled()) return;
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) return;
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(actions: PaneAction[], spawnedPaneId: string | undefined): void {
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 = spawnedPaneId;
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 {
@@ -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: parts[0],
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;