opentmux 1.3.11 → 1.5.3

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.
@@ -1,69 +1,618 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin/opentmux.ts
4
- import { spawn, execSync } from "child_process";
4
+ import { spawn, execSync as execSync2 } from "child_process";
5
5
  import { createServer } from "net";
6
- import { env, platform, exit, argv } from "process";
7
- import { existsSync, appendFileSync } from "fs";
8
- import { join, dirname } from "path";
6
+ import { env, platform as platform2, exit, argv } from "process";
7
+ import { existsSync as existsSync2, appendFileSync as appendFileSync2 } from "fs";
8
+ import { join as join3, dirname } from "path";
9
9
  import { homedir } from "os";
10
10
  import { fileURLToPath } from "url";
11
- var OPENCODE_PORT_START = parseInt(env.OPENCODE_PORT || "4096", 10);
12
- var OPENCODE_PORT_MAX = OPENCODE_PORT_START + 10;
11
+
12
+ // src/utils/process.ts
13
+ import { execSync } from "child_process";
14
+ import { platform } from "os";
15
+ function safeExec(command) {
16
+ try {
17
+ const output = execSync(command, {
18
+ encoding: "utf-8",
19
+ stdio: ["ignore", "pipe", "ignore"]
20
+ });
21
+ return output.trim();
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+ function getListeningPids(port) {
27
+ if (platform() === "win32") return [];
28
+ const output = safeExec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
29
+ if (!output) return [];
30
+ return output.split("\n").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value));
31
+ }
32
+ function isProcessAlive(pid) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ function getProcessStartTime(pid) {
41
+ const output = safeExec(`ps -p ${pid} -o lstart=`);
42
+ if (!output) return null;
43
+ return Date.parse(output);
44
+ }
45
+ function getProcessCommand(pid) {
46
+ const output = safeExec(`ps -p ${pid} -o command=`);
47
+ return output && output.length > 0 ? output : null;
48
+ }
49
+ function safeKill(pid, signal = "SIGTERM") {
50
+ try {
51
+ process.kill(pid, signal);
52
+ return true;
53
+ } catch (err) {
54
+ if (err.code === "ESRCH") return true;
55
+ return false;
56
+ }
57
+ }
58
+ async function waitForProcessExit(pid, timeoutMs = 2e3) {
59
+ const start = Date.now();
60
+ while (Date.now() - start < timeoutMs) {
61
+ if (!isProcessAlive(pid)) return true;
62
+ await new Promise((resolve) => setTimeout(resolve, 100));
63
+ }
64
+ return !isProcessAlive(pid);
65
+ }
66
+ function findProcessIds(pattern) {
67
+ if (platform() === "win32") return [];
68
+ const output = safeExec(`pgrep -f "${pattern}"`);
69
+ if (!output) return [];
70
+ return output.split("\n").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value));
71
+ }
72
+
73
+ // src/utils/logger.ts
74
+ import * as fs from "fs";
75
+ import * as os from "os";
76
+ import * as path from "path";
77
+ var logFile = path.join(os.tmpdir(), "opencode-agent-tmux.log");
78
+ function log(message, data) {
79
+ try {
80
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
81
+ const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}
82
+ `;
83
+ fs.appendFileSync(logFile, logEntry);
84
+ } catch {
85
+ }
86
+ }
87
+
88
+ // src/zombie-reaper.ts
89
+ var OPENCODE_PORT_START = 4096;
90
+ var ZombieReaper = class _ZombieReaper {
91
+ serverUrl;
92
+ options;
93
+ pollInterval;
94
+ candidates = /* @__PURE__ */ new Map();
95
+ isScanning = false;
96
+ lastActivityTime = Date.now();
97
+ constructor(serverUrl, options) {
98
+ this.serverUrl = serverUrl;
99
+ this.options = options;
100
+ }
101
+ /**
102
+ * Manual global reap command (for CLI).
103
+ * Scans ALL attach processes and checks them against their respective servers.
104
+ */
105
+ static async reapAll(options = {}) {
106
+ const opts = {
107
+ enabled: true,
108
+ intervalMs: 0,
109
+ minZombieChecks: 0,
110
+ // Instant kill for CLI (manual)
111
+ gracePeriodMs: 0,
112
+ // No grace for manual reap
113
+ ...options
114
+ };
115
+ log("[zombie-reaper] starting manual global reap");
116
+ const reaper = new _ZombieReaper("", opts);
117
+ const maxPorts = options.maxPorts || 10;
118
+ const endPort = OPENCODE_PORT_START + maxPorts;
119
+ const reapedServers = await _ZombieReaper.reapServers(OPENCODE_PORT_START, endPort);
120
+ if (reapedServers > 0) {
121
+ console.log(`Reaped ${reapedServers} inactive opencode servers.`);
122
+ }
123
+ const processes = await reaper.findAllAttachProcesses();
124
+ if (processes.length === 0) {
125
+ console.log("No opencode attach processes found.");
126
+ return;
127
+ }
128
+ console.log(`Found ${processes.length} attach processes. Checking statuses...`);
129
+ const byUrl = /* @__PURE__ */ new Map();
130
+ for (const p of processes) {
131
+ const url = p.targetUrl || "unknown";
132
+ const arr = byUrl.get(url) || [];
133
+ arr.push(p);
134
+ byUrl.set(url, arr);
135
+ }
136
+ let reapedCount = 0;
137
+ for (const [url, procs] of byUrl.entries()) {
138
+ if (url === "unknown") {
139
+ console.log(`\u26A0\uFE0F Skipping ${procs.length} processes with unknown target URL`);
140
+ continue;
141
+ }
142
+ let activeSessions = null;
143
+ try {
144
+ activeSessions = await reaper.fetchActiveSessions(url);
145
+ } catch (err) {
146
+ }
147
+ if (activeSessions === null) {
148
+ console.warn(`\u26A0\uFE0F Warning: Could not fetch active sessions from ${url}. Server likely stuck.`);
149
+ console.warn(`[zombie-reaper] Cleaning up ${procs.length} zombies attached to stuck server.`);
150
+ for (const p of procs) {
151
+ console.log(`\u{1F9DF} Zombie detected (Stuck Server): PID ${p.pid} (Session ${p.sessionId} on ${url})`);
152
+ await reaper.forceKill(p.pid);
153
+ reapedCount++;
154
+ }
155
+ continue;
156
+ }
157
+ for (const p of procs) {
158
+ if (!activeSessions.has(p.sessionId)) {
159
+ console.log(`\u{1F9DF} Zombie detected: PID ${p.pid} (Session ${p.sessionId} on ${url})`);
160
+ await reaper.forceKill(p.pid);
161
+ reapedCount++;
162
+ } else {
163
+ }
164
+ }
165
+ }
166
+ console.log(`Reap complete. Killed ${reapedCount} zombies.`);
167
+ }
168
+ async forceKill(pid) {
169
+ try {
170
+ process.kill(pid, "SIGTERM");
171
+ } catch {
172
+ }
173
+ }
174
+ start() {
175
+ if (!this.options.enabled) return;
176
+ if (this.pollInterval) return;
177
+ log("[zombie-reaper] starting", this.options);
178
+ this.pollInterval = setInterval(() => this.scanOnce(), this.options.intervalMs);
179
+ }
180
+ stop() {
181
+ if (this.pollInterval) {
182
+ clearInterval(this.pollInterval);
183
+ this.pollInterval = void 0;
184
+ log("[zombie-reaper] stopped");
185
+ }
186
+ }
187
+ async shutdown() {
188
+ this.stop();
189
+ log("[zombie-reaper] shutting down, running final scan");
190
+ await this.scanOnce();
191
+ }
192
+ async scanOnce() {
193
+ if (this.isScanning) return;
194
+ this.isScanning = true;
195
+ try {
196
+ const processes = await this.findAllAttachProcesses();
197
+ if (processes.length === 0) {
198
+ this.candidates.clear();
199
+ return;
200
+ }
201
+ const myProcesses = processes.filter((p) => this.areUrlsEqual(p.targetUrl, this.serverUrl));
202
+ if (myProcesses.length > 0) {
203
+ this.lastActivityTime = Date.now();
204
+ } else {
205
+ if (this.options.autoSelfDestruct && this.options.selfDestructTimeoutMs) {
206
+ const idleTime = Date.now() - this.lastActivityTime;
207
+ if (idleTime > this.options.selfDestructTimeoutMs) {
208
+ log("[zombie-reaper] Server abandoned (no clients). Self-destructing.", {
209
+ idleTimeMs: idleTime,
210
+ timeoutMs: this.options.selfDestructTimeoutMs
211
+ });
212
+ process.exit(0);
213
+ }
214
+ }
215
+ }
216
+ if (myProcesses.length === 0) {
217
+ this.pruneCandidates(/* @__PURE__ */ new Set());
218
+ return;
219
+ }
220
+ const activeSessions = await this.fetchActiveSessions(this.serverUrl);
221
+ if (activeSessions === null) {
222
+ log("[zombie-reaper] server unreachable, skipping scan");
223
+ return;
224
+ }
225
+ const currentPids = /* @__PURE__ */ new Set();
226
+ for (const proc of myProcesses) {
227
+ currentPids.add(proc.pid);
228
+ const isZombie = !activeSessions.has(proc.sessionId);
229
+ if (isZombie) {
230
+ this.markAsZombie(proc.pid);
231
+ if (this.shouldKill(proc.pid)) {
232
+ await this.reapProcess(proc);
233
+ }
234
+ } else {
235
+ if (this.candidates.has(proc.pid)) {
236
+ this.candidates.delete(proc.pid);
237
+ }
238
+ }
239
+ }
240
+ this.pruneCandidates(currentPids);
241
+ } catch (err) {
242
+ log("[zombie-reaper] scan error", { error: String(err) });
243
+ } finally {
244
+ this.isScanning = false;
245
+ }
246
+ }
247
+ pruneCandidates(currentPids) {
248
+ for (const pid of this.candidates.keys()) {
249
+ if (!currentPids.has(pid)) {
250
+ this.candidates.delete(pid);
251
+ }
252
+ }
253
+ }
254
+ areUrlsEqual(url1, url2) {
255
+ if (!url1) return false;
256
+ try {
257
+ const normalize = (u) => {
258
+ if (!u.match(/^https?:\/\//)) {
259
+ u = `http://${u}`;
260
+ }
261
+ const urlObj = new URL(u);
262
+ if (urlObj.hostname === "localhost") {
263
+ urlObj.hostname = "127.0.0.1";
264
+ }
265
+ return urlObj.origin;
266
+ };
267
+ return normalize(url1) === normalize(url2);
268
+ } catch {
269
+ return url1 === url2;
270
+ }
271
+ }
272
+ async findAllAttachProcesses() {
273
+ const pids = findProcessIds("opencode attach");
274
+ const results = [];
275
+ for (const pid of pids) {
276
+ const command = getProcessCommand(pid);
277
+ if (!command) continue;
278
+ const sessionMatch = command.match(/--session\s+([a-zA-Z0-9_-]+)/);
279
+ const urlMatch = command.match(/attach\s+([^\s]+)/);
280
+ if (sessionMatch && sessionMatch[1]) {
281
+ results.push({
282
+ pid,
283
+ sessionId: sessionMatch[1],
284
+ targetUrl: urlMatch ? urlMatch[1] : null,
285
+ command
286
+ });
287
+ }
288
+ }
289
+ return results;
290
+ }
291
+ // Exposed for testing
292
+ async classifyProcess(sessionId) {
293
+ const activeSessions = await this.fetchActiveSessions(this.serverUrl);
294
+ if (activeSessions === null) return "unknown";
295
+ return activeSessions.has(sessionId) ? "active" : "zombie";
296
+ }
297
+ async fetchActiveSessions(url) {
298
+ const statusUrl = new URL("/session/status", url).toString();
299
+ const controller = new AbortController();
300
+ const timeout = setTimeout(() => controller.abort(), 2e3);
301
+ try {
302
+ const response = await fetch(statusUrl, { signal: controller.signal }).catch((err) => {
303
+ if (process.env.DEBUG || process.env.VERBOSE) {
304
+ console.error(`[zombie-reaper] Fetch error for ${statusUrl}:`, err);
305
+ }
306
+ return null;
307
+ });
308
+ if (!response?.ok) {
309
+ if ((process.env.DEBUG || process.env.VERBOSE) && response) {
310
+ console.error(`[zombie-reaper] Server returned ${response.status} ${response.statusText} for ${statusUrl}`);
311
+ }
312
+ return null;
313
+ }
314
+ const payload = await response.json().catch(() => null);
315
+ if (!payload || typeof payload !== "object") return null;
316
+ const data = payload.data;
317
+ if (!data && typeof payload === "object" && !Array.isArray(payload)) {
318
+ const keys = Object.keys(payload);
319
+ if (keys.length > 0 && keys.every((k) => k.startsWith("ses_") || k.startsWith("session_"))) {
320
+ return new Set(keys);
321
+ }
322
+ }
323
+ if (!data || typeof data !== "object") {
324
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
325
+ const keys = Object.keys(payload);
326
+ if (keys.some((k) => k.startsWith("ses_"))) {
327
+ return new Set(keys);
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ if (Array.isArray(data)) {
333
+ const ids = data.map((item) => item?.id || item?.sessionId).filter(Boolean);
334
+ if (ids.length > 0) return new Set(ids);
335
+ if (data.length === 0) return /* @__PURE__ */ new Set();
336
+ return null;
337
+ }
338
+ return new Set(Object.keys(data));
339
+ } catch {
340
+ return null;
341
+ } finally {
342
+ clearTimeout(timeout);
343
+ }
344
+ }
345
+ markAsZombie(pid) {
346
+ const candidate = this.candidates.get(pid);
347
+ if (candidate) {
348
+ candidate.count++;
349
+ } else {
350
+ this.candidates.set(pid, {
351
+ count: 1,
352
+ firstDetectedAt: Date.now()
353
+ });
354
+ }
355
+ }
356
+ shouldKill(pid) {
357
+ const candidate = this.candidates.get(pid);
358
+ if (!candidate) return false;
359
+ const meetsCount = candidate.count >= this.options.minZombieChecks;
360
+ const meetsGrace = Date.now() - candidate.firstDetectedAt >= this.options.gracePeriodMs;
361
+ return meetsCount && meetsGrace;
362
+ }
363
+ async reapProcess(proc) {
364
+ log("[zombie-reaper] REAPING ZOMBIE", { pid: proc.pid, sessionId: proc.sessionId });
365
+ safeKill(proc.pid, "SIGTERM");
366
+ const exited = await waitForProcessExit(proc.pid, 2e3);
367
+ if (!exited) {
368
+ log("[zombie-reaper] force killing zombie", { pid: proc.pid });
369
+ safeKill(proc.pid, "SIGKILL");
370
+ }
371
+ this.candidates.delete(proc.pid);
372
+ }
373
+ static async reapServers(startPort, endPort) {
374
+ let reapedCount = 0;
375
+ console.log(`Scanning ports ${startPort}-${endPort} for inactive servers...`);
376
+ for (let port = startPort; port <= endPort; port++) {
377
+ const pids = getListeningPids(port);
378
+ if (pids.length === 0) continue;
379
+ for (const pid of pids) {
380
+ const cmd = getProcessCommand(pid) || "";
381
+ const isSuspicious = cmd.includes("opencode") || cmd.includes("node") || cmd.includes("bun");
382
+ if (!isSuspicious) continue;
383
+ const url = `http://127.0.0.1:${port}`;
384
+ const reaper = new _ZombieReaper(url, {
385
+ enabled: true,
386
+ intervalMs: 0,
387
+ minZombieChecks: 0,
388
+ gracePeriodMs: 0
389
+ });
390
+ try {
391
+ let sessions = null;
392
+ for (let i = 0; i < 3; i++) {
393
+ sessions = await reaper.fetchActiveSessions(url);
394
+ if (sessions !== null) break;
395
+ if (i < 2) await new Promise((r) => setTimeout(r, 1e3));
396
+ }
397
+ if (sessions === null) {
398
+ console.log(`[zombie-reaper] Server on port ${port} (PID ${pid}) is unreachable/stuck after 3 retries. Killing...`);
399
+ try {
400
+ safeKill(pid, "SIGTERM");
401
+ const exited = await waitForProcessExit(pid, 2e3);
402
+ if (!exited) {
403
+ console.log(`[zombie-reaper] Force killing server on port ${port} (PID ${pid})...`);
404
+ safeKill(pid, "SIGKILL");
405
+ await waitForProcessExit(pid, 1e3);
406
+ if (isProcessAlive(pid)) {
407
+ console.error(`[zombie-reaper] CRITICAL: Failed to kill PID ${pid} on port ${port}`);
408
+ }
409
+ }
410
+ } catch (err) {
411
+ console.error(`[zombie-reaper] Error killing PID ${pid}:`, err);
412
+ }
413
+ reapedCount++;
414
+ continue;
415
+ }
416
+ if (sessions.size > 0) {
417
+ console.log(`[zombie-reaper] Skipping port ${port} (Has ${sessions.size} active session(s))`);
418
+ continue;
419
+ }
420
+ if (sessions.size === 0) {
421
+ console.log(`[zombie-reaper] Found inactive server on port ${port} (PID ${pid}). Killing...`);
422
+ try {
423
+ safeKill(pid, "SIGTERM");
424
+ const exited = await waitForProcessExit(pid, 2e3);
425
+ if (!exited) {
426
+ console.log(`[zombie-reaper] Force killing server on port ${port} (PID ${pid})...`);
427
+ safeKill(pid, "SIGKILL");
428
+ await waitForProcessExit(pid, 1e3);
429
+ if (isProcessAlive(pid)) {
430
+ console.error(`[zombie-reaper] CRITICAL: Failed to kill PID ${pid} on port ${port}`);
431
+ }
432
+ }
433
+ } catch (err) {
434
+ console.error(`[zombie-reaper] Error killing PID ${pid}:`, err);
435
+ }
436
+ reapedCount++;
437
+ }
438
+ } catch (e) {
439
+ console.log(`[zombie-reaper] Server on port ${port} (PID ${pid}) error. Killing...`);
440
+ try {
441
+ safeKill(pid, "SIGTERM");
442
+ const exited = await waitForProcessExit(pid, 2e3);
443
+ if (!exited) {
444
+ console.log(`[zombie-reaper] Force killing server on port ${port} (PID ${pid})...`);
445
+ safeKill(pid, "SIGKILL");
446
+ await waitForProcessExit(pid, 1e3);
447
+ if (isProcessAlive(pid)) {
448
+ console.error(`[zombie-reaper] CRITICAL: Failed to kill PID ${pid} on port ${port}`);
449
+ }
450
+ }
451
+ } catch (err) {
452
+ console.error(`[zombie-reaper] Error killing PID ${pid}:`, err);
453
+ }
454
+ reapedCount++;
455
+ }
456
+ }
457
+ }
458
+ return reapedCount;
459
+ }
460
+ };
461
+
462
+ // src/utils/config-loader.ts
463
+ import * as fs2 from "fs";
464
+ import * as path2 from "path";
465
+
466
+ // src/config.ts
467
+ import { z } from "zod";
468
+ var TmuxLayoutSchema = z.enum([
469
+ "main-horizontal",
470
+ "main-vertical",
471
+ "tiled",
472
+ "even-horizontal",
473
+ "even-vertical"
474
+ ]);
475
+ var TmuxConfigSchema = z.object({
476
+ enabled: z.boolean().default(true),
477
+ layout: TmuxLayoutSchema.default("main-vertical"),
478
+ main_pane_size: z.number().min(20).max(80).default(60),
479
+ spawn_delay_ms: z.number().min(50).max(2e3).default(300),
480
+ max_retry_attempts: z.number().min(0).max(5).default(2),
481
+ layout_debounce_ms: z.number().min(50).max(1e3).default(150),
482
+ max_agents_per_column: z.number().min(1).max(10).default(3),
483
+ // Reaper config
484
+ reaper_enabled: z.boolean().default(true),
485
+ reaper_interval_ms: z.number().default(3e4),
486
+ reaper_min_zombie_checks: z.number().default(3),
487
+ reaper_grace_period_ms: z.number().default(5e3),
488
+ // Auto self-destruct for abandoned servers
489
+ reaper_auto_self_destruct: z.boolean().default(true),
490
+ reaper_self_destruct_timeout_ms: z.number().default(60 * 60 * 1e3),
491
+ // 1 hour
492
+ // Port management
493
+ rotate_port: z.boolean().default(false),
494
+ max_ports: z.number().min(1).max(100).default(10)
495
+ });
496
+ var PluginConfigSchema = z.object({
497
+ enabled: z.boolean().default(true),
498
+ port: z.number().default(4096),
499
+ layout: TmuxLayoutSchema.default("main-vertical"),
500
+ main_pane_size: z.number().min(20).max(80).default(60),
501
+ auto_close: z.boolean().default(true),
502
+ spawn_delay_ms: z.number().min(50).max(2e3).default(300),
503
+ max_retry_attempts: z.number().min(0).max(5).default(2),
504
+ layout_debounce_ms: z.number().min(50).max(1e3).default(150),
505
+ max_agents_per_column: z.number().min(1).max(10).default(3),
506
+ // Reaper config
507
+ reaper_enabled: z.boolean().default(true),
508
+ reaper_interval_ms: z.number().default(3e4),
509
+ reaper_min_zombie_checks: z.number().default(3),
510
+ reaper_grace_period_ms: z.number().default(5e3),
511
+ // Auto self-destruct for abandoned servers
512
+ reaper_auto_self_destruct: z.boolean().default(true),
513
+ reaper_self_destruct_timeout_ms: z.number().default(60 * 60 * 1e3),
514
+ // 1 hour
515
+ // Port management
516
+ rotate_port: z.boolean().default(false),
517
+ max_ports: z.number().min(1).max(100).default(10)
518
+ });
519
+ var POLL_INTERVAL_MS = 2e3;
520
+ var SESSION_TIMEOUT_MS = 10 * 60 * 1e3;
521
+ var SESSION_MISSING_GRACE_MS = POLL_INTERVAL_MS * 3;
522
+
523
+ // src/utils/config-loader.ts
524
+ function loadConfig(directory) {
525
+ const configPaths = [];
526
+ if (directory) {
527
+ configPaths.push(
528
+ path2.join(directory, "opentmux.json"),
529
+ path2.join(directory, "opencode-agent-tmux.json")
530
+ );
531
+ }
532
+ configPaths.push(
533
+ path2.join(
534
+ process.env.HOME ?? "",
535
+ ".config",
536
+ "opencode",
537
+ "opentmux.json"
538
+ )
539
+ );
540
+ for (const configPath of configPaths) {
541
+ try {
542
+ if (fs2.existsSync(configPath)) {
543
+ const content = fs2.readFileSync(configPath, "utf-8");
544
+ const parsed = JSON.parse(content);
545
+ const result = PluginConfigSchema.safeParse(parsed);
546
+ if (result.success) {
547
+ return result.data;
548
+ }
549
+ }
550
+ } catch (err) {
551
+ }
552
+ }
553
+ return PluginConfigSchema.parse({});
554
+ }
555
+
556
+ // src/bin/opentmux.ts
557
+ var config = loadConfig();
558
+ var OPENCODE_PORT_START2 = config.port || parseInt(env.OPENCODE_PORT || "4096", 10);
559
+ var OPENCODE_PORT_MAX = OPENCODE_PORT_START2 + (config.max_ports || 10);
13
560
  var LOG_FILE = "/tmp/opentmux.log";
14
561
  var HEALTH_TIMEOUT_MS = 1e3;
15
- var __filename = fileURLToPath(import.meta.url);
16
- var __dirname = dirname(__filename);
17
- function log(...args) {
562
+ var __filename2 = fileURLToPath(import.meta.url);
563
+ var __dirname2 = dirname(__filename2);
564
+ function log2(...args) {
18
565
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
19
566
  const message = `[${timestamp}] ${args.join(" ")}
20
567
  `;
21
568
  try {
22
- appendFileSync(LOG_FILE, message);
569
+ appendFileSync2(LOG_FILE, message);
23
570
  } catch {
24
571
  }
25
572
  }
26
573
  function spawnPluginUpdater() {
27
574
  if (env.OPENCODE_TMUX_DISABLE_UPDATES === "1") return;
28
- const updaterPath = join(__dirname, "../scripts/update-plugins.js");
29
- if (!existsSync(updaterPath)) return;
575
+ const updaterPath = join3(__dirname2, "../scripts/update-plugins.js");
576
+ if (!existsSync2(updaterPath)) return;
30
577
  try {
31
- const child = spawn(
32
- process.execPath,
33
- [updaterPath],
34
- {
35
- stdio: "ignore",
36
- detached: true,
37
- env: {
38
- ...process.env,
39
- OPENCODE_TMUX_UPDATE: "1"
40
- }
578
+ const child = spawn(process.execPath, [updaterPath], {
579
+ stdio: "ignore",
580
+ detached: true,
581
+ env: {
582
+ ...process.env,
583
+ OPENCODE_TMUX_UPDATE: "1"
41
584
  }
42
- );
585
+ });
43
586
  child.unref();
44
587
  } catch (error) {
45
588
  }
46
589
  }
47
590
  function findOpencodeBin() {
48
591
  try {
49
- const cmd = platform === "win32" ? "where opencode" : "which -a opencode";
50
- const output = execSync(cmd, { encoding: "utf-8" }).trim().split("\n");
592
+ const cmd = platform2 === "win32" ? "where opencode" : "which -a opencode";
593
+ const output = execSync2(cmd, { encoding: "utf-8" }).trim().split("\n");
51
594
  const currentScript = argv[1];
52
595
  for (const bin of output) {
53
596
  const normalizedBin = bin.trim();
54
- if (normalizedBin.includes("opentmux") || normalizedBin === currentScript) continue;
597
+ if (normalizedBin.includes("opentmux") || normalizedBin === currentScript)
598
+ continue;
55
599
  if (normalizedBin) return normalizedBin;
56
600
  }
57
601
  } catch (e) {
58
602
  }
59
603
  const commonPaths = [
60
- join(homedir(), ".opencode", "bin", platform === "win32" ? "opencode.exe" : "opencode"),
61
- join(homedir(), "AppData", "Local", "opencode", "bin", "opencode.exe"),
604
+ join3(
605
+ homedir(),
606
+ ".opencode",
607
+ "bin",
608
+ platform2 === "win32" ? "opencode.exe" : "opencode"
609
+ ),
610
+ join3(homedir(), "AppData", "Local", "opencode", "bin", "opencode.exe"),
62
611
  "/usr/local/bin/opencode",
63
612
  "/usr/bin/opencode"
64
613
  ];
65
614
  for (const p of commonPaths) {
66
- if (existsSync(p)) return p;
615
+ if (existsSync2(p)) return p;
67
616
  }
68
617
  return null;
69
618
  }
@@ -80,25 +629,6 @@ function checkPort(port) {
80
629
  });
81
630
  });
82
631
  }
83
- function isProcessAlive(pid) {
84
- try {
85
- process.kill(pid, 0);
86
- return true;
87
- } catch {
88
- return false;
89
- }
90
- }
91
- function safeExec(command) {
92
- try {
93
- const output = execSync(command, {
94
- encoding: "utf-8",
95
- stdio: ["ignore", "pipe", "ignore"]
96
- });
97
- return output.trim();
98
- } catch {
99
- return null;
100
- }
101
- }
102
632
  function getTmuxPanePids() {
103
633
  if (!hasTmux()) return /* @__PURE__ */ new Set();
104
634
  const output = safeExec("tmux list-panes -a -F '#{pane_pid}'");
@@ -111,9 +641,9 @@ async function isOpencodeHealthy(port) {
111
641
  const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
112
642
  const healthUrl = `http://127.0.0.1:${port}/health`;
113
643
  try {
114
- const response = await fetch(healthUrl, { signal: controller.signal }).catch(
115
- () => null
116
- );
644
+ const response = await fetch(healthUrl, {
645
+ signal: controller.signal
646
+ }).catch(() => null);
117
647
  return response?.ok ?? false;
118
648
  } catch {
119
649
  return false;
@@ -121,16 +651,6 @@ async function isOpencodeHealthy(port) {
121
651
  clearTimeout(timeout);
122
652
  }
123
653
  }
124
- function getListeningPids(port) {
125
- if (platform === "win32") return [];
126
- const output = safeExec(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`);
127
- if (!output) return [];
128
- return output.split("\n").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value));
129
- }
130
- function getProcessCommand(pid) {
131
- const output = safeExec(`ps -p ${pid} -o command=`);
132
- return output && output.length > 0 ? output : null;
133
- }
134
654
  function getProcessStat(pid) {
135
655
  const output = safeExec(`ps -p ${pid} -o stat=`);
136
656
  return output && output.length > 0 ? output.trim() : null;
@@ -173,11 +693,11 @@ function isForegroundProcess(pid) {
173
693
  return stat.includes("+");
174
694
  }
175
695
  async function tryReclaimPort(port, tmuxPanePids) {
176
- if (platform === "win32") return false;
696
+ if (platform2 === "win32") return false;
177
697
  const healthy = await isOpencodeHealthy(port);
178
698
  if (healthy) return false;
179
699
  const pids = getListeningPids(port);
180
- log(
700
+ log2(
181
701
  "Port scan:",
182
702
  port.toString(),
183
703
  "healthy",
@@ -195,7 +715,7 @@ async function tryReclaimPort(port, tmuxPanePids) {
195
715
  const stat = getProcessStat(pid);
196
716
  const hasTtyPeers = hasOtherTtyProcesses(tty, pid);
197
717
  const inTmux = tmuxPanePids.size > 0 && isDescendantOf(pid, tmuxPanePids);
198
- log(
718
+ log2(
199
719
  "Port process:",
200
720
  port.toString(),
201
721
  "pid",
@@ -213,19 +733,35 @@ async function tryReclaimPort(port, tmuxPanePids) {
213
733
  );
214
734
  if (command && command.includes("opencode")) {
215
735
  if (inTmux) {
216
- log("Port owned by tmux process, skipping:", port.toString(), pid.toString());
736
+ log2(
737
+ "Port owned by tmux process, skipping:",
738
+ port.toString(),
739
+ pid.toString()
740
+ );
217
741
  continue;
218
742
  }
219
743
  if (hasTtyPeers) {
220
- log("Port owned by active tty process, skipping:", port.toString(), pid.toString());
744
+ log2(
745
+ "Port owned by active tty process, skipping:",
746
+ port.toString(),
747
+ pid.toString()
748
+ );
221
749
  continue;
222
750
  }
223
751
  if (isForegroundProcess(pid)) {
224
- log("Port owned by potentially busy foreground process, skipping:", port.toString(), pid.toString());
752
+ log2(
753
+ "Port owned by potentially busy foreground process, skipping:",
754
+ port.toString(),
755
+ pid.toString()
756
+ );
225
757
  continue;
226
758
  }
227
759
  }
228
- log("Attempting to stop stale or non-opencode process:", port.toString(), pid.toString());
760
+ log2(
761
+ "Attempting to stop stale or non-opencode process:",
762
+ port.toString(),
763
+ pid.toString()
764
+ );
229
765
  attemptedKill = true;
230
766
  try {
231
767
  process.kill(pid, "SIGTERM");
@@ -236,7 +772,11 @@ async function tryReclaimPort(port, tmuxPanePids) {
236
772
  await new Promise((resolve) => setTimeout(resolve, 700));
237
773
  for (const pid of pids) {
238
774
  if (isProcessAlive(pid)) {
239
- log("Process still alive, sending SIGKILL:", port.toString(), pid.toString());
775
+ log2(
776
+ "Process still alive, sending SIGKILL:",
777
+ port.toString(),
778
+ pid.toString()
779
+ );
240
780
  try {
241
781
  process.kill(pid, "SIGKILL");
242
782
  } catch {
@@ -248,7 +788,7 @@ async function tryReclaimPort(port, tmuxPanePids) {
248
788
  }
249
789
  async function findAvailablePort() {
250
790
  let tmuxPanePids = null;
251
- for (let port = OPENCODE_PORT_START; port <= OPENCODE_PORT_MAX; port++) {
791
+ for (let port = OPENCODE_PORT_START2; port <= OPENCODE_PORT_MAX; port++) {
252
792
  if (await checkPort(port)) return port;
253
793
  if (!tmuxPanePids) {
254
794
  tmuxPanePids = getTmuxPanePids();
@@ -260,16 +800,56 @@ async function findAvailablePort() {
260
800
  }
261
801
  function hasTmux() {
262
802
  try {
263
- execSync("tmux -V", { stdio: "ignore" });
803
+ execSync2("tmux -V", { stdio: "ignore" });
264
804
  return true;
265
805
  } catch (e) {
266
806
  return false;
267
807
  }
268
808
  }
269
809
  async function main() {
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]));
272
- if (isCliCommand) {
810
+ const isRuntime = /\/?(node|bun)(\.exe)?$/i.test(argv[0]);
811
+ const args = isRuntime ? argv.slice(2) : argv.slice(1);
812
+ if (args.includes("--reap") || args.includes("-reap")) {
813
+ await ZombieReaper.reapAll();
814
+ exit(0);
815
+ }
816
+ const NON_TUI_COMMANDS = [
817
+ // Core CLI commands
818
+ "auth",
819
+ "config",
820
+ "plugins",
821
+ "update",
822
+ "upgrade",
823
+ "completion",
824
+ "stats",
825
+ "run",
826
+ "exec",
827
+ "doctor",
828
+ "debug",
829
+ "clean",
830
+ "uninstall",
831
+ // Agent/Session management
832
+ "agent",
833
+ "session",
834
+ "export",
835
+ "import",
836
+ "github",
837
+ "pr",
838
+ // Server commands (usually run in fg, don't need tmux wrapper)
839
+ "serve",
840
+ "web",
841
+ "acp",
842
+ "mcp",
843
+ "models",
844
+ // Flags
845
+ "--version",
846
+ "-v",
847
+ "--help",
848
+ "-h"
849
+ ];
850
+ const isCliCommand = args.length > 0 && NON_TUI_COMMANDS.includes(args[0]);
851
+ const isInteractiveMode = args.length === 0;
852
+ if (isCliCommand || isInteractiveMode) {
273
853
  const opencodeBin2 = findOpencodeBin();
274
854
  if (!opencodeBin2) {
275
855
  console.error(
@@ -298,48 +878,106 @@ async function main() {
298
878
  });
299
879
  return;
300
880
  }
301
- log("=== OpenCode Tmux Wrapper Started ===");
302
- log("Process argv:", JSON.stringify(argv));
303
- log("Current directory:", process.cwd());
881
+ log2("=== OpenCode Tmux Wrapper Started ===");
882
+ log2("Process argv:", JSON.stringify(argv));
883
+ log2("Current directory:", process.cwd());
304
884
  const opencodeBin = findOpencodeBin();
305
- log("Found opencode binary:", opencodeBin);
885
+ log2("Found opencode binary:", opencodeBin);
306
886
  if (!opencodeBin) {
307
- console.error('Error: Could not find "opencode" binary in PATH or common locations.');
308
- log("ERROR: opencode binary not found");
887
+ console.error(
888
+ 'Error: Could not find "opencode" binary in PATH or common locations.'
889
+ );
890
+ log2("ERROR: opencode binary not found");
309
891
  exit(1);
310
892
  }
311
893
  spawnPluginUpdater();
312
- const port = await findAvailablePort();
313
- log("Found available port:", port);
894
+ let port = await findAvailablePort();
895
+ log2("Found available port:", port);
314
896
  if (!port) {
315
- console.error("Error: No available ports found in range 4096-4106.");
316
- log("ERROR: No available ports");
317
- exit(1);
897
+ if (config.rotate_port) {
898
+ log2("Port rotation enabled. Finding oldest session to kill...");
899
+ let oldestPid = null;
900
+ let oldestTime = Date.now();
901
+ let targetPort = -1;
902
+ for (let p = OPENCODE_PORT_START2; p <= OPENCODE_PORT_MAX; p++) {
903
+ const pids = getListeningPids(p);
904
+ for (const pid of pids) {
905
+ const cmd = getProcessCommand(pid);
906
+ if (cmd && (cmd.includes("opencode") || cmd.includes("node") || cmd.includes("bun"))) {
907
+ const startTime = getProcessStartTime(pid);
908
+ if (startTime && startTime < oldestTime) {
909
+ oldestTime = startTime;
910
+ oldestPid = pid;
911
+ targetPort = p;
912
+ }
913
+ }
914
+ }
915
+ }
916
+ if (oldestPid && targetPort !== -1) {
917
+ log2("Rotating port:", targetPort, "Killing oldest PID:", oldestPid);
918
+ console.log(
919
+ `\u267B\uFE0F Port rotation: Killing oldest session (PID ${oldestPid}) on port ${targetPort} to make room...`
920
+ );
921
+ safeKill(oldestPid, "SIGTERM");
922
+ await waitForProcessExit(oldestPid, 2e3);
923
+ if (isProcessAlive(oldestPid)) {
924
+ safeKill(oldestPid, "SIGKILL");
925
+ await waitForProcessExit(oldestPid, 1e3);
926
+ }
927
+ if (await checkPort(targetPort)) {
928
+ port = targetPort;
929
+ log2("Port reclaimed successfully:", port);
930
+ } else {
931
+ console.error(
932
+ `\u26A0\uFE0F Failed to reclaim port ${targetPort} even after killing PID ${oldestPid}.`
933
+ );
934
+ exit(1);
935
+ }
936
+ } else {
937
+ console.error(
938
+ "Error: Could not find any valid OpenCode sessions to rotate."
939
+ );
940
+ exit(1);
941
+ }
942
+ } else {
943
+ console.error(
944
+ `Error: No available ports found in range ${OPENCODE_PORT_START2}-${OPENCODE_PORT_MAX}.`
945
+ );
946
+ console.error('Tip: Run "opentmux -reap" to clean up stuck sessions.');
947
+ console.error(
948
+ ' Or enable "rotate_port": true in config to automatically recycle oldest sessions.'
949
+ );
950
+ log2("ERROR: No available ports");
951
+ exit(1);
952
+ }
318
953
  }
319
954
  const env2 = { ...process.env };
320
955
  env2.OPENCODE_PORT = port.toString();
321
- log("User args:", JSON.stringify(args));
956
+ log2("User args:", JSON.stringify(args));
322
957
  const childArgs = ["--port", port.toString(), ...args];
323
- log("Final childArgs:", JSON.stringify(childArgs));
958
+ log2("Final childArgs:", JSON.stringify(childArgs));
324
959
  const inTmux = !!env2.TMUX;
325
960
  const tmuxAvailable = hasTmux();
326
- log("In tmux?", inTmux);
327
- log("Tmux available?", tmuxAvailable);
961
+ log2("In tmux?", inTmux);
962
+ log2("Tmux available?", tmuxAvailable);
328
963
  if (inTmux || !tmuxAvailable) {
329
- log("Running directly (in tmux or no tmux available)");
330
- const child = spawn(opencodeBin, childArgs, { stdio: "inherit", env: env2 });
964
+ log2("Running directly (in tmux or no tmux available)");
965
+ const child = spawn(opencodeBin, childArgs, {
966
+ stdio: "inherit",
967
+ env: env2
968
+ });
331
969
  child.on("error", (err) => {
332
- log("ERROR spawning child:", err.message);
970
+ log2("ERROR spawning child:", err.message);
333
971
  });
334
972
  child.on("close", (code) => {
335
- log("Child exited with code:", code);
973
+ log2("Child exited with code:", code);
336
974
  exit(code ?? 0);
337
975
  });
338
976
  process.on("SIGINT", () => child.kill("SIGINT"));
339
977
  process.on("SIGTERM", () => child.kill("SIGTERM"));
340
978
  } else {
341
979
  console.log("\u{1F680} Launching tmux session...");
342
- log("Launching tmux session");
980
+ log2("Launching tmux session");
343
981
  const escapedBin = opencodeBin.includes(" ") ? `'${opencodeBin}'` : opencodeBin;
344
982
  const escapedArgs = childArgs.map((arg) => {
345
983
  if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
@@ -348,24 +986,24 @@ async function main() {
348
986
  return arg;
349
987
  });
350
988
  const shellCommand = `${escapedBin} ${escapedArgs.join(" ")} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`;
351
- log("Shell command for tmux:", shellCommand);
352
- const tmuxArgs = [
353
- "new-session",
354
- shellCommand
355
- ];
356
- log("Tmux args:", JSON.stringify(tmuxArgs));
989
+ log2("Shell command for tmux:", shellCommand);
990
+ const tmuxArgs = ["new-session", shellCommand];
991
+ log2("Tmux args:", JSON.stringify(tmuxArgs));
357
992
  const child = spawn("tmux", tmuxArgs, { stdio: "inherit", env: env2 });
358
993
  child.on("error", (err) => {
359
- log("ERROR spawning tmux:", err.message);
994
+ log2("ERROR spawning tmux:", err.message);
360
995
  });
361
996
  child.on("close", (code) => {
362
- log("Tmux exited with code:", code);
997
+ log2("Tmux exited with code:", code);
363
998
  exit(code ?? 0);
364
999
  });
365
1000
  }
366
1001
  }
367
1002
  main().catch((err) => {
368
- log("FATAL ERROR:", err.message, err.stack);
1003
+ if (err.name === "AbortError" || err.code === 20) {
1004
+ exit(0);
1005
+ }
1006
+ log2("FATAL ERROR:", err.message, err.stack);
369
1007
  console.error(err);
370
1008
  exit(1);
371
1009
  });