@ogulcancelik/pi-tmux 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/index.ts +436 -0
  4. package/package.json +40 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Can Celik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # pi-tmux
2
+
3
+ Tmux pane management for [pi](https://github.com/badlogic/pi-mono). Run dev servers, watchers, and long-running processes in named panes without blocking the agent.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@ogulcancelik/pi-tmux
9
+ ```
10
+
11
+ Or add manually to `~/.pi/agent/settings.json`:
12
+
13
+ ```json
14
+ {
15
+ "packages": ["npm:@ogulcancelik/pi-tmux"]
16
+ }
17
+ ```
18
+
19
+ ## What it does
20
+
21
+ Gives the agent a `tmux` tool with five actions:
22
+
23
+ | Action | Description |
24
+ |--------|-------------|
25
+ | **run** | Start a command in a named pane |
26
+ | **read** | Capture output from a named pane |
27
+ | **send** | Send keys (`C-c`, `Enter`, etc.) or literal text to a pane |
28
+ | **stop** | Kill a named pane |
29
+ | **list** | List all managed panes |
30
+
31
+ The agent uses `tmux run` for long-running processes (dev servers, file watchers, builds) and `bash` for short-lived commands. This keeps the agent unblocked while background processes run.
32
+
33
+ ## Layout
34
+
35
+ Pi runs on the left. The first worker pane splits to the right. Additional panes stack vertically below existing ones. You can override with `position: "right"` or `position: "bottom"`.
36
+
37
+ ## How it works
38
+
39
+ - Panes are tagged with `@pi_name` tmux user options for reliable discovery
40
+ - ANSI escape sequences are stripped from captured output
41
+ - Dead panes are automatically replaced on `run`
42
+ - The tool self-disables when pi is not running inside tmux
43
+
44
+ ## Requirements
45
+
46
+ - [pi](https://github.com/badlogic/pi-mono) v0.40+
47
+ - [tmux](https://github.com/tmux/tmux) — pi must be running inside a tmux session
48
+
49
+ ## License
50
+
51
+ MIT
package/index.ts ADDED
@@ -0,0 +1,436 @@
1
+ /**
2
+ * tmux extension — gives the agent named panes for long-running processes.
3
+ *
4
+ * Actions:
5
+ * run — create a named pane (split right) and run a command in it
6
+ * read — capture output from a named pane
7
+ * send — send keys to a named pane (C-c, Enter, q, etc.)
8
+ * stop — kill a named pane
9
+ * list — list all managed panes
10
+ *
11
+ * Panes are tagged with @pi_name tmux user options for discovery.
12
+ * The tool is disabled when not running inside tmux.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
16
+ import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@mariozechner/pi-coding-agent";
17
+ import { StringEnum } from "@mariozechner/pi-ai";
18
+ import { Text } from "@mariozechner/pi-tui";
19
+ import { Type } from "@sinclair/typebox";
20
+
21
+ interface PaneInfo {
22
+ name: string;
23
+ paneId: string;
24
+ alive: boolean;
25
+ command: string;
26
+ pid: string;
27
+ }
28
+
29
+ // Strip ANSI escapes, OSC sequences, and tmux/wezterm wrapping
30
+ function stripAnsi(text: string): string {
31
+ return (
32
+ text
33
+ // OSC sequences (e.g. \x1b]...\x07 or \x1b]...\x1b\\)
34
+ .replace(/\x1b\].*?(?:\x07|\x1b\\)/g, "")
35
+ // tmux passthrough (\x1bPtmux;...\x1b\\)
36
+ .replace(/\x1bPtmux;.*?\x1b\\/g, "")
37
+ // CSI sequences (\x1b[...letter)
38
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
39
+ // Remaining bare escapes
40
+ .replace(/\x1b[^[\]P]/g, "")
41
+ // Carriage returns (terminal rewrite lines)
42
+ .replace(/\r/g, "")
43
+ );
44
+ }
45
+
46
+ export default function (pi: ExtensionAPI) {
47
+ const inTmux = !!process.env.TMUX;
48
+ let myPaneId: string | null = null;
49
+ let myWindowId: string | null = null;
50
+
51
+ // Discover our own pane/window/session on startup
52
+ pi.on("session_start", async () => {
53
+ if (!inTmux) {
54
+ // Disable the tmux tool
55
+ const active = pi.getActiveTools();
56
+ pi.setActiveTools(active.filter((t) => t !== "tmux"));
57
+ return;
58
+ }
59
+
60
+ try {
61
+ const result = await pi.exec("tmux", [
62
+ "display-message",
63
+ "-p",
64
+ "-t",
65
+ process.env.TMUX_PANE || "",
66
+ "#{pane_id}\t#{window_id}\t#{session_id}",
67
+ ]);
68
+ if (result.code === 0) {
69
+ const [paneId, windowId] = result.stdout.trim().split("\t");
70
+ myPaneId = paneId || null;
71
+ myWindowId = windowId || null;
72
+ }
73
+ } catch {}
74
+ });
75
+
76
+ // --- helpers ---
77
+
78
+ function requireWindowTarget(): string {
79
+ if (!myWindowId) throw new Error("Could not determine current tmux window.");
80
+ return myWindowId;
81
+ }
82
+
83
+ async function findPane(name: string): Promise<PaneInfo | null> {
84
+ const result = await pi.exec("tmux", [
85
+ "list-panes",
86
+ "-t",
87
+ requireWindowTarget(),
88
+ "-F",
89
+ "#{pane_id}\t#{@pi_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_dead}",
90
+ ]);
91
+ if (result.code !== 0) return null;
92
+
93
+ for (const line of result.stdout.trim().split("\n")) {
94
+ const [paneId, paneName, command, pid, dead] = line.split("\t");
95
+ if (paneName === name) {
96
+ return { name: paneName, paneId, alive: dead !== "1", command, pid };
97
+ }
98
+ }
99
+ return null;
100
+ }
101
+
102
+ async function listAllPanes(): Promise<PaneInfo[]> {
103
+ const result = await pi.exec("tmux", [
104
+ "list-panes",
105
+ "-t",
106
+ requireWindowTarget(),
107
+ "-F",
108
+ "#{pane_id}\t#{@pi_name}\t#{pane_current_command}\t#{pane_pid}\t#{pane_dead}",
109
+ ]);
110
+ if (result.code !== 0) return [];
111
+
112
+ const panes: PaneInfo[] = [];
113
+ for (const line of result.stdout.trim().split("\n")) {
114
+ if (!line.trim()) continue;
115
+ const [paneId, paneName, command, pid, dead] = line.split("\t");
116
+ // Skip pi's own pane
117
+ if (paneId === myPaneId) continue;
118
+ panes.push({
119
+ name: paneName?.trim() || "",
120
+ paneId,
121
+ alive: dead !== "1",
122
+ command,
123
+ pid,
124
+ });
125
+ }
126
+ return panes;
127
+ }
128
+
129
+ async function capturePane(paneId: string, lines: number): Promise<string> {
130
+ const result = await pi.exec("tmux", ["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
131
+ if (result.code !== 0) throw new Error(`capture-pane failed: ${result.stderr}`);
132
+
133
+ let output = stripAnsi(result.stdout);
134
+ // Trim trailing blank lines (tmux pads to pane height)
135
+ output = output.replace(/\n+$/, "\n");
136
+ return output;
137
+ }
138
+
139
+ // --- tool ---
140
+
141
+ pi.registerTool({
142
+ name: "tmux",
143
+ label: "tmux",
144
+ description:
145
+ "Manage tmux panes for long-running processes (dev servers, watchers, etc). " +
146
+ "Actions: run (start command in named pane), read (capture output), send (send keys like C-c), stop (kill pane), list (show panes).",
147
+ promptGuidelines: [
148
+ "Use `tmux` run for long-running processes (dev servers, watchers, builds) instead of `bash`.",
149
+ "Use `bash` only for short-lived commands that complete quickly.",
150
+ "Layout: pi runs on the left. Worker panes are created on the right, stacked vertically. First pane splits right from pi, additional panes automatically stack below existing ones.",
151
+ ],
152
+ parameters: Type.Object({
153
+ action: StringEnum(["run", "read", "send", "stop", "list"] as const, {
154
+ description: "Action to perform",
155
+ }),
156
+ pane: Type.Optional(Type.String({ description: "Pane name (required for run/read/send/stop)" })),
157
+ command: Type.Optional(Type.String({ description: "Shell command to run (for run action)" })),
158
+ keys: Type.Optional(
159
+ Type.String({
160
+ description: "Keys to send, space-separated (for send action). Examples: C-c, Enter, q, y",
161
+ }),
162
+ ),
163
+ text: Type.Optional(
164
+ Type.String({
165
+ description: "Literal text to type into the pane (for send action). Sent as-is, no key lookup.",
166
+ }),
167
+ ),
168
+ lines: Type.Optional(
169
+ Type.Number({ description: "Scrollback lines to capture (for read action, default: 20)" }),
170
+ ),
171
+ restart: Type.Optional(
172
+ Type.Boolean({ description: "Kill existing pane before starting (for run action, default: false)" }),
173
+ ),
174
+ cwd: Type.Optional(Type.String({ description: "Working directory (for run action)" })),
175
+ position: Type.Optional(
176
+ StringEnum(["right", "bottom"] as const, {
177
+ description: "Pane position (for run action, default: right)",
178
+ }),
179
+ ),
180
+ }),
181
+
182
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
183
+ if (!inTmux) {
184
+ throw new Error("Not running inside tmux.");
185
+ }
186
+
187
+ const { action } = params;
188
+
189
+ switch (action) {
190
+ case "run": {
191
+ const { pane, command, restart, cwd, position } = params;
192
+ if (!pane) throw new Error("'pane' is required for run");
193
+ if (!command) throw new Error("'command' is required for run");
194
+
195
+ const existing = await findPane(pane);
196
+
197
+ // Dead pane → always replace. Alive pane → error unless restart.
198
+ if (existing?.alive && !restart) {
199
+ throw new Error(
200
+ `Pane '${pane}' already exists (running ${existing.command}). Use restart: true to replace it.`,
201
+ );
202
+ }
203
+ if (existing) {
204
+ await pi.exec("tmux", ["kill-pane", "-t", existing.paneId]);
205
+ }
206
+
207
+ // Layout: first pane splits right from pi, additional panes stack
208
+ // below existing panes (vertical stack on the right side).
209
+ // Explicit position overrides this behavior.
210
+ // Uses all panes (not just managed) for correct layout awareness.
211
+ const allOtherPanes = await listAllPanes();
212
+ let splitFlag: string;
213
+ let splitTarget: string | null = null;
214
+
215
+ if (position === "right") {
216
+ splitFlag = "-h";
217
+ } else if (position === "bottom") {
218
+ splitFlag = "-v";
219
+ } else if (allOtherPanes.length > 0) {
220
+ // Auto: stack below the last existing pane
221
+ splitFlag = "-v";
222
+ splitTarget = allOtherPanes[allOtherPanes.length - 1].paneId;
223
+ } else {
224
+ // Auto: first pane goes to the right of pi
225
+ splitFlag = "-h";
226
+ }
227
+
228
+ const splitArgs = ["split-window", "-d", splitFlag, "-P", "-F", "#{pane_id}"];
229
+ splitArgs.push("-t", splitTarget ?? myPaneId ?? requireWindowTarget());
230
+ if (cwd) splitArgs.push("-c", cwd);
231
+
232
+ const result = await pi.exec("tmux", splitArgs);
233
+ if (result.code !== 0) throw new Error(`split-window failed: ${result.stderr}`);
234
+
235
+ const newPaneId = result.stdout.trim();
236
+
237
+ // Tag with name
238
+ await pi.exec("tmux", ["set-option", "-p", "-t", newPaneId, "@pi_name", pane]);
239
+
240
+ // Send command (literal text + Enter)
241
+ await pi.exec("tmux", ["send-keys", "-l", "-t", newPaneId, command]);
242
+ await pi.exec("tmux", ["send-keys", "-t", newPaneId, "Enter"]);
243
+
244
+ // Wait briefly and capture initial output
245
+ await new Promise((r) => setTimeout(r, 1500));
246
+ const initialOutput = await capturePane(newPaneId, 20);
247
+
248
+ return {
249
+ content: [
250
+ {
251
+ type: "text",
252
+ text: `Started '${command}' in pane '${pane}' (${newPaneId})\n\n${initialOutput}`,
253
+ },
254
+ ],
255
+ details: { action: "run", pane, paneId: newPaneId, command, position: position ?? "right" },
256
+ };
257
+ }
258
+
259
+ case "read": {
260
+ const { pane, lines } = params;
261
+ if (!pane) throw new Error("'pane' is required for read");
262
+
263
+ const existing = await findPane(pane);
264
+ if (!existing) throw new Error(`Pane '${pane}' not found. Use action 'list' to see managed panes.`);
265
+
266
+ const output = await capturePane(existing.paneId, lines ?? 20);
267
+
268
+ const truncation = truncateTail(output, {
269
+ maxLines: DEFAULT_MAX_LINES,
270
+ maxBytes: DEFAULT_MAX_BYTES,
271
+ });
272
+
273
+ let text = truncation.content;
274
+ if (truncation.truncated) {
275
+ text = `[Showing last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${text}`;
276
+ }
277
+
278
+ return {
279
+ content: [{ type: "text", text }],
280
+ details: { action: "read", pane, alive: existing.alive, command: existing.command },
281
+ };
282
+ }
283
+
284
+ case "send": {
285
+ const { pane, keys, text } = params;
286
+ if (!pane) throw new Error("'pane' is required for send");
287
+ if (!keys && !text) throw new Error("'keys' or 'text' is required for send");
288
+
289
+ const existing = await findPane(pane);
290
+ if (!existing) throw new Error(`Pane '${pane}' not found.`);
291
+
292
+ // Send literal text first (if provided)
293
+ if (text) {
294
+ await pi.exec("tmux", ["send-keys", "-l", "-t", existing.paneId, text]);
295
+ }
296
+
297
+ // Then send special keys (if provided)
298
+ if (keys) {
299
+ const keyArgs = keys.split(/\s+/).filter(Boolean);
300
+ await pi.exec("tmux", ["send-keys", "-t", existing.paneId, ...keyArgs]);
301
+ }
302
+
303
+ const desc = [text && `"${text}"`, keys].filter(Boolean).join(" + ");
304
+ return {
305
+ content: [{ type: "text", text: `Sent ${desc} to pane '${pane}'` }],
306
+ details: { action: "send", pane, keys, text },
307
+ };
308
+ }
309
+
310
+ case "stop": {
311
+ const { pane } = params;
312
+ if (!pane) throw new Error("'pane' is required for stop");
313
+
314
+ const existing = await findPane(pane);
315
+ if (!existing) throw new Error(`Pane '${pane}' not found.`);
316
+
317
+ if (existing.paneId === myPaneId) {
318
+ throw new Error("Refusing to kill the pane pi is running in.");
319
+ }
320
+
321
+ await pi.exec("tmux", ["kill-pane", "-t", existing.paneId]);
322
+
323
+ return {
324
+ content: [{ type: "text", text: `Stopped pane '${pane}'` }],
325
+ details: { action: "stop", pane },
326
+ };
327
+ }
328
+
329
+ case "list": {
330
+ const panes = await listAllPanes();
331
+
332
+ if (panes.length === 0) {
333
+ return {
334
+ content: [{ type: "text", text: "No panes (besides pi)." }],
335
+ details: { action: "list", panes: [] },
336
+ };
337
+ }
338
+
339
+ const text = panes
340
+ .map((p) => {
341
+ const label = p.name || `[${p.command}]`;
342
+ const managed = p.name ? "" : " (unmanaged)";
343
+ return `${label}: ${p.alive ? "running" : "dead"} (${p.command}) [${p.paneId}]${managed}`;
344
+ })
345
+ .join("\n");
346
+
347
+ return {
348
+ content: [{ type: "text", text }],
349
+ details: { action: "list", panes },
350
+ };
351
+ }
352
+
353
+ default:
354
+ throw new Error(`Unknown action: ${action}`);
355
+ }
356
+ },
357
+
358
+ // --- rendering ---
359
+
360
+ renderCall(args, theme) {
361
+ const action = args.action || "?";
362
+ let text = theme.fg("toolTitle", theme.bold("tmux "));
363
+ text += theme.fg("accent", action);
364
+
365
+ if (args.pane) text += theme.fg("muted", ` ${args.pane}`);
366
+ if (args.command) text += theme.fg("dim", ` › ${args.command}`);
367
+ if (args.text) text += theme.fg("dim", ` › "${args.text}"`);
368
+ if (args.keys) text += theme.fg("dim", ` › ${args.keys}`);
369
+
370
+ return new Text(text, 0, 0);
371
+ },
372
+
373
+ renderResult(result, { expanded }, theme) {
374
+ const details = result.details as Record<string, any> | undefined;
375
+ if (!details) {
376
+ const c = result.content?.[0];
377
+ return new Text(c?.type === "text" ? c.text : "", 0, 0);
378
+ }
379
+
380
+ switch (details.action) {
381
+ case "run": {
382
+ let t = theme.fg("success", `▶ ${details.pane}`);
383
+ t += theme.fg("dim", ` › ${details.command}`);
384
+ return new Text(t, 0, 0);
385
+ }
386
+
387
+ case "read": {
388
+ const dot = details.alive ? theme.fg("success", "●") : theme.fg("error", "●");
389
+ let t = `${dot} ${theme.fg("accent", details.pane)}`;
390
+
391
+ if (expanded) {
392
+ const c = result.content?.[0];
393
+ if (c?.type === "text") {
394
+ const outputLines = c.text.split("\n").slice(0, 40);
395
+ t += "\n" + outputLines.map((l: string) => theme.fg("dim", l)).join("\n");
396
+ const total = c.text.split("\n").length;
397
+ if (total > 40) {
398
+ t += `\n${theme.fg("muted", `... (${total} total lines)`)}`;
399
+ }
400
+ }
401
+ }
402
+ return new Text(t, 0, 0);
403
+ }
404
+
405
+ case "send": {
406
+ const desc = [details.text && `"${details.text}"`, details.keys].filter(Boolean).join(" + ");
407
+ return new Text(theme.fg("accent", `⏎ ${details.pane} › ${desc}`), 0, 0);
408
+ }
409
+
410
+ case "stop": {
411
+ return new Text(theme.fg("warning", `■ ${details.pane}`), 0, 0);
412
+ }
413
+
414
+ case "list": {
415
+ const panes = details.panes as PaneInfo[];
416
+ if (!panes?.length) return new Text(theme.fg("dim", "no panes"), 0, 0);
417
+
418
+ const lines = panes.map((p) => {
419
+ const dot = p.alive ? theme.fg("success", "●") : theme.fg("error", "●");
420
+ const label = p.name
421
+ ? theme.fg("accent", p.name)
422
+ : theme.fg("muted", `[${p.command}]`);
423
+ const extra = p.name ? "" : theme.fg("dim", " (unmanaged)");
424
+ return `${dot} ${label} ${theme.fg("dim", p.command)}${extra}`;
425
+ });
426
+ return new Text(lines.join("\n"), 0, 0);
427
+ }
428
+
429
+ default: {
430
+ const c = result.content?.[0];
431
+ return new Text(c?.type === "text" ? c.text : "", 0, 0);
432
+ }
433
+ }
434
+ },
435
+ });
436
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@ogulcancelik/pi-tmux",
3
+ "version": "0.1.0",
4
+ "description": "Tmux pane management for pi. Run dev servers, watchers, and long-running processes in named panes without blocking the agent.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "tmux",
10
+ "terminal",
11
+ "pane-management",
12
+ "dev-server",
13
+ "long-running",
14
+ "coding-agent"
15
+ ],
16
+ "author": "Can Celik",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/ogulcancelik/pi-extensions.git",
21
+ "directory": "packages/pi-tmux"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/ogulcancelik/pi-extensions/issues"
25
+ },
26
+ "homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-tmux#readme",
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ },
30
+ "pi": {
31
+ "extensions": [
32
+ "./index.ts"
33
+ ]
34
+ },
35
+ "files": [
36
+ "index.ts",
37
+ "README.md",
38
+ "LICENSE"
39
+ ]
40
+ }