opencode-agent-tmux 1.2.1 → 1.2.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/opencode-tmux.js +289 -11
- package/dist/index.js +39 -0
- package/package.json +1 -1
|
@@ -4,14 +4,25 @@
|
|
|
4
4
|
import { spawn, execSync } from "child_process";
|
|
5
5
|
import { createServer } from "net";
|
|
6
6
|
import { env, platform, exit, argv } from "process";
|
|
7
|
-
import { existsSync } from "fs";
|
|
7
|
+
import { existsSync, appendFileSync } from "fs";
|
|
8
8
|
import { join, dirname } from "path";
|
|
9
9
|
import { homedir } from "os";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
var OPENCODE_PORT_START = parseInt(env.OPENCODE_PORT || "4096", 10);
|
|
12
12
|
var OPENCODE_PORT_MAX = OPENCODE_PORT_START + 10;
|
|
13
|
+
var LOG_FILE = "/tmp/opencode-tmux.log";
|
|
14
|
+
var HEALTH_TIMEOUT_MS = 1e3;
|
|
13
15
|
var __filename = fileURLToPath(import.meta.url);
|
|
14
16
|
var __dirname = dirname(__filename);
|
|
17
|
+
function log(...args) {
|
|
18
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
19
|
+
const message = `[${timestamp}] ${args.join(" ")}
|
|
20
|
+
`;
|
|
21
|
+
try {
|
|
22
|
+
appendFileSync(LOG_FILE, message);
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
function spawnPluginUpdater() {
|
|
16
27
|
if (env.OPENCODE_TMUX_DISABLE_UPDATES === "1") return;
|
|
17
28
|
const updaterPath = join(__dirname, "../scripts/update-plugins.js");
|
|
@@ -48,7 +59,6 @@ function findOpencodeBin() {
|
|
|
48
59
|
const commonPaths = [
|
|
49
60
|
join(homedir(), ".opencode", "bin", platform === "win32" ? "opencode.exe" : "opencode"),
|
|
50
61
|
join(homedir(), "AppData", "Local", "opencode", "bin", "opencode.exe"),
|
|
51
|
-
// Common Windows location
|
|
52
62
|
"/usr/local/bin/opencode",
|
|
53
63
|
"/usr/bin/opencode"
|
|
54
64
|
];
|
|
@@ -70,9 +80,229 @@ function checkPort(port) {
|
|
|
70
80
|
});
|
|
71
81
|
});
|
|
72
82
|
}
|
|
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
|
+
function getTmuxPanePids() {
|
|
103
|
+
if (!hasTmux()) return /* @__PURE__ */ new Set();
|
|
104
|
+
const output = safeExec("tmux list-panes -a -F '#{pane_pid}'");
|
|
105
|
+
if (!output) return /* @__PURE__ */ new Set();
|
|
106
|
+
const pids = output.split("\n").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value));
|
|
107
|
+
return new Set(pids);
|
|
108
|
+
}
|
|
109
|
+
async function isOpencodeHealthy(port) {
|
|
110
|
+
const controller = new AbortController();
|
|
111
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
112
|
+
const healthUrl = `http://127.0.0.1:${port}/health`;
|
|
113
|
+
try {
|
|
114
|
+
const response = await fetch(healthUrl, { signal: controller.signal }).catch(
|
|
115
|
+
() => null
|
|
116
|
+
);
|
|
117
|
+
return response?.ok ?? false;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timeout);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
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
|
+
function getProcessStat(pid) {
|
|
135
|
+
const output = safeExec(`ps -p ${pid} -o stat=`);
|
|
136
|
+
return output && output.length > 0 ? output.trim() : null;
|
|
137
|
+
}
|
|
138
|
+
function getProcessTty(pid) {
|
|
139
|
+
const output = safeExec(`ps -p ${pid} -o tty=`);
|
|
140
|
+
return output && output.length > 0 ? output.trim() : null;
|
|
141
|
+
}
|
|
142
|
+
function getTtyProcessIds(tty) {
|
|
143
|
+
const output = safeExec(`ps -t ${tty} -o pid=`);
|
|
144
|
+
if (!output) return [];
|
|
145
|
+
return output.split("\n").map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value));
|
|
146
|
+
}
|
|
147
|
+
function hasOtherTtyProcesses(tty, pid) {
|
|
148
|
+
if (!tty || tty === "?" || tty === "??") return false;
|
|
149
|
+
const ttyPids = getTtyProcessIds(tty);
|
|
150
|
+
return ttyPids.some((ttyPid) => ttyPid !== pid);
|
|
151
|
+
}
|
|
152
|
+
function getParentPid(pid) {
|
|
153
|
+
const output = safeExec(`ps -p ${pid} -o ppid=`);
|
|
154
|
+
if (!output) return null;
|
|
155
|
+
const value = Number.parseInt(output.trim(), 10);
|
|
156
|
+
return Number.isFinite(value) ? value : null;
|
|
157
|
+
}
|
|
158
|
+
function isDescendantOf(pid, ancestors) {
|
|
159
|
+
let current = pid;
|
|
160
|
+
const visited = /* @__PURE__ */ new Set();
|
|
161
|
+
while (current > 1 && !visited.has(current)) {
|
|
162
|
+
if (ancestors.has(current)) return true;
|
|
163
|
+
visited.add(current);
|
|
164
|
+
const parent = getParentPid(current);
|
|
165
|
+
if (!parent || parent <= 1) return false;
|
|
166
|
+
current = parent;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
function isForegroundProcess(pid) {
|
|
171
|
+
const stat = safeExec(`ps -p ${pid} -o stat=`);
|
|
172
|
+
if (!stat) return false;
|
|
173
|
+
return stat.includes("+");
|
|
174
|
+
}
|
|
175
|
+
async function getOpencodeSessionCount(port) {
|
|
176
|
+
const controller = new AbortController();
|
|
177
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
|
178
|
+
const statusUrl = `http://127.0.0.1:${port}/session/status`;
|
|
179
|
+
try {
|
|
180
|
+
const response = await fetch(statusUrl, { signal: controller.signal }).catch(
|
|
181
|
+
() => null
|
|
182
|
+
);
|
|
183
|
+
if (!response?.ok) return null;
|
|
184
|
+
const payload = await response.json().catch(() => null);
|
|
185
|
+
if (!payload || typeof payload !== "object") return null;
|
|
186
|
+
const maybeData = payload.data;
|
|
187
|
+
if (maybeData && typeof maybeData === "object" && !Array.isArray(maybeData)) {
|
|
188
|
+
return Object.keys(maybeData).length;
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(payload)) {
|
|
191
|
+
return Object.keys(payload).length;
|
|
192
|
+
}
|
|
193
|
+
return payload.length;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timeout);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function tryReclaimPort(port, tmuxPanePids) {
|
|
201
|
+
if (platform === "win32") return false;
|
|
202
|
+
const healthy = await isOpencodeHealthy(port);
|
|
203
|
+
const pids = getListeningPids(port);
|
|
204
|
+
const sessionCount = healthy ? await getOpencodeSessionCount(port) : null;
|
|
205
|
+
const idleServer = healthy && sessionCount === 0;
|
|
206
|
+
log(
|
|
207
|
+
"Port scan:",
|
|
208
|
+
port.toString(),
|
|
209
|
+
"healthy",
|
|
210
|
+
String(healthy),
|
|
211
|
+
"sessions",
|
|
212
|
+
sessionCount === null ? "unknown" : sessionCount.toString(),
|
|
213
|
+
"idle",
|
|
214
|
+
String(idleServer),
|
|
215
|
+
"pids",
|
|
216
|
+
pids.length > 0 ? pids.join(",") : "none"
|
|
217
|
+
);
|
|
218
|
+
if (healthy && sessionCount !== null && sessionCount > 0) {
|
|
219
|
+
log("Port in use by active server:", port.toString());
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
if (pids.length === 0) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
let attemptedKill = false;
|
|
226
|
+
for (const pid of pids) {
|
|
227
|
+
const command = getProcessCommand(pid);
|
|
228
|
+
const tty = getProcessTty(pid);
|
|
229
|
+
const stat = getProcessStat(pid);
|
|
230
|
+
const hasTtyPeers = hasOtherTtyProcesses(tty, pid);
|
|
231
|
+
if (!command || !command.includes("opencode")) {
|
|
232
|
+
log("Port owned by non-opencode process, skipping:", port.toString());
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const inTmux = tmuxPanePids.size > 0 && isDescendantOf(pid, tmuxPanePids);
|
|
236
|
+
log(
|
|
237
|
+
"Port process:",
|
|
238
|
+
port.toString(),
|
|
239
|
+
"pid",
|
|
240
|
+
pid.toString(),
|
|
241
|
+
"tty",
|
|
242
|
+
tty ?? "unknown",
|
|
243
|
+
"stat",
|
|
244
|
+
stat ?? "unknown",
|
|
245
|
+
"tmux",
|
|
246
|
+
String(inTmux),
|
|
247
|
+
"ttyPeers",
|
|
248
|
+
String(hasTtyPeers),
|
|
249
|
+
"idle",
|
|
250
|
+
String(idleServer),
|
|
251
|
+
"command",
|
|
252
|
+
command
|
|
253
|
+
);
|
|
254
|
+
if (!idleServer) {
|
|
255
|
+
if (inTmux) {
|
|
256
|
+
log("Port owned by tmux process, skipping:", port.toString(), pid.toString());
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (hasTtyPeers) {
|
|
260
|
+
log("Port owned by active tty process, skipping:", port.toString(), pid.toString());
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (healthy && sessionCount === null) {
|
|
264
|
+
log("Unable to read sessions, skipping:", port.toString(), pid.toString());
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (healthy && sessionCount !== null && sessionCount > 0) {
|
|
268
|
+
log("Port has active sessions, skipping:", port.toString(), pid.toString());
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!healthy && !isForegroundProcess(pid)) {
|
|
272
|
+
log("Port owned by background opencode process, skipping:", port.toString(), pid.toString());
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
log("Attempting to stop stale opencode process:", port.toString(), pid.toString());
|
|
277
|
+
attemptedKill = true;
|
|
278
|
+
try {
|
|
279
|
+
process.kill(pid, "SIGTERM");
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!attemptedKill) return false;
|
|
284
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
285
|
+
for (const pid of pids) {
|
|
286
|
+
if (isProcessAlive(pid)) {
|
|
287
|
+
log("Process still alive, sending SIGKILL:", port.toString(), pid.toString());
|
|
288
|
+
try {
|
|
289
|
+
process.kill(pid, "SIGKILL");
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
295
|
+
return checkPort(port);
|
|
296
|
+
}
|
|
73
297
|
async function findAvailablePort() {
|
|
298
|
+
let tmuxPanePids = null;
|
|
74
299
|
for (let port = OPENCODE_PORT_START; port <= OPENCODE_PORT_MAX; port++) {
|
|
75
300
|
if (await checkPort(port)) return port;
|
|
301
|
+
if (!tmuxPanePids) {
|
|
302
|
+
tmuxPanePids = getTmuxPanePids();
|
|
303
|
+
}
|
|
304
|
+
const reclaimed = await tryReclaimPort(port, tmuxPanePids);
|
|
305
|
+
if (reclaimed && await checkPort(port)) return port;
|
|
76
306
|
}
|
|
77
307
|
return null;
|
|
78
308
|
}
|
|
@@ -85,45 +315,93 @@ function hasTmux() {
|
|
|
85
315
|
}
|
|
86
316
|
}
|
|
87
317
|
async function main() {
|
|
318
|
+
const args = argv.slice(2);
|
|
319
|
+
const isCliCommand = args.length > 0 && (["auth", "config", "plugins", "update", "completion"].includes(args[0]) || ["--version", "-v", "--help", "-h"].includes(args[0]));
|
|
320
|
+
if (isCliCommand) {
|
|
321
|
+
const opencodeBin2 = findOpencodeBin();
|
|
322
|
+
if (!opencodeBin2) {
|
|
323
|
+
console.error(
|
|
324
|
+
'Error: Could not find "opencode" binary in PATH or common locations.'
|
|
325
|
+
);
|
|
326
|
+
exit(1);
|
|
327
|
+
}
|
|
328
|
+
const child = spawn(opencodeBin2, args, {
|
|
329
|
+
stdio: "inherit",
|
|
330
|
+
env: process.env
|
|
331
|
+
});
|
|
332
|
+
child.on("close", (code) => {
|
|
333
|
+
exit(code ?? 0);
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
log("=== OpenCode Tmux Wrapper Started ===");
|
|
338
|
+
log("Process argv:", JSON.stringify(argv));
|
|
339
|
+
log("Current directory:", process.cwd());
|
|
88
340
|
const opencodeBin = findOpencodeBin();
|
|
341
|
+
log("Found opencode binary:", opencodeBin);
|
|
89
342
|
if (!opencodeBin) {
|
|
90
343
|
console.error('Error: Could not find "opencode" binary in PATH or common locations.');
|
|
344
|
+
log("ERROR: opencode binary not found");
|
|
91
345
|
exit(1);
|
|
92
346
|
}
|
|
93
347
|
spawnPluginUpdater();
|
|
94
348
|
const port = await findAvailablePort();
|
|
349
|
+
log("Found available port:", port);
|
|
95
350
|
if (!port) {
|
|
96
351
|
console.error("Error: No available ports found in range 4096-4106.");
|
|
352
|
+
log("ERROR: No available ports");
|
|
97
353
|
exit(1);
|
|
98
354
|
}
|
|
99
355
|
const env2 = { ...process.env };
|
|
100
356
|
env2.OPENCODE_PORT = port.toString();
|
|
101
|
-
|
|
357
|
+
log("User args:", JSON.stringify(args));
|
|
102
358
|
const childArgs = ["--port", port.toString(), ...args];
|
|
359
|
+
log("Final childArgs:", JSON.stringify(childArgs));
|
|
103
360
|
const inTmux = !!env2.TMUX;
|
|
104
361
|
const tmuxAvailable = hasTmux();
|
|
362
|
+
log("In tmux?", inTmux);
|
|
363
|
+
log("Tmux available?", tmuxAvailable);
|
|
105
364
|
if (inTmux || !tmuxAvailable) {
|
|
365
|
+
log("Running directly (in tmux or no tmux available)");
|
|
106
366
|
const child = spawn(opencodeBin, childArgs, { stdio: "inherit", env: env2 });
|
|
107
|
-
child.on("
|
|
367
|
+
child.on("error", (err) => {
|
|
368
|
+
log("ERROR spawning child:", err.message);
|
|
369
|
+
});
|
|
370
|
+
child.on("close", (code) => {
|
|
371
|
+
log("Child exited with code:", code);
|
|
372
|
+
exit(code ?? 0);
|
|
373
|
+
});
|
|
108
374
|
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
109
375
|
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
110
376
|
} else {
|
|
111
377
|
console.log("\u{1F680} Launching tmux session...");
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
378
|
+
log("Launching tmux session");
|
|
379
|
+
const escapedBin = opencodeBin.includes(" ") ? `'${opencodeBin}'` : opencodeBin;
|
|
380
|
+
const escapedArgs = childArgs.map((arg) => {
|
|
381
|
+
if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
|
|
382
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
383
|
+
}
|
|
384
|
+
return arg;
|
|
385
|
+
});
|
|
386
|
+
const shellCommand = `${escapedBin} ${escapedArgs.join(" ")} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`;
|
|
387
|
+
log("Shell command for tmux:", shellCommand);
|
|
118
388
|
const tmuxArgs = [
|
|
119
389
|
"new-session",
|
|
120
390
|
shellCommand
|
|
121
391
|
];
|
|
392
|
+
log("Tmux args:", JSON.stringify(tmuxArgs));
|
|
122
393
|
const child = spawn("tmux", tmuxArgs, { stdio: "inherit", env: env2 });
|
|
123
|
-
child.on("
|
|
394
|
+
child.on("error", (err) => {
|
|
395
|
+
log("ERROR spawning tmux:", err.message);
|
|
396
|
+
});
|
|
397
|
+
child.on("close", (code) => {
|
|
398
|
+
log("Tmux exited with code:", code);
|
|
399
|
+
exit(code ?? 0);
|
|
400
|
+
});
|
|
124
401
|
}
|
|
125
402
|
}
|
|
126
403
|
main().catch((err) => {
|
|
404
|
+
log("FATAL ERROR:", err.message, err.stack);
|
|
127
405
|
console.error(err);
|
|
128
406
|
exit(1);
|
|
129
407
|
});
|
package/dist/index.js
CHANGED
|
@@ -292,6 +292,7 @@ var TmuxSessionManager = class {
|
|
|
292
292
|
sessions = /* @__PURE__ */ new Map();
|
|
293
293
|
pollInterval;
|
|
294
294
|
enabled = false;
|
|
295
|
+
shuttingDown = false;
|
|
295
296
|
constructor(ctx, tmuxConfig, serverUrl) {
|
|
296
297
|
this.client = ctx.client;
|
|
297
298
|
this.tmuxConfig = tmuxConfig;
|
|
@@ -302,6 +303,9 @@ var TmuxSessionManager = class {
|
|
|
302
303
|
tmuxConfig: this.tmuxConfig,
|
|
303
304
|
serverUrl: this.serverUrl
|
|
304
305
|
});
|
|
306
|
+
if (this.enabled) {
|
|
307
|
+
this.registerShutdownHandlers();
|
|
308
|
+
}
|
|
305
309
|
}
|
|
306
310
|
async onSessionCreated(event) {
|
|
307
311
|
if (!this.enabled) return;
|
|
@@ -395,6 +399,41 @@ var TmuxSessionManager = class {
|
|
|
395
399
|
}
|
|
396
400
|
} catch (err) {
|
|
397
401
|
log("[tmux-session-manager] poll error", { error: String(err) });
|
|
402
|
+
const serverAlive = await this.isServerAlive();
|
|
403
|
+
if (!serverAlive) {
|
|
404
|
+
await this.handleShutdown("server-unreachable");
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
registerShutdownHandlers() {
|
|
409
|
+
const handler = (reason) => {
|
|
410
|
+
void this.handleShutdown(reason);
|
|
411
|
+
};
|
|
412
|
+
process.once("SIGINT", () => handler("SIGINT"));
|
|
413
|
+
process.once("SIGTERM", () => handler("SIGTERM"));
|
|
414
|
+
process.once("SIGHUP", () => handler("SIGHUP"));
|
|
415
|
+
process.once("SIGQUIT", () => handler("SIGQUIT"));
|
|
416
|
+
process.once("beforeExit", () => handler("beforeExit"));
|
|
417
|
+
}
|
|
418
|
+
async handleShutdown(reason) {
|
|
419
|
+
if (this.shuttingDown) return;
|
|
420
|
+
this.shuttingDown = true;
|
|
421
|
+
log("[tmux-session-manager] shutdown detected", { reason });
|
|
422
|
+
await this.cleanup();
|
|
423
|
+
}
|
|
424
|
+
async isServerAlive() {
|
|
425
|
+
const healthUrl = new URL("/health", this.serverUrl).toString();
|
|
426
|
+
const controller = new AbortController();
|
|
427
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
428
|
+
try {
|
|
429
|
+
const response = await fetch(healthUrl, { signal: controller.signal }).catch(
|
|
430
|
+
() => null
|
|
431
|
+
);
|
|
432
|
+
return response?.ok ?? false;
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
} finally {
|
|
436
|
+
clearTimeout(timeout);
|
|
398
437
|
}
|
|
399
438
|
}
|
|
400
439
|
async closeSession(sessionId) {
|