opencode-agent-tmux 1.2.0 → 1.2.2

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,75 @@ function hasTmux() {
85
315
  }
86
316
  }
87
317
  async function main() {
318
+ log("=== OpenCode Tmux Wrapper Started ===");
319
+ log("Process argv:", JSON.stringify(argv));
320
+ log("Current directory:", process.cwd());
88
321
  const opencodeBin = findOpencodeBin();
322
+ log("Found opencode binary:", opencodeBin);
89
323
  if (!opencodeBin) {
90
324
  console.error('Error: Could not find "opencode" binary in PATH or common locations.');
325
+ log("ERROR: opencode binary not found");
91
326
  exit(1);
92
327
  }
93
328
  spawnPluginUpdater();
94
329
  const port = await findAvailablePort();
330
+ log("Found available port:", port);
95
331
  if (!port) {
96
332
  console.error("Error: No available ports found in range 4096-4106.");
333
+ log("ERROR: No available ports");
97
334
  exit(1);
98
335
  }
99
336
  const env2 = { ...process.env };
100
337
  env2.OPENCODE_PORT = port.toString();
101
338
  const args = argv.slice(2);
339
+ log("User args:", JSON.stringify(args));
102
340
  const childArgs = ["--port", port.toString(), ...args];
341
+ log("Final childArgs:", JSON.stringify(childArgs));
103
342
  const inTmux = !!env2.TMUX;
104
343
  const tmuxAvailable = hasTmux();
344
+ log("In tmux?", inTmux);
345
+ log("Tmux available?", tmuxAvailable);
105
346
  if (inTmux || !tmuxAvailable) {
347
+ log("Running directly (in tmux or no tmux available)");
106
348
  const child = spawn(opencodeBin, childArgs, { stdio: "inherit", env: env2 });
107
- child.on("close", (code) => exit(code ?? 0));
349
+ child.on("error", (err) => {
350
+ log("ERROR spawning child:", err.message);
351
+ });
352
+ child.on("close", (code) => {
353
+ log("Child exited with code:", code);
354
+ exit(code ?? 0);
355
+ });
108
356
  process.on("SIGINT", () => child.kill("SIGINT"));
109
357
  process.on("SIGTERM", () => child.kill("SIGTERM"));
110
358
  } else {
111
359
  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; }`;
360
+ log("Launching tmux session");
361
+ const escapedBin = opencodeBin.includes(" ") ? `'${opencodeBin}'` : opencodeBin;
362
+ const escapedArgs = childArgs.map((arg) => {
363
+ if (arg.includes(" ") || arg.includes('"') || arg.includes("'")) {
364
+ return `'${arg.replace(/'/g, "'\\''")}'`;
365
+ }
366
+ return arg;
367
+ });
368
+ const shellCommand = `${escapedBin} ${escapedArgs.join(" ")} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`;
369
+ log("Shell command for tmux:", shellCommand);
118
370
  const tmuxArgs = [
119
371
  "new-session",
120
372
  shellCommand
121
373
  ];
374
+ log("Tmux args:", JSON.stringify(tmuxArgs));
122
375
  const child = spawn("tmux", tmuxArgs, { stdio: "inherit", env: env2 });
123
- child.on("close", (code) => exit(code ?? 0));
376
+ child.on("error", (err) => {
377
+ log("ERROR spawning tmux:", err.message);
378
+ });
379
+ child.on("close", (code) => {
380
+ log("Tmux exited with code:", code);
381
+ exit(code ?? 0);
382
+ });
124
383
  }
125
384
  }
126
385
  main().catch((err) => {
386
+ log("FATAL ERROR:", err.message, err.stack);
127
387
  console.error(err);
128
388
  exit(1);
129
389
  });
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.0",
3
+ "version": "1.2.2",
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",