@agentuity/opencode 0.1.42 → 0.1.44

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 (122) hide show
  1. package/README.md +9 -9
  2. package/dist/agents/architect.d.ts +1 -1
  3. package/dist/agents/architect.d.ts.map +1 -1
  4. package/dist/agents/architect.js +4 -0
  5. package/dist/agents/architect.js.map +1 -1
  6. package/dist/agents/builder.d.ts +1 -1
  7. package/dist/agents/builder.d.ts.map +1 -1
  8. package/dist/agents/builder.js +4 -0
  9. package/dist/agents/builder.js.map +1 -1
  10. package/dist/agents/expert.d.ts +1 -1
  11. package/dist/agents/expert.d.ts.map +1 -1
  12. package/dist/agents/expert.js +2 -2
  13. package/dist/agents/index.d.ts.map +1 -1
  14. package/dist/agents/index.js +4 -0
  15. package/dist/agents/index.js.map +1 -1
  16. package/dist/agents/lead.d.ts +1 -1
  17. package/dist/agents/lead.d.ts.map +1 -1
  18. package/dist/agents/lead.js +94 -11
  19. package/dist/agents/lead.js.map +1 -1
  20. package/dist/agents/memory/entities.d.ts +32 -0
  21. package/dist/agents/memory/entities.d.ts.map +1 -0
  22. package/dist/agents/memory/entities.js +168 -0
  23. package/dist/agents/memory/entities.js.map +1 -0
  24. package/dist/agents/memory/index.d.ts +4 -0
  25. package/dist/agents/memory/index.d.ts.map +1 -0
  26. package/dist/agents/memory/index.js +2 -0
  27. package/dist/agents/memory/index.js.map +1 -0
  28. package/dist/agents/memory/types.d.ts +71 -0
  29. package/dist/agents/memory/types.d.ts.map +1 -0
  30. package/dist/agents/memory/types.js +2 -0
  31. package/dist/agents/memory/types.js.map +1 -0
  32. package/dist/agents/memory.d.ts +1 -1
  33. package/dist/agents/memory.d.ts.map +1 -1
  34. package/dist/agents/memory.js +344 -7
  35. package/dist/agents/memory.js.map +1 -1
  36. package/dist/agents/product.d.ts +4 -0
  37. package/dist/agents/product.d.ts.map +1 -0
  38. package/dist/agents/product.js +333 -0
  39. package/dist/agents/product.js.map +1 -0
  40. package/dist/agents/reasoner.d.ts +16 -0
  41. package/dist/agents/reasoner.d.ts.map +1 -0
  42. package/dist/agents/reasoner.js +160 -0
  43. package/dist/agents/reasoner.js.map +1 -0
  44. package/dist/agents/reviewer.d.ts +1 -1
  45. package/dist/agents/reviewer.d.ts.map +1 -1
  46. package/dist/agents/reviewer.js +9 -0
  47. package/dist/agents/reviewer.js.map +1 -1
  48. package/dist/background/manager.d.ts +1 -0
  49. package/dist/background/manager.d.ts.map +1 -1
  50. package/dist/background/manager.js +7 -1
  51. package/dist/background/manager.js.map +1 -1
  52. package/dist/plugin/hooks/index.d.ts +2 -0
  53. package/dist/plugin/hooks/index.d.ts.map +1 -0
  54. package/dist/plugin/hooks/index.js +2 -0
  55. package/dist/plugin/hooks/index.js.map +1 -0
  56. package/dist/plugin/hooks/session-memory.d.ts.map +1 -1
  57. package/dist/plugin/hooks/session-memory.js +5 -0
  58. package/dist/plugin/hooks/session-memory.js.map +1 -1
  59. package/dist/plugin/hooks/tools.d.ts +11 -0
  60. package/dist/plugin/hooks/tools.d.ts.map +1 -1
  61. package/dist/plugin/hooks/tools.js +18 -1
  62. package/dist/plugin/hooks/tools.js.map +1 -1
  63. package/dist/plugin/plugin.d.ts.map +1 -1
  64. package/dist/plugin/plugin.js +203 -12
  65. package/dist/plugin/plugin.js.map +1 -1
  66. package/dist/tmux/executor.d.ts +43 -20
  67. package/dist/tmux/executor.d.ts.map +1 -1
  68. package/dist/tmux/executor.js +547 -243
  69. package/dist/tmux/executor.js.map +1 -1
  70. package/dist/tmux/index.d.ts +1 -1
  71. package/dist/tmux/index.d.ts.map +1 -1
  72. package/dist/tmux/index.js +1 -1
  73. package/dist/tmux/index.js.map +1 -1
  74. package/dist/tmux/manager.d.ts +37 -3
  75. package/dist/tmux/manager.d.ts.map +1 -1
  76. package/dist/tmux/manager.js +219 -96
  77. package/dist/tmux/manager.js.map +1 -1
  78. package/dist/tmux/types.d.ts +10 -0
  79. package/dist/tmux/types.d.ts.map +1 -1
  80. package/dist/tmux/types.js.map +1 -1
  81. package/dist/tmux/utils.d.ts +17 -0
  82. package/dist/tmux/utils.d.ts.map +1 -1
  83. package/dist/tmux/utils.js +39 -0
  84. package/dist/tmux/utils.js.map +1 -1
  85. package/dist/tools/background.d.ts +2 -0
  86. package/dist/tools/background.d.ts.map +1 -1
  87. package/dist/tools/background.js +3 -3
  88. package/dist/tools/background.js.map +1 -1
  89. package/dist/tools/delegate.d.ts +4 -0
  90. package/dist/tools/delegate.d.ts.map +1 -1
  91. package/dist/tools/delegate.js +18 -3
  92. package/dist/tools/delegate.js.map +1 -1
  93. package/dist/types.d.ts +2 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +2 -0
  96. package/dist/types.js.map +1 -1
  97. package/package.json +3 -3
  98. package/src/agents/architect.ts +4 -0
  99. package/src/agents/builder.ts +4 -0
  100. package/src/agents/expert.ts +2 -2
  101. package/src/agents/index.ts +4 -0
  102. package/src/agents/lead.ts +94 -11
  103. package/src/agents/memory/entities.ts +220 -0
  104. package/src/agents/memory/index.ts +22 -0
  105. package/src/agents/memory/types.ts +76 -0
  106. package/src/agents/memory.ts +344 -7
  107. package/src/agents/product.ts +336 -0
  108. package/src/agents/reasoner.ts +182 -0
  109. package/src/agents/reviewer.ts +9 -0
  110. package/src/background/manager.ts +7 -1
  111. package/src/plugin/hooks/index.ts +1 -0
  112. package/src/plugin/hooks/session-memory.ts +5 -0
  113. package/src/plugin/hooks/tools.ts +24 -1
  114. package/src/plugin/plugin.ts +228 -12
  115. package/src/tmux/executor.ts +610 -249
  116. package/src/tmux/index.ts +5 -2
  117. package/src/tmux/manager.ts +241 -98
  118. package/src/tmux/types.ts +11 -0
  119. package/src/tmux/utils.ts +39 -0
  120. package/src/tools/background.ts +3 -3
  121. package/src/tools/delegate.ts +18 -3
  122. package/src/types.ts +2 -0
package/src/tmux/index.ts CHANGED
@@ -8,7 +8,10 @@ export {
8
8
  closeAgentsWindow,
9
9
  closeAgentsWindowSync,
10
10
  getAgentsWindowId,
11
- killOrphanedAttachProcesses,
12
- killOrphanedAttachProcessesSync,
11
+ cleanupOwnedResources,
12
+ cleanupOwnedResourcesSync,
13
+ findOwnedAgentPanes,
14
+ getPanePid,
15
+ getPanePidSync,
13
16
  } from './executor';
14
17
  export { TmuxSessionManager } from './manager';
@@ -1,5 +1,6 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
- import { spawn, spawnSync } from 'bun';
2
+ import { spawnSync } from 'bun';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import type {
4
5
  PaneAction,
5
6
  TmuxConfig,
@@ -9,7 +10,13 @@ import type {
9
10
  SessionMapping,
10
11
  } from './types';
11
12
  import { POLL_INTERVAL_MS, SESSION_MISSING_GRACE_MS, SESSION_TIMEOUT_MS } from './types';
12
- import { getCurrentPaneId, getTmuxPath, isInsideTmux } from './utils';
13
+ import {
14
+ canonicalizeServerUrl,
15
+ getCurrentPaneId,
16
+ getTmuxPath,
17
+ getTmuxSessionId,
18
+ isInsideTmux,
19
+ } from './utils';
13
20
  import { queryWindowState } from './state-query';
14
21
  import { decideSpawnActions } from './decision-engine';
15
22
  import {
@@ -18,8 +25,11 @@ import {
18
25
  closeAgentsWindowSync,
19
26
  closePaneById,
20
27
  killProcessByPid,
21
- killOrphanedAttachProcesses,
22
- killOrphanedAttachProcessesSync,
28
+ getPanePid,
29
+ getPanePidSync,
30
+ cleanupOwnedResources,
31
+ cleanupOwnedResourcesSync,
32
+ findOwnedAgentPanes,
23
33
  } from './executor';
24
34
 
25
35
  /**
@@ -55,6 +65,16 @@ export class TmuxSessionManager {
55
65
  private pendingSessions = new Set<string>();
56
66
  private pollInterval?: ReturnType<typeof setInterval>;
57
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();
58
78
 
59
79
  constructor(
60
80
  private ctx: PluginInput,
@@ -64,6 +84,20 @@ export class TmuxSessionManager {
64
84
  this.sourcePaneId = getCurrentPaneId();
65
85
  }
66
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
+
67
101
  /**
68
102
  * Check if tmux integration is enabled and available
69
103
  */
@@ -74,11 +108,25 @@ export class TmuxSessionManager {
74
108
  /**
75
109
  * Handle a new background session being created
76
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.
77
114
  */
78
115
  async onSessionCreated(event: {
79
116
  sessionId: string;
80
117
  parentId: string;
81
118
  title: string;
119
+ }): Promise<void> {
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;
82
130
  }): Promise<void> {
83
131
  this.log(`onSessionCreated called for ${event.sessionId} (${event.title})`);
84
132
 
@@ -110,6 +158,14 @@ export class TmuxSessionManager {
110
158
  return;
111
159
  }
112
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
+
113
169
  const state = await queryWindowState(this.sourcePaneId);
114
170
  if (!state) {
115
171
  this.log('Failed to query tmux window state.');
@@ -150,10 +206,19 @@ export class TmuxSessionManager {
150
206
  return;
151
207
  }
152
208
 
209
+ const serverKey = canonicalizeServerUrl(serverUrl);
210
+ this.serverKey = serverKey;
211
+
153
212
  const result = await executeActions(decision.actions, {
154
213
  config: this.config,
155
214
  serverUrl,
156
215
  windowState: state,
216
+ ownership: {
217
+ instanceId: this.instanceId,
218
+ ownerPid: this.ownerPid,
219
+ serverKey,
220
+ tmuxSessionId: this.tmuxSessionId,
221
+ },
157
222
  });
158
223
 
159
224
  if (!result.success) {
@@ -162,6 +227,7 @@ export class TmuxSessionManager {
162
227
  }
163
228
 
164
229
  this.applyActionResults(decision.actions, result.results);
230
+ await this.refreshMissingPids();
165
231
  this.log(
166
232
  `Successfully spawned pane for ${event.sessionId}. Tracking ${this.sessions.size} sessions. PIDs: ${this.getTrackedPids().join(', ') || 'none'}`
167
233
  );
@@ -188,8 +254,17 @@ export class TmuxSessionManager {
188
254
  * Explicitly kills the pane when a background session completes.
189
255
  * We can't rely on `opencode attach` exiting because it's an interactive
190
256
  * terminal that keeps running even after the session goes idle.
257
+ *
258
+ * Operations are queued to prevent race conditions.
191
259
  */
192
260
  async onSessionDeleted(event: { sessionId: string }): Promise<void> {
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> {
193
268
  this.log(`onSessionDeleted called for ${event.sessionId}`);
194
269
 
195
270
  if (!this.isEnabled()) {
@@ -218,6 +293,7 @@ export class TmuxSessionManager {
218
293
 
219
294
  // Update internal state
220
295
  this.sessions.delete(event.sessionId);
296
+ this.statusMissingSince.delete(event.sessionId);
221
297
  this.log(`Removed session from tracking. Now tracking ${this.sessions.size} sessions.`);
222
298
 
223
299
  if (this.sessions.size === 0) {
@@ -235,27 +311,30 @@ export class TmuxSessionManager {
235
311
  this.log('Starting cleanup...');
236
312
  this.stopPolling();
237
313
 
238
- let pidCleanupFailed = false;
239
314
  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);
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);
243
325
  if (!success) {
244
- this.log(`Failed to kill process ${session.pid} for session ${session.sessionId}`);
245
- pidCleanupFailed = true;
326
+ this.log(`Failed to kill process ${pid} for session ${session.sessionId}`);
246
327
  }
247
328
  }
248
329
 
249
- // Kill the entire agents window - this closes all panes at once
250
- await closeAgentsWindow();
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));
330
+ const serverKey = this.getServerKey();
331
+ if (serverKey) {
332
+ await cleanupOwnedResources(serverKey, this.instanceId);
333
+ } else {
334
+ await closeAgentsWindow();
258
335
  }
336
+ this.sessions.clear();
337
+ this.statusMissingSince.clear();
259
338
 
260
339
  this.log('Cleanup complete');
261
340
  }
@@ -270,30 +349,27 @@ export class TmuxSessionManager {
270
349
  this.log('Starting sync cleanup...');
271
350
  this.stopPolling();
272
351
 
273
- let pidCleanupFailed = false;
274
352
  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
353
+ let pid = session.pid;
354
+ if (!pid) {
355
+ pid = getPanePidSync(session.paneId);
356
+ if (pid) {
357
+ session.pid = pid;
358
+ }
284
359
  }
360
+ if (!pid) continue;
361
+ this.log(`Killing process ${pid} for session ${session.sessionId}`);
362
+ this.killProcessByPidSync(pid);
285
363
  }
286
364
 
287
- // Kill the entire agents window synchronously
288
- closeAgentsWindowSync();
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));
365
+ const serverKey = this.getServerKey();
366
+ if (serverKey) {
367
+ cleanupOwnedResourcesSync(serverKey, this.instanceId);
368
+ } else {
369
+ closeAgentsWindowSync();
296
370
  }
371
+ this.sessions.clear();
372
+ this.statusMissingSince.clear();
297
373
 
298
374
  this.log('Sync cleanup complete');
299
375
  }
@@ -321,30 +397,73 @@ export class TmuxSessionManager {
321
397
  * Poll active sessions for status changes
322
398
  */
323
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> {
324
407
  if (!this.isEnabled()) return;
325
408
  if (!this.sourcePaneId) return;
326
409
 
327
410
  const state = await queryWindowState(this.sourcePaneId);
328
411
  if (!state) return;
412
+ const statusMap = await this.fetchSessionStatuses();
329
413
 
330
414
  const now = Date.now();
415
+ const sessionsToClose: TrackedSession[] = [];
331
416
  for (const session of this.sessions.values()) {
332
417
  const pane = findPane(state, session.paneId);
333
418
  if (pane) {
334
419
  session.lastSeenAt = new Date();
335
- 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
+ }
336
436
  }
337
437
 
338
- const missingFor = now - session.lastSeenAt.getTime();
339
- if (missingFor > SESSION_MISSING_GRACE_MS) {
340
- this.sessions.delete(session.sessionId);
341
- 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);
342
443
  }
343
444
 
445
+ const statusMissingAt = this.statusMissingSince.get(session.sessionId);
446
+ const missingTooLong =
447
+ statusMissingAt !== undefined && now - statusMissingAt > SESSION_MISSING_GRACE_MS;
344
448
  const age = now - session.createdAt.getTime();
345
- if (age > SESSION_TIMEOUT_MS) {
346
- 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);
454
+ }
455
+ }
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
+ );
347
464
  }
465
+ this.sessions.delete(session.sessionId);
466
+ this.statusMissingSince.delete(session.sessionId);
348
467
  }
349
468
 
350
469
  if (this.sessions.size === 0) {
@@ -374,6 +493,20 @@ export class TmuxSessionManager {
374
493
  return typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
375
494
  }
376
495
 
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
+
377
510
  private applyActionResults(
378
511
  actions: PaneAction[],
379
512
  results: Array<{ action: PaneAction; result: { paneId?: string; pid?: number } }>
@@ -387,6 +520,7 @@ export class TmuxSessionManager {
387
520
  break;
388
521
  case 'replace':
389
522
  this.sessions.delete(action.oldSessionId);
523
+ this.statusMissingSince.delete(action.oldSessionId);
390
524
  this.sessions.set(action.newSessionId, {
391
525
  sessionId: action.newSessionId,
392
526
  paneId: action.paneId,
@@ -413,6 +547,30 @@ export class TmuxSessionManager {
413
547
  }
414
548
  }
415
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
+
416
574
  /**
417
575
  * Find and report orphaned processes (does NOT kill them by default).
418
576
  * Call this manually if you need to identify orphaned processes after a crash.
@@ -424,56 +582,31 @@ export class TmuxSessionManager {
424
582
  */
425
583
  async reportOrphanedProcesses(): Promise<number[]> {
426
584
  if (!this.isEnabled()) return [];
427
- const serverUrl = this.getServerUrl();
428
- if (!serverUrl) return [];
585
+ const serverKey = this.getServerKey();
586
+ if (!serverKey) return [];
429
587
 
430
588
  const trackedSessionIds = new Set(this.sessions.keys());
431
- const orphanedPids = await this.findOrphanedAttachPids(serverUrl, trackedSessionIds);
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
+ });
432
594
 
433
- if (orphanedPids.length > 0) {
595
+ if (orphanedPanes.length > 0) {
596
+ const sessionIds = orphanedPanes
597
+ .map((pane) => pane.sessionId)
598
+ .filter((sessionId): sessionId is string => !!sessionId);
434
599
  this.log(
435
- `Found ${orphanedPids.length} potentially orphaned processes: ${orphanedPids.join(', ')}`
600
+ `Found ${orphanedPanes.length} potentially orphaned sessions: ${sessionIds.join(', ')}`
436
601
  );
437
602
  this.log(
438
- 'These may be user-initiated sessions. Run "pkill -f opencode\\ attach" to clean them up manually if needed.'
603
+ 'These may be user-initiated sessions. Close their tmux panes manually if needed.'
439
604
  );
440
605
  }
441
606
 
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];
607
+ return orphanedPanes
608
+ .map((pane) => pane.panePid)
609
+ .filter((pid): pid is number => typeof pid === 'number');
477
610
  }
478
611
 
479
612
  /**
@@ -543,31 +676,34 @@ export class TmuxSessionManager {
543
676
  *
544
677
  * @param serverUrl - Optional server URL to filter processes
545
678
  * @param logger - Optional logging function
679
+ * @param instanceId - Ownership instance id to target cleanup
546
680
  * @returns Object with cleanup results
547
681
  */
548
682
  static async cleanupOrphans(
549
683
  serverUrl?: string,
550
- logger?: (msg: string) => void
684
+ logger?: (msg: string) => void,
685
+ instanceId?: string
551
686
  ): Promise<{ killed: number; windowClosed: boolean }> {
552
687
  const log = logger ?? (() => {});
553
688
 
554
689
  log('Starting orphan cleanup...');
555
690
 
556
- // First, try to close the agents window (recovers from persisted file)
557
- let windowClosed = false;
691
+ let serverKey: string | undefined;
558
692
  try {
559
- await closeAgentsWindow();
560
- windowClosed = true;
561
- log('Closed agents window');
693
+ serverKey = serverUrl ? canonicalizeServerUrl(serverUrl) : undefined;
562
694
  } catch {
563
- log('No agents window to close');
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 };
564
700
  }
565
701
 
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 };
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 };
571
707
  }
572
708
  }
573
709
 
@@ -575,3 +711,10 @@ function findPane(state: WindowState, paneId: string): TmuxPaneInfo | undefined
575
711
  if (state.mainPane?.paneId === paneId) return state.mainPane;
576
712
  return state.agentPanes.find((pane) => pane.paneId === paneId);
577
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/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
  */
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
+ }
@@ -60,7 +60,7 @@ export function createBackgroundTools(manager: BackgroundManager): {
60
60
  };
61
61
  } {
62
62
  const backgroundTaskTool = {
63
- name: 'background_task',
63
+ name: 'agentuity_background_task',
64
64
  description: 'Launch a task to run in the background.',
65
65
  args: BackgroundTaskArgsSchema,
66
66
  async execute(
@@ -92,7 +92,7 @@ export function createBackgroundTools(manager: BackgroundManager): {
92
92
  };
93
93
 
94
94
  const backgroundOutputTool = {
95
- name: 'background_output',
95
+ name: 'agentuity_background_output',
96
96
  description: 'Retrieve output for a background task.',
97
97
  args: BackgroundOutputArgsSchema,
98
98
  async execute(args: BackgroundOutputArgs): Promise<{
@@ -119,7 +119,7 @@ export function createBackgroundTools(manager: BackgroundManager): {
119
119
  };
120
120
 
121
121
  const backgroundCancelTool = {
122
- name: 'background_cancel',
122
+ name: 'agentuity_background_cancel',
123
123
  description: 'Cancel a running background task.',
124
124
  args: BackgroundCancelArgsSchema,
125
125
  async execute(args: BackgroundCancelArgs): Promise<{
@@ -4,7 +4,18 @@ import type { AgentRole } from '../types';
4
4
  // Schema for the delegate tool
5
5
  export const DelegateArgsSchema = z.object({
6
6
  agent: z
7
- .enum(['scout', 'builder', 'architect', 'reviewer', 'memory', 'expert', 'planner', 'runner'])
7
+ .enum([
8
+ 'scout',
9
+ 'builder',
10
+ 'architect',
11
+ 'reviewer',
12
+ 'memory',
13
+ 'reasoner',
14
+ 'expert',
15
+ 'planner',
16
+ 'runner',
17
+ 'product',
18
+ ])
8
19
  .describe('The agent to delegate to'),
9
20
  task: z.string().describe('Clear description of the task to delegate'),
10
21
  context: z.string().optional().describe('Additional context from previous tasks'),
@@ -27,21 +38,25 @@ const AGENT_MENTIONS: Record<AgentRole, string> = {
27
38
  expert: '@Agentuity Coder Expert',
28
39
  planner: '@Agentuity Coder Planner',
29
40
  runner: '@Agentuity Coder Runner',
41
+ reasoner: '@Agentuity Coder Reasoner',
42
+ product: '@Agentuity Coder Product',
30
43
  };
31
44
 
32
45
  export const delegateTool = {
33
- name: 'coder_delegate',
46
+ name: 'agentuity_coder_delegate',
34
47
  description: `Delegate a task to a specialized Agentuity Coder agent.
35
48
 
36
49
  Use this to:
37
50
  - Scout: Explore codebase, find patterns, research documentation
38
51
  - Builder: Implement features, write code, run tests (interactive work)
39
52
  - Architect: Complex autonomous tasks, Cadence mode, deep reasoning (GPT Codex)
40
- - Reviewer: Review changes, catch issues, apply fixes
53
+ - Reviewer: Review changes, catch issues, apply fixes
41
54
  - Memory: Store context, remember decisions across sessions
55
+ - Reasoner: Extract structured conclusions, resolve conflicts, surface corrections
42
56
  - Expert: Get help with Agentuity CLI and cloud services
43
57
  - Planner: Strategic advisor for complex architecture and deep planning (read-only)
44
58
  - Runner: Run lint/build/test/typecheck/format/clean/install commands, returns structured results
59
+ - Product: Drive clarity on requirements, validate features, track progress, Cadence briefings
45
60
 
46
61
  The task will be executed by the specified agent and the result returned.`,
47
62
 
package/src/types.ts CHANGED
@@ -25,6 +25,8 @@ export const AgentRoleSchema = z.enum([
25
25
  'expert',
26
26
  'planner',
27
27
  'runner',
28
+ 'reasoner',
29
+ 'product',
28
30
  ]);
29
31
  export type AgentRole = z.infer<typeof AgentRoleSchema>;
30
32