@agentuity/opencode 0.1.39 → 0.1.41

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 (161) hide show
  1. package/README.md +321 -9
  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 +183 -19
  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/keyword.d.ts.map +1 -1
  63. package/dist/plugin/hooks/keyword.js +3 -0
  64. package/dist/plugin/hooks/keyword.js.map +1 -1
  65. package/dist/plugin/plugin.d.ts.map +1 -1
  66. package/dist/plugin/plugin.js +297 -36
  67. package/dist/plugin/plugin.js.map +1 -1
  68. package/dist/skills/frontmatter.d.ts +7 -0
  69. package/dist/skills/frontmatter.d.ts.map +1 -0
  70. package/dist/skills/frontmatter.js +17 -0
  71. package/dist/skills/frontmatter.js.map +1 -0
  72. package/dist/skills/index.d.ts +4 -0
  73. package/dist/skills/index.d.ts.map +1 -0
  74. package/dist/skills/index.js +4 -0
  75. package/dist/skills/index.js.map +1 -0
  76. package/dist/skills/loader.d.ts +20 -0
  77. package/dist/skills/loader.d.ts.map +1 -0
  78. package/dist/skills/loader.js +152 -0
  79. package/dist/skills/loader.js.map +1 -0
  80. package/dist/skills/types.d.ts +41 -0
  81. package/dist/skills/types.d.ts.map +1 -0
  82. package/dist/skills/types.js +2 -0
  83. package/dist/skills/types.js.map +1 -0
  84. package/dist/tmux/decision-engine.d.ts +24 -0
  85. package/dist/tmux/decision-engine.d.ts.map +1 -0
  86. package/dist/tmux/decision-engine.js +193 -0
  87. package/dist/tmux/decision-engine.js.map +1 -0
  88. package/dist/tmux/executor.d.ts +56 -0
  89. package/dist/tmux/executor.d.ts.map +1 -0
  90. package/dist/tmux/executor.js +231 -0
  91. package/dist/tmux/executor.js.map +1 -0
  92. package/dist/tmux/index.d.ts +7 -0
  93. package/dist/tmux/index.d.ts.map +1 -0
  94. package/dist/tmux/index.js +7 -0
  95. package/dist/tmux/index.js.map +1 -0
  96. package/dist/tmux/manager.d.ts +80 -0
  97. package/dist/tmux/manager.d.ts.map +1 -0
  98. package/dist/tmux/manager.js +276 -0
  99. package/dist/tmux/manager.js.map +1 -0
  100. package/dist/tmux/state-query.d.ts +7 -0
  101. package/dist/tmux/state-query.d.ts.map +1 -0
  102. package/dist/tmux/state-query.js +67 -0
  103. package/dist/tmux/state-query.js.map +1 -0
  104. package/dist/tmux/types.d.ts +96 -0
  105. package/dist/tmux/types.d.ts.map +1 -0
  106. package/dist/tmux/types.js +8 -0
  107. package/dist/tmux/types.js.map +1 -0
  108. package/dist/tmux/utils.d.ts +32 -0
  109. package/dist/tmux/utils.d.ts.map +1 -0
  110. package/dist/tmux/utils.js +80 -0
  111. package/dist/tmux/utils.js.map +1 -0
  112. package/dist/tools/background.d.ts +61 -0
  113. package/dist/tools/background.d.ts.map +1 -0
  114. package/dist/tools/background.js +78 -0
  115. package/dist/tools/background.js.map +1 -0
  116. package/dist/tools/delegate.d.ts +6 -0
  117. package/dist/tools/delegate.d.ts.map +1 -1
  118. package/dist/tools/delegate.js +8 -2
  119. package/dist/tools/delegate.js.map +1 -1
  120. package/dist/tools/index.d.ts +1 -0
  121. package/dist/tools/index.d.ts.map +1 -1
  122. package/dist/tools/index.js +1 -0
  123. package/dist/tools/index.js.map +1 -1
  124. package/dist/types.d.ts +118 -18
  125. package/dist/types.d.ts.map +1 -1
  126. package/dist/types.js +49 -7
  127. package/dist/types.js.map +1 -1
  128. package/package.json +4 -3
  129. package/src/agents/architect.ts +262 -0
  130. package/src/agents/builder.ts +44 -1
  131. package/src/agents/index.ts +6 -0
  132. package/src/agents/lead.ts +183 -19
  133. package/src/agents/planner.ts +161 -0
  134. package/src/agents/runner.ts +367 -0
  135. package/src/agents/types.ts +5 -1
  136. package/src/background/concurrency.ts +116 -0
  137. package/src/background/index.ts +4 -0
  138. package/src/background/manager.ts +478 -0
  139. package/src/background/types.ts +52 -0
  140. package/src/config/index.ts +2 -0
  141. package/src/config/loader.ts +128 -31
  142. package/src/config/presets.ts +21 -0
  143. package/src/config/validation.ts +70 -0
  144. package/src/index.ts +1 -0
  145. package/src/plugin/hooks/keyword.ts +3 -0
  146. package/src/plugin/plugin.ts +323 -42
  147. package/src/skills/frontmatter.ts +25 -0
  148. package/src/skills/index.ts +3 -0
  149. package/src/skills/loader.ts +185 -0
  150. package/src/skills/types.ts +43 -0
  151. package/src/tmux/decision-engine.ts +246 -0
  152. package/src/tmux/executor.ts +286 -0
  153. package/src/tmux/index.ts +11 -0
  154. package/src/tmux/manager.ts +331 -0
  155. package/src/tmux/state-query.ts +74 -0
  156. package/src/tmux/types.ts +106 -0
  157. package/src/tmux/utils.ts +85 -0
  158. package/src/tools/background.ts +145 -0
  159. package/src/tools/delegate.ts +8 -2
  160. package/src/tools/index.ts +9 -0
  161. package/src/types.ts +88 -15
@@ -0,0 +1,286 @@
1
+ import type { PaneAction, WindowState, TmuxConfig } from './types';
2
+ import { runTmuxCommand, runTmuxCommandSync } from './utils';
3
+
4
+ /**
5
+ * Escape a string for safe use in shell commands.
6
+ * Wraps in single quotes and escapes any internal single quotes.
7
+ */
8
+ function shellEscape(str: string): string {
9
+ // Replace single quotes with '\'' (end quote, escaped quote, start quote)
10
+ return `'${str.replace(/'/g, "'\\''")}'`;
11
+ }
12
+
13
+ /** Maximum retries for recursive spawn attempts to prevent infinite loops */
14
+ const MAX_SPAWN_RETRIES = 3;
15
+
16
+ export interface ActionResult {
17
+ success: boolean;
18
+ paneId?: string;
19
+ windowId?: string;
20
+ error?: string;
21
+ }
22
+
23
+ /**
24
+ * State for separate-window mode - tracks the dedicated "Agents" window
25
+ */
26
+ let agentsWindowId: string | undefined;
27
+
28
+ /**
29
+ * Execute a single pane action
30
+ *
31
+ * All agents spawn in a dedicated "Agents" window with tiled grid layout.
32
+ */
33
+ export async function executeAction(
34
+ action: PaneAction,
35
+ ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
36
+ ): Promise<ActionResult> {
37
+ switch (action.type) {
38
+ case 'spawn':
39
+ return spawnInAgentsWindow(action, { serverUrl: ctx.serverUrl });
40
+ case 'close':
41
+ return closePane(action);
42
+ case 'replace':
43
+ return replacePane(action, ctx);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Execute multiple actions in sequence
49
+ */
50
+ export async function executeActions(
51
+ actions: PaneAction[],
52
+ ctx: { config: TmuxConfig; serverUrl: string; windowState: WindowState }
53
+ ): Promise<{
54
+ success: boolean;
55
+ spawnedPaneId?: string;
56
+ results: Array<{ action: PaneAction; result: ActionResult }>;
57
+ }> {
58
+ const results: Array<{ action: PaneAction; result: ActionResult }> = [];
59
+ let spawnedPaneId: string | undefined;
60
+
61
+ for (const action of actions) {
62
+ const result = await executeAction(action, ctx);
63
+ results.push({ action, result });
64
+ if (!result.success) {
65
+ return { success: false, spawnedPaneId, results };
66
+ }
67
+ if (action.type === 'spawn' && result.paneId) {
68
+ spawnedPaneId = result.paneId;
69
+ }
70
+ }
71
+
72
+ return { success: true, spawnedPaneId, results };
73
+ }
74
+
75
+ /**
76
+ * Close an existing pane
77
+ * Uses: tmux kill-pane -t <paneId>
78
+ */
79
+ async function closePane(action: Extract<PaneAction, { type: 'close' }>): Promise<ActionResult> {
80
+ const result = await runTmuxCommand(['kill-pane', '-t', action.paneId]);
81
+ if (!result.success) {
82
+ return { success: false, error: result.output };
83
+ }
84
+ return { success: true };
85
+ }
86
+
87
+ /**
88
+ * Close a pane by its ID
89
+ * Exported for use by TmuxSessionManager when sessions complete
90
+ */
91
+ export async function closePaneById(paneId: string): Promise<ActionResult> {
92
+ const result = await runTmuxCommand(['kill-pane', '-t', paneId]);
93
+ if (!result.success) {
94
+ return { success: false, error: result.output };
95
+ }
96
+ return { success: true };
97
+ }
98
+
99
+ /**
100
+ * Replace an existing pane with a new session
101
+ * Pane self-destructs when command exits (session complete, server died, etc.)
102
+ */
103
+ async function replacePane(
104
+ action: Extract<PaneAction, { type: 'replace' }>,
105
+ ctx: { serverUrl: string }
106
+ ): Promise<ActionResult> {
107
+ // Pane kills itself when opencode attach exits (for any reason)
108
+ // Use shellEscape to prevent shell injection via session IDs
109
+ const escapedServerUrl = shellEscape(ctx.serverUrl);
110
+ const escapedSessionId = shellEscape(action.newSessionId);
111
+ const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
112
+ const result = await runTmuxCommand(['respawn-pane', '-k', '-t', action.paneId, command]);
113
+ if (!result.success) {
114
+ return { success: false, error: result.output };
115
+ }
116
+ return { success: true, paneId: action.paneId };
117
+ }
118
+
119
+ /**
120
+ * Spawn agent in a dedicated "Agents" window with tiled grid layout
121
+ *
122
+ * On first spawn: Creates a new window named "Agents"
123
+ * Subsequent spawns: Splits within that window
124
+ * After each spawn: Applies tiled layout for a clean grid
125
+ *
126
+ * This keeps the main pane untouched while grouping all agent panes together.
127
+ * Tip: Click a pane to select it, then press Ctrl-b z to zoom/unzoom.
128
+ *
129
+ * @param retryCount - Internal counter to prevent infinite recursion (default 0)
130
+ */
131
+ async function spawnInAgentsWindow(
132
+ action: Extract<PaneAction, { type: 'spawn' }>,
133
+ ctx: { serverUrl: string },
134
+ retryCount = 0
135
+ ): Promise<ActionResult> {
136
+ // Prevent infinite recursion if tmux keeps failing
137
+ if (retryCount >= MAX_SPAWN_RETRIES) {
138
+ return {
139
+ success: false,
140
+ error: `Failed to spawn agent pane after ${MAX_SPAWN_RETRIES} attempts`,
141
+ };
142
+ }
143
+
144
+ // Pane kills itself when opencode attach exits (session complete, server died, etc.)
145
+ // Use shellEscape to prevent shell injection via session IDs
146
+ const escapedServerUrl = shellEscape(ctx.serverUrl);
147
+ const escapedSessionId = shellEscape(action.sessionId);
148
+ const command = `opencode attach ${escapedServerUrl} --session ${escapedSessionId}; tmux kill-pane`;
149
+ const layout = 'tiled'; // Always use tiled layout for grid arrangement
150
+
151
+ // Check if we have a cached agents window ID and if it still exists
152
+ if (agentsWindowId) {
153
+ const checkResult = await runTmuxCommand([
154
+ 'list-panes',
155
+ '-t',
156
+ agentsWindowId,
157
+ '-F',
158
+ '#{pane_id}',
159
+ ]);
160
+
161
+ if (!checkResult.success) {
162
+ // Window no longer exists, clear the cache
163
+ agentsWindowId = undefined;
164
+ }
165
+ }
166
+
167
+ // If no agents window exists, create one
168
+ if (!agentsWindowId) {
169
+ const createResult = await runTmuxCommand([
170
+ 'new-window',
171
+ '-d', // Don't switch to new window
172
+ '-P',
173
+ '-F',
174
+ '#{window_id}:#{pane_id}',
175
+ '-n',
176
+ 'Agents',
177
+ command,
178
+ ]);
179
+
180
+ if (!createResult.success) {
181
+ return { success: false, error: createResult.output };
182
+ }
183
+
184
+ // Parse window_id:pane_id from output
185
+ const output = createResult.output?.trim() || '';
186
+ const [windowId, paneId] = output.split(':');
187
+ agentsWindowId = windowId;
188
+
189
+ // Apply initial layout (useful when more panes are added later)
190
+ if (agentsWindowId && layout) {
191
+ await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
192
+ }
193
+
194
+ return { success: true, paneId, windowId };
195
+ }
196
+
197
+ // Agents window exists - split within it
198
+ // First, get the first pane in the agents window to use as split target
199
+ const listResult = await runTmuxCommand([
200
+ 'list-panes',
201
+ '-t',
202
+ agentsWindowId,
203
+ '-F',
204
+ '#{pane_id}',
205
+ ]);
206
+
207
+ if (!listResult.success || !listResult.output) {
208
+ // Fallback: create new window (with retry counter)
209
+ agentsWindowId = undefined;
210
+ return spawnInAgentsWindow(action, ctx, retryCount + 1);
211
+ }
212
+
213
+ const targetPaneId = listResult.output.split('\n')[0]?.trim();
214
+ if (!targetPaneId) {
215
+ // Fallback: create new window (with retry counter)
216
+ agentsWindowId = undefined;
217
+ return spawnInAgentsWindow(action, ctx, retryCount + 1);
218
+ }
219
+
220
+ // Split within the agents window
221
+ const splitResult = await runTmuxCommand([
222
+ 'split-window',
223
+ action.splitDirection,
224
+ '-t',
225
+ targetPaneId,
226
+ '-P',
227
+ '-F',
228
+ '#{pane_id}',
229
+ command,
230
+ ]);
231
+
232
+ if (!splitResult.success) {
233
+ return { success: false, error: splitResult.output };
234
+ }
235
+
236
+ const paneId = splitResult.output?.trim();
237
+
238
+ // Apply the configured layout to the agents window (e.g., tiled for grid)
239
+ if (agentsWindowId && layout) {
240
+ await runTmuxCommand(['select-layout', '-t', agentsWindowId, layout]);
241
+ }
242
+
243
+ return {
244
+ success: true,
245
+ paneId: paneId || undefined,
246
+ windowId: agentsWindowId,
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Reset the agents window state (for cleanup)
252
+ */
253
+ export function resetAgentsWindow(): void {
254
+ agentsWindowId = undefined;
255
+ }
256
+
257
+ /**
258
+ * Close the agents window if it exists
259
+ * This kills the entire window, which closes all panes within it
260
+ */
261
+ export async function closeAgentsWindow(): Promise<void> {
262
+ if (!agentsWindowId) return;
263
+
264
+ // Kill the entire window (closes all panes within it)
265
+ await runTmuxCommand(['kill-window', '-t', agentsWindowId]);
266
+ agentsWindowId = undefined;
267
+ }
268
+
269
+ /**
270
+ * Synchronously close the agents window (for shutdown)
271
+ * Uses spawnSync to ensure it completes before process exit
272
+ */
273
+ export function closeAgentsWindowSync(): void {
274
+ if (!agentsWindowId) return;
275
+
276
+ // Kill the entire window synchronously
277
+ runTmuxCommandSync(['kill-window', '-t', agentsWindowId]);
278
+ agentsWindowId = undefined;
279
+ }
280
+
281
+ /**
282
+ * Get the current agents window ID (for testing/debugging)
283
+ */
284
+ export function getAgentsWindowId(): string | undefined {
285
+ return agentsWindowId;
286
+ }
@@ -0,0 +1,11 @@
1
+ export * from './types';
2
+ export * from './utils';
3
+ export { queryWindowState } from './state-query';
4
+ export { decideSpawnActions, calculateCapacity } from './decision-engine';
5
+ export {
6
+ executeAction,
7
+ executeActions,
8
+ closeAgentsWindow,
9
+ closeAgentsWindowSync,
10
+ } from './executor';
11
+ export { TmuxSessionManager } from './manager';
@@ -0,0 +1,331 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ import type {
3
+ PaneAction,
4
+ TmuxConfig,
5
+ TmuxPaneInfo,
6
+ TrackedSession,
7
+ WindowState,
8
+ SessionMapping,
9
+ } from './types';
10
+ import { POLL_INTERVAL_MS, SESSION_MISSING_GRACE_MS, SESSION_TIMEOUT_MS } from './types';
11
+ import { getCurrentPaneId, getTmuxPath, isInsideTmux } from './utils';
12
+ import { queryWindowState } from './state-query';
13
+ import { decideSpawnActions } from './decision-engine';
14
+ import {
15
+ executeActions,
16
+ closeAgentsWindow,
17
+ closeAgentsWindowSync,
18
+ closePaneById,
19
+ } from './executor';
20
+
21
+ /**
22
+ * Check if the OpenCode server is running by hitting the health endpoint
23
+ */
24
+ async function isServerRunning(serverUrl: string): Promise<boolean> {
25
+ try {
26
+ const healthUrl = new URL('/health', serverUrl).toString();
27
+ const response = await fetch(healthUrl, {
28
+ signal: AbortSignal.timeout(2000),
29
+ });
30
+ return response.ok;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export interface TmuxSessionManagerCallbacks {
37
+ onLog?: (message: string) => void;
38
+ }
39
+
40
+ /**
41
+ * Manages tmux panes for background agents.
42
+ *
43
+ * Architecture:
44
+ * 1. QUERY: Get actual tmux pane state (source of truth)
45
+ * 2. DECIDE: Pure function determines actions based on state
46
+ * 3. EXECUTE: Execute actions with verification
47
+ * 4. UPDATE: Update internal cache only after tmux confirms success
48
+ */
49
+ export class TmuxSessionManager {
50
+ private sessions = new Map<string, TrackedSession>();
51
+ private pendingSessions = new Set<string>();
52
+ private pollInterval?: ReturnType<typeof setInterval>;
53
+ private sourcePaneId: string | undefined;
54
+
55
+ constructor(
56
+ private ctx: PluginInput,
57
+ private config: TmuxConfig,
58
+ private callbacks?: TmuxSessionManagerCallbacks
59
+ ) {
60
+ this.sourcePaneId = getCurrentPaneId();
61
+ }
62
+
63
+ /**
64
+ * Check if tmux integration is enabled and available
65
+ */
66
+ isEnabled(): boolean {
67
+ return this.config.enabled && isInsideTmux();
68
+ }
69
+
70
+ /**
71
+ * Handle a new background session being created
72
+ * This is called by BackgroundManager when a background task starts
73
+ */
74
+ async onSessionCreated(event: {
75
+ sessionId: string;
76
+ parentId: string;
77
+ title: string;
78
+ }): Promise<void> {
79
+ if (!this.isEnabled()) return;
80
+ if (this.pendingSessions.has(event.sessionId) || this.sessions.has(event.sessionId)) return;
81
+ this.pendingSessions.add(event.sessionId);
82
+
83
+ try {
84
+ const tmuxPath = await getTmuxPath();
85
+ if (!tmuxPath) {
86
+ this.log('tmux binary not found.');
87
+ return;
88
+ }
89
+
90
+ if (!this.sourcePaneId) {
91
+ this.sourcePaneId = getCurrentPaneId();
92
+ }
93
+
94
+ if (!this.sourcePaneId) {
95
+ this.log('Unable to determine source pane id.');
96
+ return;
97
+ }
98
+
99
+ const state = await queryWindowState(this.sourcePaneId);
100
+ if (!state) {
101
+ this.log('Failed to query tmux window state.');
102
+ return;
103
+ }
104
+
105
+ const decision = decideSpawnActions(
106
+ state,
107
+ event.sessionId,
108
+ event.title,
109
+ {
110
+ mainPaneMinWidth: this.config.mainPaneMinWidth,
111
+ agentPaneMinWidth: this.config.agentPaneMinWidth,
112
+ maxPanes: this.config.maxPanes,
113
+ },
114
+ this.getSessionMappings()
115
+ );
116
+
117
+ if (!decision.canSpawn) {
118
+ if (decision.reason) {
119
+ this.log(`Cannot spawn pane: ${decision.reason}`);
120
+ }
121
+ return;
122
+ }
123
+
124
+ const serverUrl = this.getServerUrl();
125
+ if (!serverUrl) {
126
+ this.log('Unable to determine OpenCode server URL.');
127
+ return;
128
+ }
129
+
130
+ // Check if server is actually running before attempting to spawn
131
+ const serverRunning = await isServerRunning(serverUrl);
132
+ if (!serverRunning) {
133
+ this.log(
134
+ `Server not running at ${serverUrl}. Start opencode with --port flag to enable tmux integration.`
135
+ );
136
+ return;
137
+ }
138
+
139
+ const result = await executeActions(decision.actions, {
140
+ config: this.config,
141
+ serverUrl,
142
+ windowState: state,
143
+ });
144
+
145
+ if (!result.success) {
146
+ this.log('Failed to execute tmux actions.');
147
+ return;
148
+ }
149
+
150
+ this.applyActionResults(decision.actions, result.spawnedPaneId);
151
+ if (this.sessions.size > 0) {
152
+ this.startPolling();
153
+ }
154
+ } finally {
155
+ this.pendingSessions.delete(event.sessionId);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Handle a session being deleted
161
+ *
162
+ * Explicitly kills the pane when a background session completes.
163
+ * We can't rely on `opencode attach` exiting because it's an interactive
164
+ * terminal that keeps running even after the session goes idle.
165
+ */
166
+ async onSessionDeleted(event: { sessionId: string }): Promise<void> {
167
+ if (!this.isEnabled()) return;
168
+
169
+ // Find the session in our mappings
170
+ const session = this.sessions.get(event.sessionId);
171
+ if (!session) return;
172
+
173
+ // Kill the pane explicitly - opencode attach won't exit on its own
174
+ const result = await closePaneById(session.paneId);
175
+ if (!result.success) {
176
+ this.log(`Failed to close pane ${session.paneId}: ${result.error}`);
177
+ }
178
+
179
+ // Update internal state
180
+ this.sessions.delete(event.sessionId);
181
+
182
+ if (this.sessions.size === 0) {
183
+ this.stopPolling();
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Clean up all panes on shutdown
189
+ *
190
+ * Kills the entire "Agents" window, which closes all agent panes at once.
191
+ */
192
+ async cleanup(): Promise<void> {
193
+ this.stopPolling();
194
+
195
+ // Kill the entire agents window - this closes all panes at once
196
+ await closeAgentsWindow();
197
+ this.sessions.clear();
198
+ }
199
+
200
+ /**
201
+ * Synchronous cleanup for shutdown (ensures completion before exit)
202
+ *
203
+ * Uses spawnSync to guarantee the tmux commands complete before the
204
+ * process exits, which is necessary for signal handlers.
205
+ */
206
+ cleanupSync(): void {
207
+ this.stopPolling();
208
+
209
+ // Kill the entire agents window synchronously
210
+ closeAgentsWindowSync();
211
+ this.sessions.clear();
212
+ }
213
+
214
+ /**
215
+ * Start polling for session status
216
+ */
217
+ private startPolling(): void {
218
+ if (this.pollInterval) return;
219
+ this.pollInterval = setInterval(() => {
220
+ void this.pollSessions();
221
+ }, POLL_INTERVAL_MS);
222
+ }
223
+
224
+ /**
225
+ * Stop polling
226
+ */
227
+ private stopPolling(): void {
228
+ if (!this.pollInterval) return;
229
+ clearInterval(this.pollInterval);
230
+ this.pollInterval = undefined;
231
+ }
232
+
233
+ /**
234
+ * Poll active sessions for status changes
235
+ */
236
+ private async pollSessions(): Promise<void> {
237
+ if (!this.isEnabled()) return;
238
+ if (!this.sourcePaneId) return;
239
+
240
+ const state = await queryWindowState(this.sourcePaneId);
241
+ if (!state) return;
242
+
243
+ const now = Date.now();
244
+ for (const session of this.sessions.values()) {
245
+ const pane = findPane(state, session.paneId);
246
+ if (pane) {
247
+ session.lastSeenAt = new Date();
248
+ continue;
249
+ }
250
+
251
+ const missingFor = now - session.lastSeenAt.getTime();
252
+ if (missingFor > SESSION_MISSING_GRACE_MS) {
253
+ this.sessions.delete(session.sessionId);
254
+ continue;
255
+ }
256
+
257
+ const age = now - session.createdAt.getTime();
258
+ if (age > SESSION_TIMEOUT_MS) {
259
+ this.sessions.delete(session.sessionId);
260
+ }
261
+ }
262
+
263
+ if (this.sessions.size === 0) {
264
+ this.stopPolling();
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Get session mappings for decision engine
270
+ */
271
+ private getSessionMappings(): SessionMapping[] {
272
+ return Array.from(this.sessions.values()).map((session) => ({
273
+ sessionId: session.sessionId,
274
+ paneId: session.paneId,
275
+ createdAt: session.createdAt,
276
+ }));
277
+ }
278
+
279
+ private getServerUrl(): string | undefined {
280
+ const ctx = this.ctx as unknown as {
281
+ serverUrl?: string | URL;
282
+ baseUrl?: string | URL;
283
+ client?: { baseUrl?: string | URL };
284
+ };
285
+ const serverUrl = ctx.serverUrl ?? ctx.baseUrl ?? ctx.client?.baseUrl;
286
+ if (!serverUrl) return undefined;
287
+ return typeof serverUrl === 'string' ? serverUrl : serverUrl.toString();
288
+ }
289
+
290
+ private applyActionResults(actions: PaneAction[], spawnedPaneId: string | undefined): void {
291
+ const now = new Date();
292
+ for (const action of actions) {
293
+ switch (action.type) {
294
+ case 'close':
295
+ this.sessions.delete(action.sessionId);
296
+ break;
297
+ case 'replace':
298
+ this.sessions.delete(action.oldSessionId);
299
+ this.sessions.set(action.newSessionId, {
300
+ sessionId: action.newSessionId,
301
+ paneId: action.paneId,
302
+ description: action.description,
303
+ createdAt: now,
304
+ lastSeenAt: now,
305
+ });
306
+ break;
307
+ case 'spawn': {
308
+ const paneId = spawnedPaneId;
309
+ if (!paneId) break;
310
+ this.sessions.set(action.sessionId, {
311
+ sessionId: action.sessionId,
312
+ paneId,
313
+ description: action.description,
314
+ createdAt: now,
315
+ lastSeenAt: now,
316
+ });
317
+ break;
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ private log(message: string): void {
324
+ this.callbacks?.onLog?.(`[tmux] ${message}`);
325
+ }
326
+ }
327
+
328
+ function findPane(state: WindowState, paneId: string): TmuxPaneInfo | undefined {
329
+ if (state.mainPane?.paneId === paneId) return state.mainPane;
330
+ return state.agentPanes.find((pane) => pane.paneId === paneId);
331
+ }
@@ -0,0 +1,74 @@
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 windowWidthValue = Number(parts[parts.length - 2]);
36
+ const windowHeightValue = Number(parts[parts.length - 1]);
37
+ const isActiveValue = parts[parts.length - 3] === '1';
38
+ const title = parts.slice(5, parts.length - 3).join(',');
39
+
40
+ const width = Number(parts[1]);
41
+ const height = Number(parts[2]);
42
+ const left = Number(parts[3]);
43
+ const top = Number(parts[4]);
44
+ if ([width, height, left, top].some((value) => Number.isNaN(value))) {
45
+ continue;
46
+ }
47
+
48
+ const paneInfo: TmuxPaneInfo = {
49
+ paneId: parts[0],
50
+ width,
51
+ height,
52
+ left,
53
+ top,
54
+ title,
55
+ isActive: isActiveValue,
56
+ };
57
+
58
+ if (!Number.isNaN(windowWidthValue)) windowWidth = windowWidthValue;
59
+ if (!Number.isNaN(windowHeightValue)) windowHeight = windowHeightValue;
60
+ panes.push(paneInfo);
61
+ }
62
+
63
+ if (panes.length === 0) return null;
64
+
65
+ const mainPane = panes.find((pane) => pane.paneId === sourcePaneId) ?? null;
66
+ const agentPanes = panes.filter((pane) => pane.paneId !== sourcePaneId);
67
+
68
+ return {
69
+ windowWidth,
70
+ windowHeight,
71
+ mainPane,
72
+ agentPanes,
73
+ };
74
+ }