opencode-agent-tmux 1.0.0

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/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # opencode-agent-tmux
2
+
3
+ OpenCode plugin that provides tmux integration for viewing agent execution in real-time. Automatically spawns panes, streams output, and manages your terminal workspace.
4
+
5
+ ## 🤖 For Humans (Quick Start)
6
+
7
+ Want to get started immediately? Just paste this prompt into your OpenCode agent (or any other agentic tool like Claude Code) and let it handle the setup for you:
8
+
9
+ ```text
10
+ Please install the opencode-agent-tmux plugin for me.
11
+
12
+ 1. Clone the repository to ~/Code/opencode-agent-tmux
13
+ 2. Run 'bun install' and 'bun run build' inside the directory
14
+ 3. Add the plugin path to my ~/.config/opencode/opencode.json file
15
+ 4. Verify the installation by running 'opencode --version'
16
+
17
+ The plugin repo is: https://github.com/AnganSamadder/opencode-agent-tmux.git
18
+ ```
19
+
20
+ ## ✨ Features
21
+
22
+ - **Automatic Tmux Pane Spawning**: When any agent starts, automatically spawns a tmux pane
23
+ - **Live Streaming**: Each pane runs `opencode attach` to show real-time agent output
24
+ - **Auto-Cleanup**: Panes automatically close when agents complete
25
+ - **Configurable Layout**: Support multiple tmux layouts (main-vertical, tiled, etc.)
26
+ - **Multi-Port Support**: Automatically finds available ports when running multiple instances
27
+ - **Agent-Agnostic**: Works with oh-my-opencode, omoc-slim, or vanilla OpenCode
28
+
29
+ ## 📋 Requirements
30
+
31
+ - **OpenCode**
32
+ - **tmux**
33
+ - **Bun** (for building)
34
+
35
+ ## 📦 Installation (Official)
36
+
37
+ 1. **Install via NPM:**
38
+ ```bash
39
+ npm install -g opencode-agent-tmux
40
+ ```
41
+ *Note: The installation automatically configures a shell alias to enable the smart tmux wrapper.*
42
+
43
+ 2. **Configure OpenCode:**
44
+ Add the plugin name to your `~/.config/opencode/opencode.json`:
45
+ ```json
46
+ {
47
+ "plugins": [
48
+ "opencode-agent-tmux"
49
+ ]
50
+ }
51
+ ```
52
+
53
+ ## 🛠 Manual Installation (Development)
54
+
55
+ If you prefer to install it yourself:
56
+
57
+ 1. **Clone the repository:**
58
+ ```bash
59
+ git clone https://github.com/AnganSamadder/opencode-agent-tmux.git ~/Code/opencode-agent-tmux
60
+ ```
61
+
62
+ 2. **Build the plugin:**
63
+ ```bash
64
+ cd ~/Code/opencode-agent-tmux
65
+ bun install
66
+ bun run build
67
+ ```
68
+
69
+ 3. **Configure OpenCode:**
70
+ Add the plugin to your `~/.config/opencode/opencode.json`:
71
+ ```json
72
+ {
73
+ "plugins": [
74
+ "~/Code/opencode-agent-tmux"
75
+ ]
76
+ }
77
+ ```
78
+
79
+ ## 🚀 Usage
80
+
81
+ ### Easy Mode (Recommended)
82
+
83
+ After installation, just type:
84
+
85
+ ```bash
86
+ opencode
87
+ ```
88
+
89
+ The wrapper automatically:
90
+ - Launches tmux if you're not already in it
91
+ - Finds an available port (4096-4106) if default is in use
92
+ - Starts OpenCode with the available port
93
+ - Enables the plugin to spawn panes for agents
94
+
95
+ ### Running Multiple Instances
96
+
97
+ Want to run multiple OpenCode sessions? No problem:
98
+
99
+ ```bash
100
+ # Terminal 1
101
+ opencode
102
+ # → Starts on port 4096
103
+
104
+ # Terminal 2
105
+ opencode
106
+ # → Detects 4096 in use, automatically uses port 4097
107
+ ```
108
+
109
+ Each instance works independently with its own tmux panes!
110
+
111
+ ### Manual Mode
112
+
113
+ Or start OpenCode inside tmux manually:
114
+
115
+ ```bash
116
+ tmux
117
+ opencode --port 4096
118
+ ```
119
+
120
+ ## ⚙️ Configuration
121
+
122
+ You can customize behavior by creating `~/.config/opencode/opencode-agent-tmux.json`:
123
+
124
+ ```json
125
+ {
126
+ "enabled": true,
127
+ "port": 4096,
128
+ "layout": "main-vertical",
129
+ "main_pane_size": 60,
130
+ "auto_close": true
131
+ }
132
+ ```
133
+
134
+ | Option | Type | Default | Description |
135
+ |--------|------|---------|-------------|
136
+ | `enabled` | boolean | `true` | Enable/disable the plugin |
137
+ | `port` | number | `4096` | OpenCode server port |
138
+ | `layout` | string | `"main-vertical"` | Tmux layout: `main-horizontal`, `main-vertical`, `tiled`, etc. |
139
+ | `main_pane_size` | number | `60` | Size of main pane (20-80%) |
140
+ | `auto_close` | boolean | `true` | Auto-close panes when sessions complete |
141
+
142
+ ## ❓ Troubleshooting
143
+
144
+ ### Panes Not Spawning
145
+ 1. Verify you're inside tmux: `echo $TMUX`
146
+ 2. Check tmux is installed: `which tmux`
147
+ 3. Check OpenCode server is running with port: `opencode --port 4096`
148
+ 4. Check logs: `cat /tmp/opencode-agent-tmux.log`
149
+
150
+ ### Server Not Found
151
+ Make sure OpenCode is started with the `--port` flag matching your config:
152
+ ```bash
153
+ opencode --port 4096
154
+ ```
155
+
156
+ ## 📄 License
157
+
158
+ MIT
159
+
160
+ ## 🙏 Acknowledgements
161
+
162
+ This project extracts and improves upon the tmux session management from [oh-my-opencode-slim](https://github.com/alvinunreal/oh-my-opencode-slim) by alvinunreal.
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, execSync } from 'child_process';
4
+ import { createServer } from 'net';
5
+ import { env, platform, exit, argv } from 'process';
6
+ import { existsSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { homedir } from 'os';
9
+
10
+ const OPENCODE_PORT_START = parseInt(env.OPENCODE_PORT || '4096', 10);
11
+ const OPENCODE_PORT_MAX = OPENCODE_PORT_START + 10;
12
+
13
+ function findOpencodeBin() {
14
+ try {
15
+ const cmd = platform === 'win32' ? 'where opencode' : 'which -a opencode';
16
+ const output = execSync(cmd, { encoding: 'utf-8' }).trim().split('\n');
17
+ const currentScript = process.argv[1];
18
+
19
+ for (const bin of output) {
20
+ const normalizedBin = bin.trim();
21
+ if (normalizedBin.includes('opencode-tmux') || normalizedBin === currentScript) continue;
22
+ if (normalizedBin) return normalizedBin;
23
+ }
24
+ } catch (e) {
25
+ }
26
+
27
+ const commonPaths = [
28
+ join(homedir(), '.opencode', 'bin', platform === 'win32' ? 'opencode.exe' : 'opencode'),
29
+ join(homedir(), 'AppData', 'Local', 'opencode', 'bin', 'opencode.exe'),
30
+ '/usr/local/bin/opencode',
31
+ '/usr/bin/opencode'
32
+ ];
33
+
34
+ for (const p of commonPaths) {
35
+ if (existsSync(p)) return p;
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ function checkPort(port) {
42
+ return new Promise((resolve) => {
43
+ const server = createServer();
44
+ server.listen(port, '127.0.0.1');
45
+ server.on('listening', () => {
46
+ server.close();
47
+ resolve(true);
48
+ });
49
+ server.on('error', () => {
50
+ resolve(false);
51
+ });
52
+ });
53
+ }
54
+
55
+ async function findAvailablePort() {
56
+ for (let port = OPENCODE_PORT_START; port <= OPENCODE_PORT_MAX; port++) {
57
+ if (await checkPort(port)) return port;
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function hasTmux() {
63
+ try {
64
+ execSync('tmux -V', { stdio: 'ignore' });
65
+ return true;
66
+ } catch (e) {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ async function main() {
72
+ const opencodeBin = findOpencodeBin();
73
+ if (!opencodeBin) {
74
+ console.error("❌ Error: Could not find 'opencode' binary.");
75
+ console.error(" Please ensure OpenCode is installed and in your PATH.");
76
+ exit(1);
77
+ }
78
+
79
+ const port = await findAvailablePort();
80
+ if (!port) {
81
+ console.error("❌ No ports available in range " + OPENCODE_PORT_START + "-" + OPENCODE_PORT_MAX);
82
+ exit(1);
83
+ }
84
+
85
+ if (port !== OPENCODE_PORT_START) {
86
+ console.warn(`⚠️ Port ${OPENCODE_PORT_START} is in use, using port ${port} instead`);
87
+ }
88
+
89
+ env.OPENCODE_PORT = port.toString();
90
+ const args = argv.slice(2);
91
+ const childArgs = ['--port', port.toString(), ...args];
92
+
93
+ const inTmux = !!env.TMUX;
94
+ const tmuxAvailable = hasTmux();
95
+
96
+ if (inTmux || !tmuxAvailable) {
97
+ const child = spawn(opencodeBin, childArgs, { stdio: 'inherit' });
98
+ child.on('close', (code) => exit(code));
99
+
100
+ process.on('SIGINT', () => child.kill('SIGINT'));
101
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
102
+
103
+ } else {
104
+ console.log("🚀 Launching tmux session...");
105
+
106
+ const safeCommand = [
107
+ `"${opencodeBin}"`,
108
+ `--port ${port}`,
109
+ ...args.map(a => `"${a}"`)
110
+ ].join(' ');
111
+
112
+ const shellCommand = `${safeCommand} || { echo "Exit code: $?"; echo "Press Enter to close..."; read; }`;
113
+
114
+ const tmuxArgs = [
115
+ 'new-session',
116
+ shellCommand
117
+ ];
118
+
119
+ const child = spawn('tmux', tmuxArgs, { stdio: 'inherit' });
120
+ child.on('close', (code) => exit(code));
121
+ }
122
+ }
123
+
124
+ main().catch(err => {
125
+ console.error(err);
126
+ exit(1);
127
+ });
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+
3
+ interface PluginInput {
4
+ directory: string;
5
+ serverUrl?: URL | string;
6
+ client: {
7
+ session: {
8
+ status(): Promise<{
9
+ data?: Record<string, {
10
+ type: string;
11
+ }>;
12
+ }>;
13
+ subscribe(callback: (event: {
14
+ type: string;
15
+ properties?: unknown;
16
+ }) => void): () => void;
17
+ };
18
+ };
19
+ }
20
+ interface PluginOutput {
21
+ name: string;
22
+ event?: (input: {
23
+ event: {
24
+ type: string;
25
+ properties?: unknown;
26
+ };
27
+ }) => Promise<void>;
28
+ tool?: Record<string, unknown>;
29
+ config?: unknown;
30
+ }
31
+ type Plugin = (ctx: PluginInput) => Promise<PluginOutput>;
32
+
33
+ declare const TmuxLayoutSchema: z.ZodEnum<["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"]>;
34
+ type TmuxLayout = z.infer<typeof TmuxLayoutSchema>;
35
+ declare const TmuxConfigSchema: z.ZodObject<{
36
+ enabled: z.ZodDefault<z.ZodBoolean>;
37
+ layout: z.ZodDefault<z.ZodEnum<["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"]>>;
38
+ main_pane_size: z.ZodDefault<z.ZodNumber>;
39
+ }, "strip", z.ZodTypeAny, {
40
+ enabled: boolean;
41
+ layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical";
42
+ main_pane_size: number;
43
+ }, {
44
+ enabled?: boolean | undefined;
45
+ layout?: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" | undefined;
46
+ main_pane_size?: number | undefined;
47
+ }>;
48
+ type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
49
+ declare const PluginConfigSchema: z.ZodObject<{
50
+ enabled: z.ZodDefault<z.ZodBoolean>;
51
+ port: z.ZodDefault<z.ZodNumber>;
52
+ layout: z.ZodDefault<z.ZodEnum<["main-horizontal", "main-vertical", "tiled", "even-horizontal", "even-vertical"]>>;
53
+ main_pane_size: z.ZodDefault<z.ZodNumber>;
54
+ auto_close: z.ZodDefault<z.ZodBoolean>;
55
+ }, "strip", z.ZodTypeAny, {
56
+ enabled: boolean;
57
+ port: number;
58
+ layout: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical";
59
+ main_pane_size: number;
60
+ auto_close: boolean;
61
+ }, {
62
+ enabled?: boolean | undefined;
63
+ port?: number | undefined;
64
+ layout?: "main-horizontal" | "main-vertical" | "tiled" | "even-horizontal" | "even-vertical" | undefined;
65
+ main_pane_size?: number | undefined;
66
+ auto_close?: boolean | undefined;
67
+ }>;
68
+ type PluginConfig = z.infer<typeof PluginConfigSchema>;
69
+
70
+ declare const OpencodeAgentTmux: Plugin;
71
+
72
+ export { type PluginConfig, type TmuxConfig, type TmuxLayout, OpencodeAgentTmux as default };
package/dist/index.js ADDED
@@ -0,0 +1,508 @@
1
+ // src/index.ts
2
+ import * as fs2 from "fs";
3
+ import * as path2 from "path";
4
+
5
+ // src/config.ts
6
+ import { z } from "zod";
7
+ var TmuxLayoutSchema = z.enum([
8
+ "main-horizontal",
9
+ "main-vertical",
10
+ "tiled",
11
+ "even-horizontal",
12
+ "even-vertical"
13
+ ]);
14
+ var TmuxConfigSchema = z.object({
15
+ enabled: z.boolean().default(true),
16
+ layout: TmuxLayoutSchema.default("main-vertical"),
17
+ main_pane_size: z.number().min(20).max(80).default(60)
18
+ });
19
+ var PluginConfigSchema = z.object({
20
+ enabled: z.boolean().default(true),
21
+ port: z.number().default(4096),
22
+ layout: TmuxLayoutSchema.default("main-vertical"),
23
+ main_pane_size: z.number().min(20).max(80).default(60),
24
+ auto_close: z.boolean().default(true)
25
+ });
26
+ var POLL_INTERVAL_MS = 2e3;
27
+ var SESSION_TIMEOUT_MS = 10 * 60 * 1e3;
28
+ var SESSION_MISSING_GRACE_MS = POLL_INTERVAL_MS * 3;
29
+
30
+ // src/utils/logger.ts
31
+ import * as fs from "fs";
32
+ import * as os from "os";
33
+ import * as path from "path";
34
+ var logFile = path.join(os.tmpdir(), "opencode-agent-tmux.log");
35
+ function log(message, data) {
36
+ try {
37
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
38
+ const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}
39
+ `;
40
+ fs.appendFileSync(logFile, logEntry);
41
+ } catch {
42
+ }
43
+ }
44
+
45
+ // src/utils/tmux.ts
46
+ import { spawn } from "child_process";
47
+ var tmuxPath = null;
48
+ var tmuxChecked = false;
49
+ var storedConfig = null;
50
+ var serverAvailable = null;
51
+ var serverCheckUrl = null;
52
+ async function spawnAsync(command, options) {
53
+ return new Promise((resolve) => {
54
+ const [cmd, ...args] = command;
55
+ const proc = spawn(cmd, args, { stdio: "pipe" });
56
+ let stdout = "";
57
+ let stderr = "";
58
+ if (!options?.ignoreOutput) {
59
+ proc.stdout?.on("data", (data) => {
60
+ stdout += data.toString();
61
+ });
62
+ proc.stderr?.on("data", (data) => {
63
+ stderr += data.toString();
64
+ });
65
+ }
66
+ proc.on("close", (code) => {
67
+ resolve({
68
+ exitCode: code ?? 1,
69
+ stdout,
70
+ stderr
71
+ });
72
+ });
73
+ proc.on("error", () => {
74
+ resolve({
75
+ exitCode: 1,
76
+ stdout,
77
+ stderr
78
+ });
79
+ });
80
+ });
81
+ }
82
+ async function isServerRunning(serverUrl) {
83
+ if (serverCheckUrl === serverUrl && serverAvailable === true) {
84
+ return true;
85
+ }
86
+ const healthUrl = new URL("/health", serverUrl).toString();
87
+ const timeoutMs = 3e3;
88
+ const maxAttempts = 2;
89
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
90
+ const controller = new AbortController();
91
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
92
+ let response = null;
93
+ try {
94
+ response = await fetch(healthUrl, { signal: controller.signal }).catch(
95
+ () => null
96
+ );
97
+ } finally {
98
+ clearTimeout(timeout);
99
+ }
100
+ const available = response?.ok ?? false;
101
+ if (available) {
102
+ serverCheckUrl = serverUrl;
103
+ serverAvailable = true;
104
+ log("[tmux] isServerRunning: checked", { serverUrl, available, attempt });
105
+ return true;
106
+ }
107
+ if (attempt < maxAttempts) {
108
+ await new Promise((r) => setTimeout(r, 250));
109
+ }
110
+ }
111
+ log("[tmux] isServerRunning: checked", { serverUrl, available: false });
112
+ return false;
113
+ }
114
+ async function findTmuxPath() {
115
+ const isWindows = process.platform === "win32";
116
+ const cmd = isWindows ? "where" : "which";
117
+ try {
118
+ const result = await spawnAsync([cmd, "tmux"]);
119
+ if (result.exitCode !== 0) {
120
+ log("[tmux] findTmuxPath: 'which tmux' failed", {
121
+ exitCode: result.exitCode
122
+ });
123
+ return null;
124
+ }
125
+ const path3 = result.stdout.trim().split("\n")[0];
126
+ if (!path3) {
127
+ log("[tmux] findTmuxPath: no path in output");
128
+ return null;
129
+ }
130
+ const verifyResult = await spawnAsync([path3, "-V"]);
131
+ if (verifyResult.exitCode !== 0) {
132
+ log("[tmux] findTmuxPath: tmux -V failed", {
133
+ path: path3,
134
+ verifyExit: verifyResult.exitCode
135
+ });
136
+ return null;
137
+ }
138
+ log("[tmux] findTmuxPath: found tmux", { path: path3 });
139
+ return path3;
140
+ } catch (err) {
141
+ log("[tmux] findTmuxPath: exception", { error: String(err) });
142
+ return null;
143
+ }
144
+ }
145
+ async function getTmuxPath() {
146
+ if (tmuxChecked) {
147
+ return tmuxPath;
148
+ }
149
+ tmuxPath = await findTmuxPath();
150
+ tmuxChecked = true;
151
+ log("[tmux] getTmuxPath: initialized", { tmuxPath });
152
+ return tmuxPath;
153
+ }
154
+ function isInsideTmux() {
155
+ return !!process.env.TMUX;
156
+ }
157
+ async function applyLayout(tmux, layout, mainPaneSize) {
158
+ try {
159
+ await spawnAsync([tmux, "select-layout", layout]);
160
+ if (layout === "main-horizontal" || layout === "main-vertical") {
161
+ const sizeOption = layout === "main-horizontal" ? "main-pane-height" : "main-pane-width";
162
+ await spawnAsync([
163
+ tmux,
164
+ "set-window-option",
165
+ sizeOption,
166
+ `${mainPaneSize}%`
167
+ ]);
168
+ await spawnAsync([tmux, "select-layout", layout]);
169
+ }
170
+ log("[tmux] applyLayout: applied", { layout, mainPaneSize });
171
+ } catch (err) {
172
+ log("[tmux] applyLayout: exception", { error: String(err) });
173
+ }
174
+ }
175
+ async function spawnTmuxPane(sessionId, description, config, serverUrl) {
176
+ log("[tmux] spawnTmuxPane called", {
177
+ sessionId,
178
+ description,
179
+ config,
180
+ serverUrl
181
+ });
182
+ if (!config.enabled) {
183
+ log("[tmux] spawnTmuxPane: config.enabled is false, skipping");
184
+ return { success: false };
185
+ }
186
+ if (!isInsideTmux()) {
187
+ log("[tmux] spawnTmuxPane: not inside tmux, skipping");
188
+ return { success: false };
189
+ }
190
+ const serverRunning = await isServerRunning(serverUrl);
191
+ if (!serverRunning) {
192
+ const defaultPort = process.env.OPENCODE_PORT ?? "4096";
193
+ log("[tmux] spawnTmuxPane: OpenCode server not running, skipping", {
194
+ serverUrl,
195
+ hint: `Start opencode with --port ${defaultPort}`
196
+ });
197
+ return { success: false };
198
+ }
199
+ const tmux = await getTmuxPath();
200
+ if (!tmux) {
201
+ log("[tmux] spawnTmuxPane: tmux binary not found, skipping");
202
+ return { success: false };
203
+ }
204
+ storedConfig = config;
205
+ try {
206
+ const opencodeCmd = `opencode attach ${serverUrl} --session ${sessionId}`;
207
+ const args = [
208
+ "split-window",
209
+ "-h",
210
+ "-d",
211
+ "-P",
212
+ "-F",
213
+ "#{pane_id}",
214
+ opencodeCmd
215
+ ];
216
+ log("[tmux] spawnTmuxPane: executing", { tmux, args, opencodeCmd });
217
+ const result = await spawnAsync([tmux, ...args]);
218
+ const paneId = result.stdout.trim();
219
+ log("[tmux] spawnTmuxPane: split result", {
220
+ exitCode: result.exitCode,
221
+ paneId,
222
+ stderr: result.stderr.trim()
223
+ });
224
+ if (result.exitCode === 0 && paneId) {
225
+ await spawnAsync(
226
+ [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
227
+ { ignoreOutput: true }
228
+ );
229
+ const layout = config.layout ?? "main-vertical";
230
+ const mainPaneSize = config.main_pane_size ?? 60;
231
+ await applyLayout(tmux, layout, mainPaneSize);
232
+ log("[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied", {
233
+ paneId,
234
+ layout
235
+ });
236
+ return { success: true, paneId };
237
+ }
238
+ return { success: false };
239
+ } catch (err) {
240
+ log("[tmux] spawnTmuxPane: exception", { error: String(err) });
241
+ return { success: false };
242
+ }
243
+ }
244
+ async function closeTmuxPane(paneId) {
245
+ log("[tmux] closeTmuxPane called", { paneId });
246
+ if (!paneId) {
247
+ log("[tmux] closeTmuxPane: no paneId provided");
248
+ return false;
249
+ }
250
+ const tmux = await getTmuxPath();
251
+ if (!tmux) {
252
+ log("[tmux] closeTmuxPane: tmux binary not found");
253
+ return false;
254
+ }
255
+ try {
256
+ const result = await spawnAsync([tmux, "kill-pane", "-t", paneId]);
257
+ log("[tmux] closeTmuxPane: result", {
258
+ exitCode: result.exitCode,
259
+ stderr: result.stderr.trim()
260
+ });
261
+ if (result.exitCode === 0) {
262
+ log("[tmux] closeTmuxPane: SUCCESS, pane closed", { paneId });
263
+ if (storedConfig) {
264
+ const layout = storedConfig.layout ?? "main-vertical";
265
+ const mainPaneSize = storedConfig.main_pane_size ?? 60;
266
+ await applyLayout(tmux, layout, mainPaneSize);
267
+ log("[tmux] closeTmuxPane: layout reapplied", { layout });
268
+ }
269
+ return true;
270
+ }
271
+ log("[tmux] closeTmuxPane: failed (pane may already be closed)", {
272
+ paneId
273
+ });
274
+ return false;
275
+ } catch (err) {
276
+ log("[tmux] closeTmuxPane: exception", { error: String(err) });
277
+ return false;
278
+ }
279
+ }
280
+ function startTmuxCheck() {
281
+ if (!tmuxChecked) {
282
+ getTmuxPath().catch(() => {
283
+ });
284
+ }
285
+ }
286
+
287
+ // src/tmux-session-manager.ts
288
+ var TmuxSessionManager = class {
289
+ client;
290
+ tmuxConfig;
291
+ serverUrl;
292
+ sessions = /* @__PURE__ */ new Map();
293
+ pollInterval;
294
+ enabled = false;
295
+ constructor(ctx, tmuxConfig, serverUrl) {
296
+ this.client = ctx.client;
297
+ this.tmuxConfig = tmuxConfig;
298
+ this.serverUrl = serverUrl;
299
+ this.enabled = tmuxConfig.enabled && isInsideTmux();
300
+ log("[tmux-session-manager] initialized", {
301
+ enabled: this.enabled,
302
+ tmuxConfig: this.tmuxConfig,
303
+ serverUrl: this.serverUrl
304
+ });
305
+ }
306
+ async onSessionCreated(event) {
307
+ if (!this.enabled) return;
308
+ if (event.type !== "session.created") return;
309
+ const info = event.properties?.info;
310
+ if (!info?.id || !info?.parentID) {
311
+ return;
312
+ }
313
+ const sessionId = info.id;
314
+ const parentId = info.parentID;
315
+ const title = info.title ?? "Subagent";
316
+ if (this.sessions.has(sessionId)) {
317
+ log("[tmux-session-manager] session already tracked", { sessionId });
318
+ return;
319
+ }
320
+ log("[tmux-session-manager] child session created, spawning pane", {
321
+ sessionId,
322
+ parentId,
323
+ title
324
+ });
325
+ const paneResult = await spawnTmuxPane(
326
+ sessionId,
327
+ title,
328
+ this.tmuxConfig,
329
+ this.serverUrl
330
+ ).catch((err) => {
331
+ log("[tmux-session-manager] failed to spawn pane", {
332
+ error: String(err)
333
+ });
334
+ return { success: false, paneId: void 0 };
335
+ });
336
+ if (paneResult.success && paneResult.paneId) {
337
+ const now = Date.now();
338
+ this.sessions.set(sessionId, {
339
+ sessionId,
340
+ paneId: paneResult.paneId,
341
+ parentId,
342
+ title,
343
+ createdAt: now,
344
+ lastSeenAt: now
345
+ });
346
+ log("[tmux-session-manager] pane spawned", {
347
+ sessionId,
348
+ paneId: paneResult.paneId
349
+ });
350
+ this.startPolling();
351
+ }
352
+ }
353
+ startPolling() {
354
+ if (this.pollInterval) return;
355
+ this.pollInterval = setInterval(
356
+ () => this.pollSessions(),
357
+ POLL_INTERVAL_MS
358
+ );
359
+ log("[tmux-session-manager] polling started");
360
+ }
361
+ stopPolling() {
362
+ if (this.pollInterval) {
363
+ clearInterval(this.pollInterval);
364
+ this.pollInterval = void 0;
365
+ log("[tmux-session-manager] polling stopped");
366
+ }
367
+ }
368
+ async pollSessions() {
369
+ if (this.sessions.size === 0) {
370
+ this.stopPolling();
371
+ return;
372
+ }
373
+ try {
374
+ const statusResult = await this.client.session.status();
375
+ const allStatuses = statusResult.data ?? {};
376
+ const now = Date.now();
377
+ const sessionsToClose = [];
378
+ for (const [sessionId, tracked] of this.sessions.entries()) {
379
+ const status = allStatuses[sessionId];
380
+ const isIdle = status?.type === "idle";
381
+ if (status) {
382
+ tracked.lastSeenAt = now;
383
+ tracked.missingSince = void 0;
384
+ } else if (!tracked.missingSince) {
385
+ tracked.missingSince = now;
386
+ }
387
+ const missingTooLong = !!tracked.missingSince && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
388
+ const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
389
+ if (isIdle || missingTooLong || isTimedOut) {
390
+ sessionsToClose.push(sessionId);
391
+ }
392
+ }
393
+ for (const sessionId of sessionsToClose) {
394
+ await this.closeSession(sessionId);
395
+ }
396
+ } catch (err) {
397
+ log("[tmux-session-manager] poll error", { error: String(err) });
398
+ }
399
+ }
400
+ async closeSession(sessionId) {
401
+ const tracked = this.sessions.get(sessionId);
402
+ if (!tracked) return;
403
+ log("[tmux-session-manager] closing session pane", {
404
+ sessionId,
405
+ paneId: tracked.paneId
406
+ });
407
+ await closeTmuxPane(tracked.paneId);
408
+ this.sessions.delete(sessionId);
409
+ if (this.sessions.size === 0) {
410
+ this.stopPolling();
411
+ }
412
+ }
413
+ createEventHandler() {
414
+ return async (input) => {
415
+ await this.onSessionCreated(input.event);
416
+ };
417
+ }
418
+ async cleanup() {
419
+ this.stopPolling();
420
+ if (this.sessions.size > 0) {
421
+ log("[tmux-session-manager] closing all panes", {
422
+ count: this.sessions.size
423
+ });
424
+ const closePromises = Array.from(this.sessions.values()).map(
425
+ (s) => closeTmuxPane(s.paneId).catch(
426
+ (err) => log("[tmux-session-manager] cleanup error for pane", {
427
+ paneId: s.paneId,
428
+ error: String(err)
429
+ })
430
+ )
431
+ );
432
+ await Promise.all(closePromises);
433
+ this.sessions.clear();
434
+ }
435
+ log("[tmux-session-manager] cleanup complete");
436
+ }
437
+ };
438
+
439
+ // src/index.ts
440
+ function detectServerUrl() {
441
+ if (process.env.OPENCODE_PORT) {
442
+ return `http://localhost:${process.env.OPENCODE_PORT}`;
443
+ }
444
+ return "http://localhost:4096";
445
+ }
446
+ function loadConfig(directory) {
447
+ const configPaths = [
448
+ path2.join(directory, "opencode-agent-tmux.json"),
449
+ path2.join(
450
+ process.env.HOME ?? "",
451
+ ".config",
452
+ "opencode",
453
+ "opencode-agent-tmux.json"
454
+ )
455
+ ];
456
+ for (const configPath of configPaths) {
457
+ try {
458
+ if (fs2.existsSync(configPath)) {
459
+ const content = fs2.readFileSync(configPath, "utf-8");
460
+ const parsed = JSON.parse(content);
461
+ const result = PluginConfigSchema.safeParse(parsed);
462
+ if (result.success) {
463
+ log("[plugin] loaded config", { configPath, config: result.data });
464
+ return result.data;
465
+ }
466
+ log("[plugin] config parse error", {
467
+ configPath,
468
+ error: result.error.message
469
+ });
470
+ }
471
+ } catch (err) {
472
+ log("[plugin] config load error", { configPath, error: String(err) });
473
+ }
474
+ }
475
+ const defaultConfig = PluginConfigSchema.parse({});
476
+ log("[plugin] using default config", { config: defaultConfig });
477
+ return defaultConfig;
478
+ }
479
+ var OpencodeAgentTmux = async (ctx) => {
480
+ const config = loadConfig(ctx.directory);
481
+ const tmuxConfig = {
482
+ enabled: config.enabled,
483
+ layout: config.layout,
484
+ main_pane_size: config.main_pane_size
485
+ };
486
+ const serverUrl = ctx.serverUrl?.toString() || detectServerUrl();
487
+ log("[plugin] initialized", {
488
+ tmuxConfig,
489
+ directory: ctx.directory,
490
+ serverUrl
491
+ });
492
+ if (tmuxConfig.enabled) {
493
+ startTmuxCheck();
494
+ }
495
+ const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig, serverUrl);
496
+ return {
497
+ name: "opencode-agent-tmux",
498
+ event: async (input) => {
499
+ await tmuxSessionManager.onSessionCreated(
500
+ input.event
501
+ );
502
+ }
503
+ };
504
+ };
505
+ var index_default = OpencodeAgentTmux;
506
+ export {
507
+ index_default as default
508
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "opencode-agent-tmux",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode plugin that provides tmux integration for viewing agent execution in real-time",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "bin": {
9
+ "opencode-tmux": "./bin/opencode-tmux.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "bin",
14
+ "scripts"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsup src/index.ts --format esm --dts --clean",
18
+ "dev": "tsup src/index.ts --format esm --dts --watch",
19
+ "typecheck": "tsc --noEmit",
20
+ "prepublishOnly": "bun run build",
21
+ "postinstall": "node scripts/install.js"
22
+ },
23
+ "keywords": [
24
+ "opencode",
25
+ "opencode-plugin",
26
+ "tmux",
27
+ "agent"
28
+ ],
29
+ "author": "",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "zod": "^3.24.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/bun": "^1.2.4",
36
+ "tsup": "^8.3.6",
37
+ "typescript": "^5.7.3"
38
+ }
39
+ }
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+
7
+ const HOME = os.homedir();
8
+
9
+ function detectShell() {
10
+ const shell = process.env.SHELL || '';
11
+ const platform = process.platform;
12
+
13
+ if (platform === 'win32') {
14
+ const documents = path.join(HOME, 'Documents');
15
+ const psDir = path.join(documents, 'PowerShell');
16
+ const psProfile = path.join(psDir, 'Microsoft.PowerShell_profile.ps1');
17
+ return {
18
+ name: 'powershell',
19
+ rcFile: psProfile,
20
+ dir: psDir
21
+ };
22
+ }
23
+
24
+ if (shell.includes('zsh')) {
25
+ return { name: 'zsh', rcFile: path.join(HOME, '.zshrc') };
26
+ } else if (shell.includes('bash')) {
27
+ const bashProfile = path.join(HOME, '.bash_profile');
28
+ const bashrc = path.join(HOME, '.bashrc');
29
+ return {
30
+ name: 'bash',
31
+ rcFile: fs.existsSync(bashProfile) ? bashProfile : bashrc
32
+ };
33
+ } else if (shell.includes('fish')) {
34
+ return {
35
+ name: 'fish',
36
+ rcFile: path.join(HOME, '.config', 'fish', 'config.fish')
37
+ };
38
+ }
39
+
40
+ return { name: 'unknown', rcFile: path.join(HOME, '.profile') };
41
+ }
42
+
43
+ function getAliasContent(shellName) {
44
+ if (shellName === 'powershell') {
45
+ return `
46
+ function opencode {
47
+ opencode-tmux $args
48
+ }
49
+ `;
50
+ }
51
+
52
+ return `alias opencode='opencode-tmux'`;
53
+ }
54
+
55
+ function getExportLine() {
56
+ return `export OPENCODE_PORT=4096`;
57
+ }
58
+
59
+ function setupAlias() {
60
+ const shell = detectShell();
61
+
62
+ console.log('');
63
+ console.log('🔧 Setting up opencode-agent-tmux auto-launcher...');
64
+ console.log(` Detected shell: ${shell.name}`);
65
+ console.log(` Config file: ${shell.rcFile}`);
66
+
67
+ if (shell.name === 'powershell') {
68
+ if (!fs.existsSync(shell.dir)) {
69
+ fs.mkdirSync(shell.dir, { recursive: true });
70
+ }
71
+ }
72
+
73
+ if (!fs.existsSync(shell.rcFile)) {
74
+ console.log(` Creating ${shell.rcFile}...`);
75
+ fs.writeFileSync(shell.rcFile, '', 'utf-8');
76
+ }
77
+
78
+ let rcContent = fs.readFileSync(shell.rcFile, 'utf-8');
79
+ const aliasContent = getAliasContent(shell.name);
80
+
81
+ const MARKER_START = '# >>> opencode-agent-tmux >>>';
82
+ const MARKER_END = '# <<< opencode-agent-tmux <<<';
83
+
84
+ const OLD_MARKER_START = '# >>> opencode-subagent-tmux >>>';
85
+ const OLD_MARKER_END = '# <<< opencode-subagent-tmux <<<';
86
+
87
+ if (rcContent.includes(OLD_MARKER_START)) {
88
+ console.log(' Removing old opencode-subagent-tmux alias...');
89
+ const regex = new RegExp(`${OLD_MARKER_START}[\\s\\S]*?${OLD_MARKER_END}\\n?`, 'g');
90
+ rcContent = rcContent.replace(regex, '');
91
+ fs.writeFileSync(shell.rcFile, rcContent, 'utf-8');
92
+ console.log(' ✓ Removed old alias');
93
+ rcContent = fs.readFileSync(shell.rcFile, 'utf-8');
94
+ }
95
+
96
+ if (rcContent.includes(MARKER_START)) {
97
+ console.log(' ✓ Auto-launcher already configured');
98
+ return;
99
+ }
100
+
101
+ let configBlock = '';
102
+ if (shell.name === 'powershell') {
103
+ configBlock = `
104
+ ${MARKER_START}
105
+ $env:OPENCODE_PORT="4096"
106
+ ${aliasContent}
107
+ ${MARKER_END}
108
+ `;
109
+ } else {
110
+ configBlock = `
111
+ ${MARKER_START}
112
+ ${getExportLine()}
113
+ ${aliasContent}
114
+ ${MARKER_END}
115
+ `;
116
+ }
117
+
118
+ fs.appendFileSync(shell.rcFile, configBlock);
119
+
120
+ console.log(' ✓ Auto-launcher configured successfully!');
121
+ console.log('');
122
+ console.log(' To activate now:');
123
+ if (shell.name === 'powershell') {
124
+ console.log(` . ${shell.rcFile}`);
125
+ } else {
126
+ console.log(` source ${shell.rcFile}`);
127
+ }
128
+ console.log('');
129
+ console.log(' Or restart your terminal.');
130
+ console.log('');
131
+ console.log(' Usage: Just type "opencode" and tmux + port 4096 will be auto-configured!');
132
+ console.log('');
133
+ }
134
+
135
+ try {
136
+ setupAlias();
137
+ } catch (error) {
138
+ console.error('');
139
+ console.error('⚠️ Failed to auto-configure shell alias:', error.message);
140
+ console.error('');
141
+ process.exit(0);
142
+ }