@agentuity/opencode 0.1.40 → 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 (165) hide show
  1. package/README.md +324 -19
  2. package/dist/agents/architect.d.ts +4 -0
  3. package/dist/agents/architect.d.ts.map +1 -0
  4. package/dist/agents/architect.js +259 -0
  5. package/dist/agents/architect.js.map +1 -0
  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 +44 -1
  9. package/dist/agents/builder.js.map +1 -1
  10. package/dist/agents/index.d.ts.map +1 -1
  11. package/dist/agents/index.js +6 -0
  12. package/dist/agents/index.js.map +1 -1
  13. package/dist/agents/lead.d.ts +1 -1
  14. package/dist/agents/lead.d.ts.map +1 -1
  15. package/dist/agents/lead.js +185 -22
  16. package/dist/agents/lead.js.map +1 -1
  17. package/dist/agents/planner.d.ts +4 -0
  18. package/dist/agents/planner.d.ts.map +1 -0
  19. package/dist/agents/planner.js +158 -0
  20. package/dist/agents/planner.js.map +1 -0
  21. package/dist/agents/runner.d.ts +4 -0
  22. package/dist/agents/runner.d.ts.map +1 -0
  23. package/dist/agents/runner.js +364 -0
  24. package/dist/agents/runner.js.map +1 -0
  25. package/dist/agents/types.d.ts +5 -1
  26. package/dist/agents/types.d.ts.map +1 -1
  27. package/dist/background/concurrency.d.ts +36 -0
  28. package/dist/background/concurrency.d.ts.map +1 -0
  29. package/dist/background/concurrency.js +92 -0
  30. package/dist/background/concurrency.js.map +1 -0
  31. package/dist/background/index.d.ts +5 -0
  32. package/dist/background/index.d.ts.map +1 -0
  33. package/dist/background/index.js +4 -0
  34. package/dist/background/index.js.map +1 -0
  35. package/dist/background/manager.d.ts +54 -0
  36. package/dist/background/manager.d.ts.map +1 -0
  37. package/dist/background/manager.js +409 -0
  38. package/dist/background/manager.js.map +1 -0
  39. package/dist/background/types.d.ts +47 -0
  40. package/dist/background/types.d.ts.map +1 -0
  41. package/dist/background/types.js +2 -0
  42. package/dist/background/types.js.map +1 -0
  43. package/dist/config/index.d.ts +2 -0
  44. package/dist/config/index.d.ts.map +1 -1
  45. package/dist/config/index.js +2 -0
  46. package/dist/config/index.js.map +1 -1
  47. package/dist/config/loader.d.ts +24 -0
  48. package/dist/config/loader.d.ts.map +1 -1
  49. package/dist/config/loader.js +102 -23
  50. package/dist/config/loader.js.map +1 -1
  51. package/dist/config/presets.d.ts +16 -0
  52. package/dist/config/presets.d.ts.map +1 -0
  53. package/dist/config/presets.js +20 -0
  54. package/dist/config/presets.js.map +1 -0
  55. package/dist/config/validation.d.ts +26 -0
  56. package/dist/config/validation.d.ts.map +1 -0
  57. package/dist/config/validation.js +48 -0
  58. package/dist/config/validation.js.map +1 -0
  59. package/dist/index.d.ts +1 -1
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/plugin/hooks/cadence.d.ts.map +1 -1
  63. package/dist/plugin/hooks/cadence.js +3 -1
  64. package/dist/plugin/hooks/cadence.js.map +1 -1
  65. package/dist/plugin/hooks/keyword.d.ts.map +1 -1
  66. package/dist/plugin/hooks/keyword.js +3 -0
  67. package/dist/plugin/hooks/keyword.js.map +1 -1
  68. package/dist/plugin/plugin.d.ts.map +1 -1
  69. package/dist/plugin/plugin.js +335 -36
  70. package/dist/plugin/plugin.js.map +1 -1
  71. package/dist/skills/frontmatter.d.ts +7 -0
  72. package/dist/skills/frontmatter.d.ts.map +1 -0
  73. package/dist/skills/frontmatter.js +17 -0
  74. package/dist/skills/frontmatter.js.map +1 -0
  75. package/dist/skills/index.d.ts +4 -0
  76. package/dist/skills/index.d.ts.map +1 -0
  77. package/dist/skills/index.js +4 -0
  78. package/dist/skills/index.js.map +1 -0
  79. package/dist/skills/loader.d.ts +20 -0
  80. package/dist/skills/loader.d.ts.map +1 -0
  81. package/dist/skills/loader.js +152 -0
  82. package/dist/skills/loader.js.map +1 -0
  83. package/dist/skills/types.d.ts +41 -0
  84. package/dist/skills/types.d.ts.map +1 -0
  85. package/dist/skills/types.js +2 -0
  86. package/dist/skills/types.js.map +1 -0
  87. package/dist/tmux/decision-engine.d.ts +24 -0
  88. package/dist/tmux/decision-engine.d.ts.map +1 -0
  89. package/dist/tmux/decision-engine.js +193 -0
  90. package/dist/tmux/decision-engine.js.map +1 -0
  91. package/dist/tmux/executor.d.ts +84 -0
  92. package/dist/tmux/executor.d.ts.map +1 -0
  93. package/dist/tmux/executor.js +546 -0
  94. package/dist/tmux/executor.js.map +1 -0
  95. package/dist/tmux/index.d.ts +7 -0
  96. package/dist/tmux/index.d.ts.map +1 -0
  97. package/dist/tmux/index.js +7 -0
  98. package/dist/tmux/index.js.map +1 -0
  99. package/dist/tmux/manager.d.ts +116 -0
  100. package/dist/tmux/manager.d.ts.map +1 -0
  101. package/dist/tmux/manager.js +488 -0
  102. package/dist/tmux/manager.js.map +1 -0
  103. package/dist/tmux/state-query.d.ts +7 -0
  104. package/dist/tmux/state-query.d.ts.map +1 -0
  105. package/dist/tmux/state-query.js +70 -0
  106. package/dist/tmux/state-query.js.map +1 -0
  107. package/dist/tmux/types.d.ts +97 -0
  108. package/dist/tmux/types.d.ts.map +1 -0
  109. package/dist/tmux/types.js +8 -0
  110. package/dist/tmux/types.js.map +1 -0
  111. package/dist/tmux/utils.d.ts +32 -0
  112. package/dist/tmux/utils.d.ts.map +1 -0
  113. package/dist/tmux/utils.js +80 -0
  114. package/dist/tmux/utils.js.map +1 -0
  115. package/dist/tools/background.d.ts +61 -0
  116. package/dist/tools/background.d.ts.map +1 -0
  117. package/dist/tools/background.js +78 -0
  118. package/dist/tools/background.js.map +1 -0
  119. package/dist/tools/delegate.d.ts +6 -0
  120. package/dist/tools/delegate.d.ts.map +1 -1
  121. package/dist/tools/delegate.js +8 -2
  122. package/dist/tools/delegate.js.map +1 -1
  123. package/dist/tools/index.d.ts +1 -0
  124. package/dist/tools/index.d.ts.map +1 -1
  125. package/dist/tools/index.js +1 -0
  126. package/dist/tools/index.js.map +1 -1
  127. package/dist/types.d.ts +118 -18
  128. package/dist/types.d.ts.map +1 -1
  129. package/dist/types.js +49 -7
  130. package/dist/types.js.map +1 -1
  131. package/package.json +4 -3
  132. package/src/agents/architect.ts +262 -0
  133. package/src/agents/builder.ts +44 -1
  134. package/src/agents/index.ts +6 -0
  135. package/src/agents/lead.ts +185 -22
  136. package/src/agents/planner.ts +161 -0
  137. package/src/agents/runner.ts +367 -0
  138. package/src/agents/types.ts +5 -1
  139. package/src/background/concurrency.ts +116 -0
  140. package/src/background/index.ts +4 -0
  141. package/src/background/manager.ts +478 -0
  142. package/src/background/types.ts +52 -0
  143. package/src/config/index.ts +2 -0
  144. package/src/config/loader.ts +128 -31
  145. package/src/config/presets.ts +21 -0
  146. package/src/config/validation.ts +70 -0
  147. package/src/index.ts +1 -0
  148. package/src/plugin/hooks/cadence.ts +2 -1
  149. package/src/plugin/hooks/keyword.ts +3 -0
  150. package/src/plugin/plugin.ts +374 -42
  151. package/src/skills/frontmatter.ts +25 -0
  152. package/src/skills/index.ts +3 -0
  153. package/src/skills/loader.ts +185 -0
  154. package/src/skills/types.ts +43 -0
  155. package/src/tmux/decision-engine.ts +246 -0
  156. package/src/tmux/executor.ts +618 -0
  157. package/src/tmux/index.ts +14 -0
  158. package/src/tmux/manager.ts +577 -0
  159. package/src/tmux/state-query.ts +77 -0
  160. package/src/tmux/types.ts +107 -0
  161. package/src/tmux/utils.ts +85 -0
  162. package/src/tools/background.ts +145 -0
  163. package/src/tools/delegate.ts +8 -2
  164. package/src/tools/index.ts +9 -0
  165. package/src/types.ts +88 -15
@@ -0,0 +1,577 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import { spawn, spawnSync } from 'bun';
3
+ import type {
4
+ PaneAction,
5
+ TmuxConfig,
6
+ TmuxPaneInfo,
7
+ TrackedSession,
8
+ WindowState,
9
+ SessionMapping,
10
+ } from './types';
11
+ import { POLL_INTERVAL_MS, SESSION_MISSING_GRACE_MS, SESSION_TIMEOUT_MS } from './types';
12
+ import { getCurrentPaneId, getTmuxPath, isInsideTmux } from './utils';
13
+ import { queryWindowState } from './state-query';
14
+ import { decideSpawnActions } from './decision-engine';
15
+ import {
16
+ executeActions,
17
+ closeAgentsWindow,
18
+ closeAgentsWindowSync,
19
+ closePaneById,
20
+ killProcessByPid,
21
+ killOrphanedAttachProcesses,
22
+ killOrphanedAttachProcessesSync,
23
+ } from './executor';
24
+
25
+ /**
26
+ * Check if the OpenCode server is running by hitting the health endpoint
27
+ */
28
+ async function isServerRunning(serverUrl: string): Promise<boolean> {
29
+ try {
30
+ const healthUrl = new URL('/health', serverUrl).toString();
31
+ const response = await fetch(healthUrl, {
32
+ signal: AbortSignal.timeout(2000),
33
+ });
34
+ return response.ok;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ export interface TmuxSessionManagerCallbacks {
41
+ onLog?: (message: string) => void;
42
+ }
43
+
44
+ /**
45
+ * Manages tmux panes for background agents.
46
+ *
47
+ * Architecture:
48
+ * 1. QUERY: Get actual tmux pane state (source of truth)
49
+ * 2. DECIDE: Pure function determines actions based on state
50
+ * 3. EXECUTE: Execute actions with verification
51
+ * 4. UPDATE: Update internal cache only after tmux confirms success
52
+ */
53
+ export class TmuxSessionManager {
54
+ private sessions = new Map<string, TrackedSession>();
55
+ private pendingSessions = new Set<string>();
56
+ private pollInterval?: ReturnType<typeof setInterval>;
57
+ private sourcePaneId: string | undefined;
58
+
59
+ constructor(
60
+ private ctx: PluginInput,
61
+ private config: TmuxConfig,
62
+ private callbacks?: TmuxSessionManagerCallbacks
63
+ ) {
64
+ this.sourcePaneId = getCurrentPaneId();
65
+ }
66
+
67
+ /**
68
+ * Check if tmux integration is enabled and available
69
+ */
70
+ isEnabled(): boolean {
71
+ return this.config.enabled && isInsideTmux();
72
+ }
73
+
74
+ /**
75
+ * Handle a new background session being created
76
+ * This is called by BackgroundManager when a background task starts
77
+ */
78
+ async onSessionCreated(event: {
79
+ sessionId: string;
80
+ parentId: string;
81
+ title: string;
82
+ }): Promise<void> {
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
+ }
95
+ this.pendingSessions.add(event.sessionId);
96
+
97
+ try {
98
+ const tmuxPath = await getTmuxPath();
99
+ if (!tmuxPath) {
100
+ this.log('tmux binary not found.');
101
+ return;
102
+ }
103
+
104
+ if (!this.sourcePaneId) {
105
+ this.sourcePaneId = getCurrentPaneId();
106
+ }
107
+
108
+ if (!this.sourcePaneId) {
109
+ this.log('Unable to determine source pane id.');
110
+ return;
111
+ }
112
+
113
+ const state = await queryWindowState(this.sourcePaneId);
114
+ if (!state) {
115
+ this.log('Failed to query tmux window state.');
116
+ return;
117
+ }
118
+
119
+ const decision = decideSpawnActions(
120
+ state,
121
+ event.sessionId,
122
+ event.title,
123
+ {
124
+ mainPaneMinWidth: this.config.mainPaneMinWidth,
125
+ agentPaneMinWidth: this.config.agentPaneMinWidth,
126
+ maxPanes: this.config.maxPanes,
127
+ },
128
+ this.getSessionMappings()
129
+ );
130
+
131
+ if (!decision.canSpawn) {
132
+ if (decision.reason) {
133
+ this.log(`Cannot spawn pane: ${decision.reason}`);
134
+ }
135
+ return;
136
+ }
137
+
138
+ const serverUrl = this.getServerUrl();
139
+ if (!serverUrl) {
140
+ this.log('Unable to determine OpenCode server URL.');
141
+ return;
142
+ }
143
+
144
+ // Check if server is actually running before attempting to spawn
145
+ const serverRunning = await isServerRunning(serverUrl);
146
+ if (!serverRunning) {
147
+ this.log(
148
+ `Server not running at ${serverUrl}. Start opencode with --port flag to enable tmux integration.`
149
+ );
150
+ return;
151
+ }
152
+
153
+ const result = await executeActions(decision.actions, {
154
+ config: this.config,
155
+ serverUrl,
156
+ windowState: state,
157
+ });
158
+
159
+ if (!result.success) {
160
+ this.log('Failed to execute tmux actions.');
161
+ return;
162
+ }
163
+
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
+ );
168
+ if (this.sessions.size > 0) {
169
+ this.startPolling();
170
+ }
171
+ } finally {
172
+ this.pendingSessions.delete(event.sessionId);
173
+ }
174
+ }
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
+
185
+ /**
186
+ * Handle a session being deleted
187
+ *
188
+ * Explicitly kills the pane when a background session completes.
189
+ * We can't rely on `opencode attach` exiting because it's an interactive
190
+ * terminal that keeps running even after the session goes idle.
191
+ */
192
+ async onSessionDeleted(event: { sessionId: string }): Promise<void> {
193
+ this.log(`onSessionDeleted called for ${event.sessionId}`);
194
+
195
+ if (!this.isEnabled()) {
196
+ this.log(`Skipping delete - tmux not enabled`);
197
+ return;
198
+ }
199
+
200
+ // Find the session in our mappings
201
+ const session = this.sessions.get(event.sessionId);
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
+ );
210
+
211
+ // Kill the pane explicitly - opencode attach won't exit on its own
212
+ const result = await closePaneById(session.paneId, session.pid);
213
+ if (!result.success) {
214
+ this.log(`Failed to close pane ${session.paneId}: ${result.error}`);
215
+ } else {
216
+ this.log(`Successfully closed pane ${session.paneId}`);
217
+ }
218
+
219
+ // Update internal state
220
+ this.sessions.delete(event.sessionId);
221
+ this.log(`Removed session from tracking. Now tracking ${this.sessions.size} sessions.`);
222
+
223
+ if (this.sessions.size === 0) {
224
+ this.stopPolling();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Clean up all panes on shutdown
230
+ *
231
+ * Kills the entire "Agents" window, which closes all agent panes at once.
232
+ * Falls back to pkill if PID-based cleanup fails.
233
+ */
234
+ async cleanup(): Promise<void> {
235
+ this.log('Starting cleanup...');
236
+ this.stopPolling();
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
+
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));
258
+ }
259
+
260
+ this.log('Cleanup complete');
261
+ }
262
+
263
+ /**
264
+ * Synchronous cleanup for shutdown (ensures completion before exit)
265
+ *
266
+ * Uses spawnSync to guarantee the tmux commands complete before the
267
+ * process exits, which is necessary for signal handlers.
268
+ */
269
+ cleanupSync(): void {
270
+ this.log('Starting sync cleanup...');
271
+ this.stopPolling();
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
+
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));
296
+ }
297
+
298
+ this.log('Sync cleanup complete');
299
+ }
300
+
301
+ /**
302
+ * Start polling for session status
303
+ */
304
+ private startPolling(): void {
305
+ if (this.pollInterval) return;
306
+ this.pollInterval = setInterval(() => {
307
+ void this.pollSessions();
308
+ }, POLL_INTERVAL_MS);
309
+ }
310
+
311
+ /**
312
+ * Stop polling
313
+ */
314
+ private stopPolling(): void {
315
+ if (!this.pollInterval) return;
316
+ clearInterval(this.pollInterval);
317
+ this.pollInterval = undefined;
318
+ }
319
+
320
+ /**
321
+ * Poll active sessions for status changes
322
+ */
323
+ private async pollSessions(): Promise<void> {
324
+ if (!this.isEnabled()) return;
325
+ if (!this.sourcePaneId) return;
326
+
327
+ const state = await queryWindowState(this.sourcePaneId);
328
+ if (!state) return;
329
+
330
+ const now = Date.now();
331
+ for (const session of this.sessions.values()) {
332
+ const pane = findPane(state, session.paneId);
333
+ if (pane) {
334
+ session.lastSeenAt = new Date();
335
+ continue;
336
+ }
337
+
338
+ const missingFor = now - session.lastSeenAt.getTime();
339
+ if (missingFor > SESSION_MISSING_GRACE_MS) {
340
+ this.sessions.delete(session.sessionId);
341
+ continue;
342
+ }
343
+
344
+ const age = now - session.createdAt.getTime();
345
+ if (age > SESSION_TIMEOUT_MS) {
346
+ this.sessions.delete(session.sessionId);
347
+ }
348
+ }
349
+
350
+ if (this.sessions.size === 0) {
351
+ this.stopPolling();
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Get session mappings for decision engine
357
+ */
358
+ private getSessionMappings(): SessionMapping[] {
359
+ return Array.from(this.sessions.values()).map((session) => ({
360
+ sessionId: session.sessionId,
361
+ paneId: session.paneId,
362
+ createdAt: session.createdAt,
363
+ }));
364
+ }
365
+
366
+ private getServerUrl(): string | undefined {
367
+ const ctx = this.ctx as unknown as {
368
+ serverUrl?: string | URL;
369
+ baseUrl?: string | URL;
370
+ client?: { baseUrl?: string | URL };
371
+ };
372
+ const serverUrl = ctx.serverUrl ?? ctx.baseUrl ?? ctx.client?.baseUrl;
373
+ if (!serverUrl) return undefined;
374
+ return typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
375
+ }
376
+
377
+ private applyActionResults(
378
+ actions: PaneAction[],
379
+ results: Array<{ action: PaneAction; result: { paneId?: string; pid?: number } }>
380
+ ): void {
381
+ const now = new Date();
382
+ for (const [index, action] of actions.entries()) {
383
+ const actionResult = results[index]?.result;
384
+ switch (action.type) {
385
+ case 'close':
386
+ this.sessions.delete(action.sessionId);
387
+ break;
388
+ case 'replace':
389
+ this.sessions.delete(action.oldSessionId);
390
+ this.sessions.set(action.newSessionId, {
391
+ sessionId: action.newSessionId,
392
+ paneId: action.paneId,
393
+ pid: actionResult?.pid,
394
+ description: action.description,
395
+ createdAt: now,
396
+ lastSeenAt: now,
397
+ });
398
+ break;
399
+ case 'spawn': {
400
+ const paneId = actionResult?.paneId;
401
+ if (!paneId) break;
402
+ this.sessions.set(action.sessionId, {
403
+ sessionId: action.sessionId,
404
+ paneId,
405
+ pid: actionResult?.pid,
406
+ description: action.description,
407
+ createdAt: now,
408
+ lastSeenAt: now,
409
+ });
410
+ break;
411
+ }
412
+ }
413
+ }
414
+ }
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
+
536
+ private log(message: string): void {
537
+ this.callbacks?.onLog?.(`[tmux] ${message}`);
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
+ }
572
+ }
573
+
574
+ function findPane(state: WindowState, paneId: string): TmuxPaneInfo | undefined {
575
+ if (state.mainPane?.paneId === paneId) return state.mainPane;
576
+ return state.agentPanes.find((pane) => pane.paneId === paneId);
577
+ }
@@ -0,0 +1,77 @@
1
+ import type { WindowState, TmuxPaneInfo } from './types';
2
+ import { runTmuxCommand } from './utils';
3
+
4
+ /**
5
+ * Query the current tmux window state
6
+ * Returns information about all panes in the current window
7
+ */
8
+ export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
9
+ const result = await runTmuxCommand([
10
+ 'list-panes',
11
+ '-t',
12
+ sourcePaneId,
13
+ '-F',
14
+ '#{pane_id},#{pane_width},#{pane_height},#{pane_left},#{pane_top},#{pane_title},#{pane_active},#{window_width},#{window_height}',
15
+ ]);
16
+
17
+ if (!result.success) {
18
+ return null;
19
+ }
20
+
21
+ const lines = result.output
22
+ .split('\n')
23
+ .map((line) => line.trim())
24
+ .filter(Boolean);
25
+ if (lines.length === 0) return null;
26
+
27
+ const panes: TmuxPaneInfo[] = [];
28
+ let windowWidth = 0;
29
+ let windowHeight = 0;
30
+
31
+ for (const line of lines) {
32
+ const parts = line.split(',');
33
+ if (parts.length < 9) continue;
34
+
35
+ const paneId = parts[0];
36
+ if (!paneId) continue;
37
+
38
+ const windowWidthValue = Number(parts[parts.length - 2]);
39
+ const windowHeightValue = Number(parts[parts.length - 1]);
40
+ const isActiveValue = parts[parts.length - 3] === '1';
41
+ const title = parts.slice(5, parts.length - 3).join(',');
42
+
43
+ const width = Number(parts[1]);
44
+ const height = Number(parts[2]);
45
+ const left = Number(parts[3]);
46
+ const top = Number(parts[4]);
47
+ if ([width, height, left, top].some((value) => Number.isNaN(value))) {
48
+ continue;
49
+ }
50
+
51
+ const paneInfo: TmuxPaneInfo = {
52
+ paneId,
53
+ width,
54
+ height,
55
+ left,
56
+ top,
57
+ title,
58
+ isActive: isActiveValue,
59
+ };
60
+
61
+ if (!Number.isNaN(windowWidthValue)) windowWidth = windowWidthValue;
62
+ if (!Number.isNaN(windowHeightValue)) windowHeight = windowHeightValue;
63
+ panes.push(paneInfo);
64
+ }
65
+
66
+ if (panes.length === 0) return null;
67
+
68
+ const mainPane = panes.find((pane) => pane.paneId === sourcePaneId) ?? null;
69
+ const agentPanes = panes.filter((pane) => pane.paneId !== sourcePaneId);
70
+
71
+ return {
72
+ windowWidth,
73
+ windowHeight,
74
+ mainPane,
75
+ agentPanes,
76
+ };
77
+ }