@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.
Files changed (51) 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/background/manager.d.ts +1 -0
  7. package/dist/background/manager.d.ts.map +1 -1
  8. package/dist/background/manager.js +6 -0
  9. package/dist/background/manager.js.map +1 -1
  10. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  11. package/dist/plugin/hooks/cadence.js +3 -1
  12. package/dist/plugin/hooks/cadence.js.map +1 -1
  13. package/dist/plugin/plugin.d.ts.map +1 -1
  14. package/dist/plugin/plugin.js +54 -11
  15. package/dist/plugin/plugin.js.map +1 -1
  16. package/dist/skills/frontmatter.js +1 -1
  17. package/dist/skills/frontmatter.js.map +1 -1
  18. package/dist/tmux/executor.d.ts +57 -6
  19. package/dist/tmux/executor.d.ts.map +1 -1
  20. package/dist/tmux/executor.js +676 -57
  21. package/dist/tmux/executor.js.map +1 -1
  22. package/dist/tmux/index.d.ts +1 -1
  23. package/dist/tmux/index.d.ts.map +1 -1
  24. package/dist/tmux/index.js +1 -1
  25. package/dist/tmux/index.js.map +1 -1
  26. package/dist/tmux/manager.d.ts +70 -0
  27. package/dist/tmux/manager.d.ts.map +1 -1
  28. package/dist/tmux/manager.js +357 -22
  29. package/dist/tmux/manager.js.map +1 -1
  30. package/dist/tmux/state-query.d.ts.map +1 -1
  31. package/dist/tmux/state-query.js +4 -1
  32. package/dist/tmux/state-query.js.map +1 -1
  33. package/dist/tmux/types.d.ts +11 -0
  34. package/dist/tmux/types.d.ts.map +1 -1
  35. package/dist/tmux/types.js.map +1 -1
  36. package/dist/tmux/utils.d.ts +17 -0
  37. package/dist/tmux/utils.d.ts.map +1 -1
  38. package/dist/tmux/utils.js +39 -0
  39. package/dist/tmux/utils.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/agents/lead.ts +2 -3
  42. package/src/background/manager.ts +6 -0
  43. package/src/plugin/hooks/cadence.ts +2 -1
  44. package/src/plugin/plugin.ts +67 -11
  45. package/src/skills/frontmatter.ts +1 -1
  46. package/src/tmux/executor.ts +748 -55
  47. package/src/tmux/index.ts +6 -0
  48. package/src/tmux/manager.ts +410 -21
  49. package/src/tmux/state-query.ts +4 -1
  50. package/src/tmux/types.ts +12 -0
  51. 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';
@@ -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 { getCurrentPaneId, getTmuxPath, isInsideTmux } from './utils';
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
- if (!this.isEnabled()) return;
80
- if (this.pendingSessions.has(event.sessionId) || this.sessions.has(event.sessionId)) return;
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.spawnedPaneId);
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
- if (!this.isEnabled()) return;
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) return;
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
- // Kill the entire agents window - this closes all panes at once
196
- await closeAgentsWindow();
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
- // Kill the entire agents window synchronously
210
- closeAgentsWindowSync();
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
- continue;
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 missingFor = now - session.lastSeenAt.getTime();
252
- if (missingFor > SESSION_MISSING_GRACE_MS) {
253
- this.sessions.delete(session.sessionId);
254
- continue;
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
- if (age > SESSION_TIMEOUT_MS) {
259
- this.sessions.delete(session.sessionId);
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 applyActionResults(actions: PaneAction[], spawnedPaneId: string | undefined): void {
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 = spawnedPaneId;
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
+ }
@@ -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
@@ -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
+ }