opencode-agent-tmux 1.2.6 → 1.3.0

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.
@@ -268,7 +268,7 @@ function hasTmux() {
268
268
  }
269
269
  async function main() {
270
270
  const args = argv.slice(2);
271
- const isCliCommand = args.length > 0 && (["auth", "config", "plugins", "update", "completion", "stats"].includes(args[0]) || ["--version", "-v", "--help", "-h"].includes(args[0]) || args.includes("--print-logs") || args.includes("--log-level"));
271
+ const isCliCommand = args.length > 0 && (["auth", "config", "plugins", "update", "completion", "stats"].includes(args[0]) || ["--version", "-v", "--help", "-h"].includes(args[0]));
272
272
  if (isCliCommand) {
273
273
  const opencodeBin2 = findOpencodeBin();
274
274
  if (!opencodeBin2) {
package/dist/index.d.ts CHANGED
@@ -36,14 +36,26 @@ declare const TmuxConfigSchema: z.ZodObject<{
36
36
  enabled: z.ZodDefault<z.ZodBoolean>;
37
37
  layout: z.ZodDefault<z.ZodEnum<["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"]>>;
38
38
  main_pane_size: z.ZodDefault<z.ZodNumber>;
39
+ spawn_delay_ms: z.ZodDefault<z.ZodNumber>;
40
+ max_retry_attempts: z.ZodDefault<z.ZodNumber>;
41
+ layout_debounce_ms: z.ZodDefault<z.ZodNumber>;
42
+ max_agents_per_column: z.ZodDefault<z.ZodNumber>;
39
43
  }, "strip", z.ZodTypeAny, {
40
44
  enabled: boolean;
41
45
  layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical";
42
46
  main_pane_size: number;
47
+ spawn_delay_ms: number;
48
+ max_retry_attempts: number;
49
+ layout_debounce_ms: number;
50
+ max_agents_per_column: number;
43
51
  }, {
44
52
  enabled?: boolean | undefined;
45
53
  layout?: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" | undefined;
46
54
  main_pane_size?: number | undefined;
55
+ spawn_delay_ms?: number | undefined;
56
+ max_retry_attempts?: number | undefined;
57
+ layout_debounce_ms?: number | undefined;
58
+ max_agents_per_column?: number | undefined;
47
59
  }>;
48
60
  type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
49
61
  declare const PluginConfigSchema: z.ZodObject<{
@@ -52,18 +64,30 @@ declare const PluginConfigSchema: z.ZodObject<{
52
64
  layout: z.ZodDefault<z.ZodEnum<["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"]>>;
53
65
  main_pane_size: z.ZodDefault<z.ZodNumber>;
54
66
  auto_close: z.ZodDefault<z.ZodBoolean>;
67
+ spawn_delay_ms: z.ZodDefault<z.ZodNumber>;
68
+ max_retry_attempts: z.ZodDefault<z.ZodNumber>;
69
+ layout_debounce_ms: z.ZodDefault<z.ZodNumber>;
70
+ max_agents_per_column: z.ZodDefault<z.ZodNumber>;
55
71
  }, "strip", z.ZodTypeAny, {
56
72
  enabled: boolean;
57
73
  port: number;
58
74
  layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical";
59
75
  main_pane_size: number;
60
76
  auto_close: boolean;
77
+ spawn_delay_ms: number;
78
+ max_retry_attempts: number;
79
+ layout_debounce_ms: number;
80
+ max_agents_per_column: number;
61
81
  }, {
62
82
  enabled?: boolean | undefined;
63
83
  port?: number | undefined;
64
84
  layout?: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" | undefined;
65
85
  main_pane_size?: number | undefined;
66
86
  auto_close?: boolean | undefined;
87
+ spawn_delay_ms?: number | undefined;
88
+ max_retry_attempts?: number | undefined;
89
+ layout_debounce_ms?: number | undefined;
90
+ max_agents_per_column?: number | undefined;
67
91
  }>;
68
92
  type PluginConfig = z.infer<typeof PluginConfigSchema>;
69
93
 
package/dist/index.js CHANGED
@@ -14,14 +14,22 @@ var TmuxLayoutSchema = z.enum([
14
14
  var TmuxConfigSchema = z.object({
15
15
  enabled: z.boolean().default(true),
16
16
  layout: TmuxLayoutSchema.default("main-vertical"),
17
- main_pane_size: z.number().min(20).max(80).default(60)
17
+ main_pane_size: z.number().min(20).max(80).default(60),
18
+ spawn_delay_ms: z.number().min(50).max(2e3).default(300),
19
+ max_retry_attempts: z.number().min(0).max(5).default(2),
20
+ layout_debounce_ms: z.number().min(50).max(1e3).default(150),
21
+ max_agents_per_column: z.number().min(1).max(10).default(3)
18
22
  });
19
23
  var PluginConfigSchema = z.object({
20
24
  enabled: z.boolean().default(true),
21
25
  port: z.number().default(4096),
22
26
  layout: TmuxLayoutSchema.default("main-vertical"),
23
27
  main_pane_size: z.number().min(20).max(80).default(60),
24
- auto_close: z.boolean().default(true)
28
+ auto_close: z.boolean().default(true),
29
+ spawn_delay_ms: z.number().min(50).max(2e3).default(300),
30
+ max_retry_attempts: z.number().min(0).max(5).default(2),
31
+ layout_debounce_ms: z.number().min(50).max(1e3).default(150),
32
+ max_agents_per_column: z.number().min(1).max(10).default(3)
25
33
  });
26
34
  var POLL_INTERVAL_MS = 2e3;
27
35
  var SESSION_TIMEOUT_MS = 10 * 60 * 1e3;
@@ -42,8 +50,359 @@ function log(message, data) {
42
50
  }
43
51
  }
44
52
 
53
+ // src/spawn-queue.ts
54
+ var BASE_BACKOFF_MS = 250;
55
+ var DEFAULT_STALE_THRESHOLD_MS = 3e4;
56
+ var SpawnQueue = class {
57
+ queue = [];
58
+ spawnFn;
59
+ spawnDelayMs;
60
+ maxRetries;
61
+ staleThresholdMs;
62
+ onQueueUpdate;
63
+ onQueueDrained;
64
+ logFn;
65
+ isProcessing = false;
66
+ hasItemInFlight = false;
67
+ isShutdown = false;
68
+ /**
69
+ * Map from sessionId to a pending promise for coalescing duplicate enqueues.
70
+ * Contains both queued and in-flight items.
71
+ */
72
+ pendingPromises = /* @__PURE__ */ new Map();
73
+ constructor(options) {
74
+ this.spawnFn = options.spawnFn;
75
+ this.spawnDelayMs = options.spawnDelayMs ?? 300;
76
+ this.maxRetries = options.maxRetries ?? 2;
77
+ this.staleThresholdMs = options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
78
+ this.onQueueUpdate = options.onQueueUpdate;
79
+ this.onQueueDrained = options.onQueueDrained;
80
+ this.logFn = options.logFn ?? log;
81
+ this.logFn("[spawn-queue] initialized", {
82
+ spawnDelayMs: this.spawnDelayMs,
83
+ maxRetries: this.maxRetries,
84
+ staleThresholdMs: this.staleThresholdMs
85
+ });
86
+ }
87
+ enqueue(item) {
88
+ if (this.isShutdown) {
89
+ this.logFn("[spawn-queue] enqueue rejected (shutdown)", { sessionId: item.sessionId });
90
+ return Promise.resolve({ success: false });
91
+ }
92
+ const existing = this.pendingPromises.get(item.sessionId);
93
+ if (existing) {
94
+ this.logFn("[spawn-queue] duplicate enqueue coalesced", {
95
+ sessionId: item.sessionId,
96
+ queueDepth: this.queue.length,
97
+ pendingCount: this.getPendingCount()
98
+ });
99
+ return existing.promise;
100
+ }
101
+ let resolveOuter;
102
+ const promise = new Promise((resolve) => {
103
+ resolveOuter = resolve;
104
+ });
105
+ this.pendingPromises.set(item.sessionId, { promise, resolve: resolveOuter });
106
+ this.queue.push({
107
+ sessionId: item.sessionId,
108
+ title: item.title,
109
+ enqueuedAt: Date.now(),
110
+ resolve: resolveOuter
111
+ });
112
+ this.logFn("[spawn-queue] enqueued", {
113
+ sessionId: item.sessionId,
114
+ queueDepth: this.queue.length,
115
+ pendingCount: this.getPendingCount()
116
+ });
117
+ this.notifyQueueUpdate();
118
+ this.processQueue();
119
+ return promise;
120
+ }
121
+ getPendingCount() {
122
+ return this.queue.length + (this.hasItemInFlight ? 1 : 0);
123
+ }
124
+ /**
125
+ * Shutdown the queue: stop processing new items and resolve all pending items as failed.
126
+ */
127
+ shutdown() {
128
+ if (this.isShutdown) {
129
+ return;
130
+ }
131
+ this.isShutdown = true;
132
+ this.logFn("[spawn-queue] shutdown initiated", {
133
+ queuedItems: this.queue.length,
134
+ hasInFlight: this.hasItemInFlight
135
+ });
136
+ while (this.queue.length > 0) {
137
+ const item = this.queue.shift();
138
+ this.logFn("[spawn-queue] shutdown - resolving queued item as failed", {
139
+ sessionId: item.sessionId
140
+ });
141
+ item.resolve({ success: false });
142
+ this.pendingPromises.delete(item.sessionId);
143
+ }
144
+ this.notifyQueueUpdate();
145
+ this.logFn("[spawn-queue] shutdown complete");
146
+ }
147
+ /**
148
+ * Alias for shutdown() for interface consistency.
149
+ */
150
+ dispose() {
151
+ this.shutdown();
152
+ }
153
+ notifyQueueUpdate() {
154
+ this.onQueueUpdate?.(this.queue.length);
155
+ }
156
+ async processQueue() {
157
+ if (this.isProcessing || this.isShutdown) {
158
+ return;
159
+ }
160
+ this.isProcessing = true;
161
+ while (this.queue.length > 0 && !this.isShutdown) {
162
+ const item = this.queue.shift();
163
+ this.hasItemInFlight = true;
164
+ this.notifyQueueUpdate();
165
+ const waitTimeMs = Date.now() - item.enqueuedAt;
166
+ if (waitTimeMs > this.staleThresholdMs) {
167
+ this.logFn("[spawn-queue] stale item skipped", {
168
+ sessionId: item.sessionId,
169
+ waitTimeMs,
170
+ thresholdMs: this.staleThresholdMs
171
+ });
172
+ item.resolve({ success: false });
173
+ this.pendingPromises.delete(item.sessionId);
174
+ this.hasItemInFlight = false;
175
+ continue;
176
+ }
177
+ this.logFn("[spawn-queue] processing start", {
178
+ sessionId: item.sessionId,
179
+ title: item.title
180
+ });
181
+ const result = await this.processItem(item);
182
+ item.resolve(result);
183
+ this.pendingPromises.delete(item.sessionId);
184
+ this.hasItemInFlight = false;
185
+ if (this.queue.length > 0 && !this.isShutdown) {
186
+ await this.delay(this.spawnDelayMs);
187
+ }
188
+ }
189
+ this.notifyQueueUpdate();
190
+ this.notifyQueueDrained();
191
+ this.isProcessing = false;
192
+ }
193
+ notifyQueueDrained() {
194
+ if (this.queue.length === 0 && !this.hasItemInFlight) {
195
+ this.logFn("[spawn-queue] queue drained");
196
+ this.onQueueDrained?.();
197
+ }
198
+ }
199
+ async processItem(item) {
200
+ let retryCount = 0;
201
+ let lastResult = { success: false };
202
+ while (retryCount <= this.maxRetries && !this.isShutdown) {
203
+ const request = {
204
+ sessionId: item.sessionId,
205
+ title: item.title,
206
+ timestamp: item.enqueuedAt,
207
+ retryCount
208
+ };
209
+ this.logFn("[spawn-queue] spawn attempt", {
210
+ sessionId: item.sessionId,
211
+ attempt: retryCount + 1,
212
+ maxAttempts: this.maxRetries + 1
213
+ });
214
+ try {
215
+ lastResult = await this.spawnFn(request);
216
+ } catch {
217
+ lastResult = { success: false };
218
+ }
219
+ if (lastResult.success) {
220
+ this.logFn("[spawn-queue] success", {
221
+ sessionId: item.sessionId,
222
+ paneId: lastResult.paneId,
223
+ attempts: retryCount + 1
224
+ });
225
+ return lastResult;
226
+ }
227
+ retryCount++;
228
+ if (retryCount <= this.maxRetries && !this.isShutdown) {
229
+ const backoffMs = BASE_BACKOFF_MS * Math.pow(2, retryCount - 1);
230
+ this.logFn("[spawn-queue] retry wait", {
231
+ sessionId: item.sessionId,
232
+ backoffMs,
233
+ nextAttempt: retryCount + 1
234
+ });
235
+ await this.delay(backoffMs);
236
+ }
237
+ }
238
+ this.logFn("[spawn-queue] final failure", {
239
+ sessionId: item.sessionId,
240
+ attempts: retryCount
241
+ });
242
+ return lastResult;
243
+ }
244
+ delay(ms) {
245
+ return new Promise((resolve) => setTimeout(resolve, ms));
246
+ }
247
+ };
248
+
45
249
  // src/utils/tmux.ts
46
250
  import { spawn } from "child_process";
251
+
252
+ // src/layout.ts
253
+ function computeColumnCount(totalAgents, maxAgentsPerColumn) {
254
+ if (totalAgents <= 0) {
255
+ return 0;
256
+ }
257
+ if (maxAgentsPerColumn <= 0) {
258
+ throw new Error("maxAgentsPerColumn must be positive");
259
+ }
260
+ return Math.ceil(totalAgents / maxAgentsPerColumn);
261
+ }
262
+ function distributeAgentsRoundRobin(agentCount, maxAgentsPerColumn) {
263
+ const numColumns = computeColumnCount(agentCount, maxAgentsPerColumn);
264
+ if (numColumns === 0) {
265
+ return { numColumns: 0, columnAssignments: [] };
266
+ }
267
+ const columnAssignments = [];
268
+ for (let i = 0; i < agentCount; i++) {
269
+ columnAssignments.push(i % numColumns);
270
+ }
271
+ return { numColumns, columnAssignments };
272
+ }
273
+ function groupAgentsByColumn(agentIds, maxAgentsPerColumn) {
274
+ const { numColumns, columnAssignments } = distributeAgentsRoundRobin(
275
+ agentIds.length,
276
+ maxAgentsPerColumn
277
+ );
278
+ if (numColumns === 0) {
279
+ return [];
280
+ }
281
+ const columns = Array.from({ length: numColumns }, () => []);
282
+ for (let i = 0; i < agentIds.length; i++) {
283
+ const columnIndex = columnAssignments[i];
284
+ columns[columnIndex].push(agentIds[i]);
285
+ }
286
+ return columns;
287
+ }
288
+ function mainPanePercentForColumns(numColumns) {
289
+ if (numColumns <= 1) return 60;
290
+ if (numColumns === 2) return 45;
291
+ return 30;
292
+ }
293
+ function layoutChecksum(layout) {
294
+ let csum = 0;
295
+ for (let i = 0; i < layout.length; i++) {
296
+ csum = (csum >> 1) + ((csum & 1) << 15);
297
+ csum = csum + layout.charCodeAt(i) & 65535;
298
+ }
299
+ return csum;
300
+ }
301
+ function dumpLayoutCell(cell) {
302
+ const base = cell.wpId !== void 0 ? `${cell.sx}x${cell.sy},${cell.xoff},${cell.yoff},${cell.wpId}` : `${cell.sx}x${cell.sy},${cell.xoff},${cell.yoff}`;
303
+ if (cell.type === "WINDOWPANE") {
304
+ return base;
305
+ }
306
+ const children = cell.children ?? [];
307
+ const open = cell.type === "LEFTRIGHT" ? "{" : "[";
308
+ const close = cell.type === "LEFTRIGHT" ? "}" : "]";
309
+ return `${base}${open}${children.map(dumpLayoutCell).join(",")}${close}`;
310
+ }
311
+ function splitSizes(total, count) {
312
+ if (count <= 0) return [];
313
+ const separators = count - 1;
314
+ const available = Math.max(0, total - separators);
315
+ const base = Math.floor(available / count);
316
+ const remainder = available - base * count;
317
+ const out = [];
318
+ for (let i = 0; i < count; i++) {
319
+ out.push(base + (i < remainder ? 1 : 0));
320
+ }
321
+ return out;
322
+ }
323
+ function buildMainVerticalMultiColumnLayoutString(params) {
324
+ const { windowWidth, windowHeight, mainPaneWpId, columns, mainPanePercent } = params;
325
+ const numColumns = columns.length;
326
+ if (numColumns <= 0) {
327
+ throw new Error("columns must be non-empty");
328
+ }
329
+ const clampedPercent = Math.max(30, Math.min(80, mainPanePercent));
330
+ const desiredMainWidth = Math.floor(windowWidth * clampedPercent / 100);
331
+ const mainWidth = Math.max(0, Math.min(windowWidth - 2, desiredMainWidth));
332
+ const rightWidth = Math.max(0, windowWidth - mainWidth - 1);
333
+ const mainCell = {
334
+ type: "WINDOWPANE",
335
+ sx: mainWidth,
336
+ sy: windowHeight,
337
+ xoff: 0,
338
+ yoff: 0,
339
+ wpId: mainPaneWpId
340
+ };
341
+ const rightXoff = mainWidth + 1;
342
+ const colWidths = splitSizes(rightWidth, numColumns);
343
+ const columnCells = [];
344
+ let xoff = rightXoff;
345
+ for (let c = 0; c < numColumns; c++) {
346
+ const colPaneIds = columns[c];
347
+ const colWidth = colWidths[c] ?? 0;
348
+ if (colPaneIds.length === 1) {
349
+ columnCells.push({
350
+ type: "WINDOWPANE",
351
+ sx: colWidth,
352
+ sy: windowHeight,
353
+ xoff,
354
+ yoff: 0,
355
+ wpId: colPaneIds[0]
356
+ });
357
+ } else {
358
+ const rowHeights = splitSizes(windowHeight, colPaneIds.length);
359
+ const rows = [];
360
+ let yoff = 0;
361
+ for (let r = 0; r < colPaneIds.length; r++) {
362
+ rows.push({
363
+ type: "WINDOWPANE",
364
+ sx: colWidth,
365
+ sy: rowHeights[r] ?? 0,
366
+ xoff,
367
+ yoff,
368
+ wpId: colPaneIds[r]
369
+ });
370
+ yoff += (rowHeights[r] ?? 0) + 1;
371
+ }
372
+ columnCells.push({
373
+ type: "TOPBOTTOM",
374
+ sx: colWidth,
375
+ sy: windowHeight,
376
+ xoff,
377
+ yoff: 0,
378
+ children: rows
379
+ });
380
+ }
381
+ xoff += colWidth + 1;
382
+ }
383
+ const rightCell = numColumns === 1 ? columnCells[0] : {
384
+ type: "LEFTRIGHT",
385
+ sx: rightWidth,
386
+ sy: windowHeight,
387
+ xoff: rightXoff,
388
+ yoff: 0,
389
+ children: columnCells
390
+ };
391
+ const root = {
392
+ type: "LEFTRIGHT",
393
+ sx: windowWidth,
394
+ sy: windowHeight,
395
+ xoff: 0,
396
+ yoff: 0,
397
+ children: [mainCell, rightCell]
398
+ };
399
+ const layout = dumpLayoutCell(root);
400
+ const checksum = layoutChecksum(layout);
401
+ return `${checksum.toString(16).padStart(4, "0")},${layout}`;
402
+ }
403
+
404
+ // src/utils/tmux.ts
405
+ var BASE_BACKOFF_MS2 = 250;
47
406
  var tmuxPath = null;
48
407
  var tmuxChecked = false;
49
408
  var storedConfig = null;
@@ -115,7 +474,7 @@ async function findTmuxPath() {
115
474
  const isWindows = process.platform === "win32";
116
475
  const cmd = isWindows ? "where" : "which";
117
476
  try {
118
- const result = await spawnAsync([cmd, "tmux"]);
477
+ const result = await spawnAsyncFn([cmd, "tmux"]);
119
478
  if (result.exitCode !== 0) {
120
479
  log("[tmux] findTmuxPath: 'which tmux' failed", {
121
480
  exitCode: result.exitCode
@@ -127,7 +486,7 @@ async function findTmuxPath() {
127
486
  log("[tmux] findTmuxPath: no path in output");
128
487
  return null;
129
488
  }
130
- const verifyResult = await spawnAsync([path3, "-V"]);
489
+ const verifyResult = await spawnAsyncFn([path3, "-V"]);
131
490
  if (verifyResult.exitCode !== 0) {
132
491
  log("[tmux] findTmuxPath: tmux -V failed", {
133
492
  path: path3,
@@ -156,22 +515,173 @@ function isInsideTmux() {
156
515
  }
157
516
  async function applyLayout(tmux, layout, mainPaneSize) {
158
517
  try {
159
- await spawnAsync([tmux, "select-layout", layout]);
518
+ await spawnAsyncFn([tmux, "select-layout", layout]);
160
519
  if (layout === "main-horizontal" || layout === "main-vertical") {
161
520
  const sizeOption = layout === "main-horizontal" ? "main-pane-height" : "main-pane-width";
162
- await spawnAsync([
521
+ await spawnAsyncFn([
163
522
  tmux,
164
523
  "set-window-option",
165
524
  sizeOption,
166
525
  `${mainPaneSize}%`
167
526
  ]);
168
- await spawnAsync([tmux, "select-layout", layout]);
527
+ await spawnAsyncFn([tmux, "select-layout", layout]);
169
528
  }
170
529
  log("[tmux] applyLayout: applied", { layout, mainPaneSize });
171
530
  } catch (err) {
172
531
  log("[tmux] applyLayout: exception", { error: String(err) });
173
532
  }
174
533
  }
534
+ async function getCurrentPaneId(tmux) {
535
+ const result = await spawnAsyncFn([tmux, "display-message", "-p", "#{pane_id}"]);
536
+ const paneId = result.stdout.trim();
537
+ return paneId ? paneId : null;
538
+ }
539
+ async function getWindowSize(tmux) {
540
+ const result = await spawnAsyncFn([
541
+ tmux,
542
+ "display-message",
543
+ "-p",
544
+ "#{window_width} #{window_height}"
545
+ ]);
546
+ const parts = result.stdout.trim().split(/\s+/);
547
+ if (parts.length < 2) return null;
548
+ const width = Number(parts[0]);
549
+ const height = Number(parts[1]);
550
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
551
+ return { width, height };
552
+ }
553
+ async function listPaneIds(tmux) {
554
+ const result = await spawnAsyncFn([tmux, "list-panes", "-F", "#{pane_id}"]);
555
+ return result.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
556
+ }
557
+ function paneWpId(paneId) {
558
+ if (!paneId.startsWith("%")) return null;
559
+ const n = Number(paneId.slice(1));
560
+ return Number.isFinite(n) ? n : null;
561
+ }
562
+ async function tryApplyMainVerticalMultiColumnLayout(tmux, maxAgentsPerColumn) {
563
+ const size = await getWindowSize(tmux);
564
+ if (!size) return false;
565
+ const currentPaneId = await getCurrentPaneId(tmux);
566
+ if (!currentPaneId) return false;
567
+ const panesBefore = await listPaneIds(tmux);
568
+ if (panesBefore.length < 2) return false;
569
+ if (panesBefore[0] && panesBefore[0] !== currentPaneId) {
570
+ await spawnAsyncFn([tmux, "swap-pane", "-s", currentPaneId, "-t", panesBefore[0]]);
571
+ }
572
+ const panes = await listPaneIds(tmux);
573
+ if (panes.length < 2) return false;
574
+ const mainPaneId = panes[0] ?? currentPaneId;
575
+ const agentPaneIds = panes.slice(1);
576
+ const columns = groupAgentsByColumn(agentPaneIds, maxAgentsPerColumn);
577
+ if (columns.length <= 1) {
578
+ return false;
579
+ }
580
+ const mainPanePercent = mainPanePercentForColumns(columns.length);
581
+ const mainWp = paneWpId(mainPaneId);
582
+ if (mainWp === null) return false;
583
+ const wpColumns = [];
584
+ for (const col of columns) {
585
+ const wpIds = [];
586
+ for (const paneId of col) {
587
+ const wpId = paneWpId(paneId);
588
+ if (wpId !== null) {
589
+ wpIds.push(wpId);
590
+ }
591
+ }
592
+ if (wpIds.length > 0) {
593
+ wpColumns.push(wpIds);
594
+ }
595
+ }
596
+ if (wpColumns.length <= 1) return false;
597
+ const layoutString = buildMainVerticalMultiColumnLayoutString({
598
+ windowWidth: size.width,
599
+ windowHeight: size.height,
600
+ mainPaneWpId: mainWp,
601
+ columns: wpColumns,
602
+ mainPanePercent
603
+ });
604
+ const result = await spawnAsyncFn([tmux, "select-layout", layoutString]);
605
+ if (result.exitCode === 0) {
606
+ log("[tmux] applyTmuxLayout: applied multi-column layout", {
607
+ columns: wpColumns.length,
608
+ mainPanePercent
609
+ });
610
+ return true;
611
+ }
612
+ log("[tmux] applyTmuxLayout: multi-column layout failed", {
613
+ exitCode: result.exitCode,
614
+ stderr: result.stderr.trim()
615
+ });
616
+ return false;
617
+ }
618
+ async function applyTmuxLayout() {
619
+ if (!storedConfig) {
620
+ log("[tmux] applyTmuxLayout: no stored config, skipping");
621
+ return;
622
+ }
623
+ const tmux = await getTmuxPath();
624
+ if (!tmux) {
625
+ log("[tmux] applyTmuxLayout: tmux binary not found");
626
+ return;
627
+ }
628
+ const layout = storedConfig.layout ?? "main-vertical";
629
+ const maxAgentsPerColumn = storedConfig.max_agents_per_column ?? 3;
630
+ const mainPaneSize = layout === "main-vertical" ? mainPanePercentForColumns(1) : storedConfig.main_pane_size ?? 60;
631
+ try {
632
+ if (layout === "main-vertical") {
633
+ const applied = await tryApplyMainVerticalMultiColumnLayout(
634
+ tmux,
635
+ maxAgentsPerColumn
636
+ );
637
+ if (applied) {
638
+ return;
639
+ }
640
+ }
641
+ await applyLayout(tmux, layout, mainPaneSize);
642
+ } catch (err) {
643
+ log("[tmux] applyTmuxLayout: failed, falling back to built-in layout", {
644
+ error: String(err)
645
+ });
646
+ try {
647
+ await spawnAsyncFn([tmux, "select-layout", layout === "tiled" ? "tiled" : "main-vertical"]);
648
+ } catch (fallbackErr) {
649
+ log("[tmux] applyTmuxLayout: fallback also failed", { error: String(fallbackErr) });
650
+ }
651
+ }
652
+ }
653
+ var spawnAsyncFn = spawnAsync;
654
+ async function attemptSpawnPane(sessionId, description, config, tmux, serverUrl) {
655
+ const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
656
+ const args = [
657
+ "split-window",
658
+ "-h",
659
+ "-d",
660
+ "-P",
661
+ "-F",
662
+ "#{pane_id}",
663
+ opencodeCmd
664
+ ];
665
+ log("[tmux] attemptSpawnPane: executing", { tmux, args, opencodeCmd });
666
+ const result = await spawnAsyncFn([tmux, ...args]);
667
+ const paneId = result.stdout.trim();
668
+ log("[tmux] attemptSpawnPane: split result", {
669
+ exitCode: result.exitCode,
670
+ paneId,
671
+ stderr: result.stderr.trim()
672
+ });
673
+ if (result.exitCode === 0 && paneId) {
674
+ await spawnAsyncFn(
675
+ [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
676
+ { ignoreOutput: true }
677
+ );
678
+ log("[tmux] attemptSpawnPane: SUCCESS, pane created", {
679
+ paneId
680
+ });
681
+ return { success: true, paneId };
682
+ }
683
+ return { success: false };
684
+ }
175
685
  async function spawnTmuxPane(sessionId, description, config, serverUrl) {
176
686
  log("[tmux] spawnTmuxPane called", {
177
687
  sessionId,
@@ -202,44 +712,35 @@ async function spawnTmuxPane(sessionId, description, config, serverUrl) {
202
712
  return { success: false };
203
713
  }
204
714
  storedConfig = config;
205
- try {
206
- const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
207
- const args = [
208
- "split-window",
209
- "-h",
210
- "-d",
211
- "-P",
212
- "-F",
213
- "#{pane_id}",
214
- opencodeCmd
215
- ];
216
- log("[tmux] spawnTmuxPane: executing", { tmux, args, opencodeCmd });
217
- const result = await spawnAsync([tmux, ...args]);
218
- const paneId = result.stdout.trim();
219
- log("[tmux] spawnTmuxPane: split result", {
220
- exitCode: result.exitCode,
221
- paneId,
222
- stderr: result.stderr.trim()
223
- });
224
- if (result.exitCode === 0 && paneId) {
225
- await spawnAsync(
226
- [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
227
- { ignoreOutput: true }
228
- );
229
- const layout = config.layout ?? "main-vertical";
230
- const mainPaneSize = config.main_pane_size ?? 60;
231
- await applyLayout(tmux, layout, mainPaneSize);
232
- log("[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied", {
233
- paneId,
234
- layout
715
+ const maxRetries = config.max_retry_attempts ?? 2;
716
+ let attempt = 0;
717
+ let lastResult = { success: false };
718
+ while (attempt <= maxRetries) {
719
+ try {
720
+ lastResult = await attemptSpawnPane(sessionId, description, config, tmux, serverUrl);
721
+ if (lastResult.success) {
722
+ return lastResult;
723
+ }
724
+ log("[tmux] spawnTmuxPane: attempt failed", {
725
+ attempt: attempt + 1,
726
+ maxRetries
235
727
  });
236
- return { success: true, paneId };
728
+ } catch (err) {
729
+ log("[tmux] spawnTmuxPane: exception on attempt", {
730
+ attempt: attempt + 1,
731
+ error: String(err)
732
+ });
733
+ lastResult = { success: false };
734
+ }
735
+ attempt++;
736
+ if (attempt <= maxRetries) {
737
+ const backoffMs = BASE_BACKOFF_MS2 * Math.pow(2, attempt - 1);
738
+ log("[tmux] spawnTmuxPane: waiting before retry", { backoffMs, attempt });
739
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
237
740
  }
238
- return { success: false };
239
- } catch (err) {
240
- log("[tmux] spawnTmuxPane: exception", { error: String(err) });
241
- return { success: false };
242
741
  }
742
+ log("[tmux] spawnTmuxPane: all retries exhausted", { attempts: attempt });
743
+ return lastResult;
243
744
  }
244
745
  async function closeTmuxPane(paneId) {
245
746
  log("[tmux] closeTmuxPane called", { paneId });
@@ -260,12 +761,7 @@ async function closeTmuxPane(paneId) {
260
761
  });
261
762
  if (result.exitCode === 0) {
262
763
  log("[tmux] closeTmuxPane: SUCCESS, pane closed", { paneId });
263
- if (storedConfig) {
264
- const layout = storedConfig.layout ?? "main-vertical";
265
- const mainPaneSize = storedConfig.main_pane_size ?? 60;
266
- await applyLayout(tmux, layout, mainPaneSize);
267
- log("[tmux] closeTmuxPane: layout reapplied", { layout });
268
- }
764
+ await applyTmuxLayout();
269
765
  return true;
270
766
  }
271
767
  log("[tmux] closeTmuxPane: failed (pane may already be closed)", {
@@ -293,11 +789,24 @@ var TmuxSessionManager = class {
293
789
  pollInterval;
294
790
  enabled = false;
295
791
  shuttingDown = false;
792
+ spawnQueue;
793
+ layoutDebounceTimer;
296
794
  constructor(ctx, tmuxConfig, serverUrl) {
297
795
  this.client = ctx.client;
298
796
  this.tmuxConfig = tmuxConfig;
299
797
  this.serverUrl = serverUrl;
300
798
  this.enabled = tmuxConfig.enabled && isInsideTmux();
799
+ this.spawnQueue = new SpawnQueue({
800
+ spawnFn: (request) => spawnTmuxPane(request.sessionId, request.title, this.tmuxConfig, this.serverUrl),
801
+ spawnDelayMs: tmuxConfig.spawn_delay_ms,
802
+ maxRetries: 0,
803
+ onQueueUpdate: (pendingCount) => {
804
+ log("[tmux-session-manager] queue update", { pendingCount });
805
+ },
806
+ onQueueDrained: () => {
807
+ this.scheduleDebouncedLayout();
808
+ }
809
+ });
301
810
  log("[tmux-session-manager] initialized", {
302
811
  enabled: this.enabled,
303
812
  tmuxConfig: this.tmuxConfig,
@@ -326,17 +835,7 @@ var TmuxSessionManager = class {
326
835
  parentId,
327
836
  title
328
837
  });
329
- const paneResult = await spawnTmuxPane(
330
- sessionId,
331
- title,
332
- this.tmuxConfig,
333
- this.serverUrl
334
- ).catch((err) => {
335
- log("[tmux-session-manager] failed to spawn pane", {
336
- error: String(err)
337
- });
338
- return { success: false, paneId: void 0 };
339
- });
838
+ const paneResult = await this.spawnQueue.enqueue({ sessionId, title });
340
839
  if (paneResult.success && paneResult.paneId) {
341
840
  const now = Date.now();
342
841
  this.sessions.set(sessionId, {
@@ -352,6 +851,8 @@ var TmuxSessionManager = class {
352
851
  paneId: paneResult.paneId
353
852
  });
354
853
  this.startPolling();
854
+ } else {
855
+ log("[tmux-session-manager] failed to spawn pane", { sessionId });
355
856
  }
356
857
  }
357
858
  startPolling() {
@@ -369,6 +870,16 @@ var TmuxSessionManager = class {
369
870
  log("[tmux-session-manager] polling stopped");
370
871
  }
371
872
  }
873
+ scheduleDebouncedLayout() {
874
+ if (this.layoutDebounceTimer) {
875
+ clearTimeout(this.layoutDebounceTimer);
876
+ }
877
+ const debounceMs = this.tmuxConfig.layout_debounce_ms ?? 150;
878
+ this.layoutDebounceTimer = setTimeout(() => {
879
+ log("[tmux-session-manager] applying deferred layout after queue drain");
880
+ void applyTmuxLayout();
881
+ }, debounceMs);
882
+ }
372
883
  async pollSessions() {
373
884
  if (this.sessions.size === 0) {
374
885
  this.stopPolling();
@@ -456,6 +967,11 @@ var TmuxSessionManager = class {
456
967
  }
457
968
  async cleanup() {
458
969
  this.stopPolling();
970
+ this.spawnQueue.shutdown();
971
+ if (this.layoutDebounceTimer) {
972
+ clearTimeout(this.layoutDebounceTimer);
973
+ this.layoutDebounceTimer = void 0;
974
+ }
459
975
  if (this.sessions.size > 0) {
460
976
  log("[tmux-session-manager] closing all panes", {
461
977
  count: this.sessions.size
@@ -520,7 +1036,11 @@ var OpencodeAgentTmux = async (ctx) => {
520
1036
  const tmuxConfig = {
521
1037
  enabled: config.enabled,
522
1038
  layout: config.layout,
523
- main_pane_size: config.main_pane_size
1039
+ main_pane_size: config.main_pane_size,
1040
+ spawn_delay_ms: config.spawn_delay_ms,
1041
+ max_retry_attempts: config.max_retry_attempts,
1042
+ layout_debounce_ms: config.layout_debounce_ms,
1043
+ max_agents_per_column: config.max_agents_per_column
524
1044
  };
525
1045
  const serverUrl = ctx.serverUrl?.toString() || detectServerUrl();
526
1046
  log("[plugin] initialized", {
@@ -72,18 +72,28 @@ function saveConfig(config) {
72
72
  }
73
73
  }
74
74
  function ensurePluginEntry(config) {
75
- const existing = Array.isArray(config.plugins) ? [...config.plugins] : [];
75
+ const existingPlugin = Array.isArray(config.plugin) ? [...config.plugin] : [];
76
+ const existingPlugins = Array.isArray(config.plugins) ? [...config.plugins] : [];
77
+ const existing = [...existingPlugin, ...existingPlugins];
76
78
  const normalized = existing.map(
77
- (plugin) => plugin === "opencode-subagent-tmux" ? "opencode-agent-tmux" : plugin
79
+ (entry) => entry === "opencode-subagent-tmux" ? "opencode-agent-tmux" : entry
78
80
  );
79
- if (!normalized.includes("opencode-agent-tmux")) {
80
- normalized.push("opencode-agent-tmux");
81
+ const deduped = [];
82
+ for (const entry of normalized) {
83
+ if (!deduped.includes(entry)) {
84
+ deduped.push(entry);
85
+ }
86
+ }
87
+ if (!deduped.includes("opencode-agent-tmux")) {
88
+ deduped.push("opencode-agent-tmux");
81
89
  }
82
- if (JSON.stringify(existing) !== JSON.stringify(normalized)) {
83
- config.plugins = normalized;
90
+ const changed = JSON.stringify(existingPlugin) !== JSON.stringify(deduped) || "plugins" in config;
91
+ if (changed) {
92
+ config.plugin = deduped;
93
+ delete config.plugins;
84
94
  saveConfig(config);
85
95
  }
86
- return normalized;
96
+ return deduped;
87
97
  }
88
98
  function installLatest(plugins) {
89
99
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-agent-tmux",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "OpenCode plugin that provides tmux integration for viewing agent execution in real-time",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,6 +15,7 @@
15
15
  "build": "tsup",
16
16
  "dev": "tsup --watch",
17
17
  "typecheck": "tsc --noEmit",
18
+ "test": "bun test",
18
19
  "prepublishOnly": "bun run build",
19
20
  "postinstall": "test -f dist/scripts/install.js && node dist/scripts/install.js || echo 'Skipping postinstall setup (dist not found)'"
20
21
  },
@@ -35,11 +36,13 @@
35
36
  },
36
37
  "homepage": "https://github.com/AnganSamadder/opencode-agent-tmux#readme",
37
38
  "dependencies": {
39
+ "proper-lockfile": "^4.1.2",
38
40
  "zod": "^3.24.1"
39
41
  },
40
42
  "devDependencies": {
41
43
  "@types/bun": "^1.2.4",
42
44
  "@types/node": "^25.0.10",
45
+ "@types/proper-lockfile": "^4.1.4",
43
46
  "tsup": "^8.3.6",
44
47
  "typescript": "^5.7.3"
45
48
  }