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.
- package/dist/bin/opentmux.js +741 -103
- package/dist/index.d.ts +24 -0
- package/dist/index.js +348 -82
- package/dist/scripts/update-plugins.js +4 -1
- package/package.json +2 -4
package/dist/bin/opentmux.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
|
16
|
-
var
|
|
17
|
-
function
|
|
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
|
-
|
|
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 =
|
|
29
|
-
if (!
|
|
575
|
+
const updaterPath = join3(__dirname2, "../scripts/update-plugins.js");
|
|
576
|
+
if (!existsSync2(updaterPath)) return;
|
|
30
577
|
try {
|
|
31
|
-
const child = spawn(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
{
|
|
35
|
-
|
|
36
|
-
|
|
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 =
|
|
50
|
-
const output =
|
|
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)
|
|
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
|
-
|
|
61
|
-
|
|
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 (
|
|
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, {
|
|
115
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
271
|
-
const
|
|
272
|
-
if (
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
885
|
+
log2("Found opencode binary:", opencodeBin);
|
|
306
886
|
if (!opencodeBin) {
|
|
307
|
-
console.error(
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
894
|
+
let port = await findAvailablePort();
|
|
895
|
+
log2("Found available port:", port);
|
|
314
896
|
if (!port) {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
956
|
+
log2("User args:", JSON.stringify(args));
|
|
322
957
|
const childArgs = ["--port", port.toString(), ...args];
|
|
323
|
-
|
|
958
|
+
log2("Final childArgs:", JSON.stringify(childArgs));
|
|
324
959
|
const inTmux = !!env2.TMUX;
|
|
325
960
|
const tmuxAvailable = hasTmux();
|
|
326
|
-
|
|
327
|
-
|
|
961
|
+
log2("In tmux?", inTmux);
|
|
962
|
+
log2("Tmux available?", tmuxAvailable);
|
|
328
963
|
if (inTmux || !tmuxAvailable) {
|
|
329
|
-
|
|
330
|
-
const child = spawn(opencodeBin, childArgs, {
|
|
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
|
-
|
|
970
|
+
log2("ERROR spawning child:", err.message);
|
|
333
971
|
});
|
|
334
972
|
child.on("close", (code) => {
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
352
|
-
const tmuxArgs = [
|
|
353
|
-
|
|
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
|
-
|
|
994
|
+
log2("ERROR spawning tmux:", err.message);
|
|
360
995
|
});
|
|
361
996
|
child.on("close", (code) => {
|
|
362
|
-
|
|
997
|
+
log2("Tmux exited with code:", code);
|
|
363
998
|
exit(code ?? 0);
|
|
364
999
|
});
|
|
365
1000
|
}
|
|
366
1001
|
}
|
|
367
1002
|
main().catch((err) => {
|
|
368
|
-
|
|
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
|
});
|