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.
@@ -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
- const args = argv.slice(2);
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("close", (code) => exit(code ?? 0));
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
- const safeCommand = [
113
- `"${opencodeBin}"`,
114
- `--port ${port}`,
115
- ...args.map((a) => `"${a}"`)
116
- ].join(" ");
117
- const shellCommand = `${safeCommand} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`;
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("close", (code) => exit(code ?? 0));
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-agent-tmux",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "OpenCode plugin that provides tmux integration for viewing agent execution in real-time",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",