Skip to content

Commit 867736e

Browse files
authored
refactor: extract tmux primitives to shared library (#48)
* refactor(bgrun): extract tmux primitives to shared library * docs: update changelog
1 parent 0704eaf commit 867736e

8 files changed

Lines changed: 739 additions & 571 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ All notable changes to agent-stuff are documented here.
8383

8484

8585

86+
87+
88+
## refactor/extract-tmux-library
89+
90+
Extracted tmux primitives to a reusable shared library (`lib/tmux.ts`) to eliminate duplication between `bgrun` and the subagent runner (#48). The new library provides a clean abstraction for session management, window creation, and pane capture through an injectable `exec` function, enabling both extensions to leverage consistent tmux handling without tight coupling to the pi extension API. This refactor improves maintainability, enables safer window naming with collision detection, and sets the foundation for future tmux-based features. The bgrun extension now re-exports key utilities for backward compatibility with existing tests and plugins.
8691

8792
## refactor/extract-ask-question-ui
8893

lib/tmux.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
/**
2+
* Shared tmux primitives
3+
*
4+
* Core tmux session and window management used by both bgrun.ts
5+
* and the subagent runner. Accepts an `exec` function to avoid
6+
* depending directly on the pi extension API.
7+
*/
8+
9+
import { basename, dirname } from "node:path";
10+
11+
/** Function signature matching pi.exec(). */
12+
export type ExecFn = (
13+
command: string,
14+
args: string[],
15+
options?: { timeout?: number },
16+
) => Promise<{ stdout: string; stderr: string; code: number }>;
17+
18+
/** Immutable config for a tmux session. */
19+
export interface TmuxConfig {
20+
socketPath: string;
21+
sessionName: string;
22+
}
23+
24+
/** Build tmux socket path and session name from a cwd and session prefix. */
25+
export function buildTmuxConfig(cwd: string, prefix: string): TmuxConfig {
26+
const socketDir =
27+
process.env.CLAUDE_TMUX_SOCKET_DIR ??
28+
`${process.env.TMPDIR ?? "/tmp"}/claude-tmux-sockets`;
29+
const socketPath = `${socketDir}/claude.sock`;
30+
const sessionName = `${prefix}-${sanitizeName(basename(cwd))}`;
31+
return { socketPath, sessionName };
32+
}
33+
34+
/** Run a tmux command and return stdout. Throws on non-zero exit. */
35+
export async function tmuxExec(
36+
exec: ExecFn,
37+
config: TmuxConfig,
38+
args: string[],
39+
): Promise<string> {
40+
const result = await exec(
41+
"tmux",
42+
["-S", config.socketPath, ...args],
43+
{ timeout: 5000 },
44+
);
45+
if (result.code !== 0) {
46+
throw new Error(
47+
`tmux ${args[0]} failed (code ${result.code}): ${result.stderr.trim()}`,
48+
);
49+
}
50+
return result.stdout.trim();
51+
}
52+
53+
/** Ensure the tmux session exists. Creates socket dir + session if missing. */
54+
export async function ensureTmuxSession(
55+
exec: ExecFn,
56+
config: TmuxConfig,
57+
): Promise<void> {
58+
const socketDir = dirname(config.socketPath);
59+
await exec("mkdir", ["-p", socketDir]);
60+
61+
try {
62+
await tmuxExec(exec, config, ["has-session", "-t", config.sessionName]);
63+
} catch {
64+
await tmuxExec(exec, config, [
65+
"new-session", "-d", "-s", config.sessionName, "-n", "_control",
66+
]);
67+
}
68+
}
69+
70+
/** Query tmux for window names and their pane_dead status. */
71+
export async function listWindowState(
72+
exec: ExecFn,
73+
config: TmuxConfig,
74+
): Promise<Map<string, boolean>> {
75+
try {
76+
const out = await tmuxExec(exec, config, [
77+
"list-windows", "-t", config.sessionName,
78+
"-F", "#{window_name}\t#{pane_dead}",
79+
]);
80+
const state = new Map<string, boolean>();
81+
for (const line of out.split("\n").filter(Boolean)) {
82+
const tab = line.indexOf("\t");
83+
const name = tab >= 0 ? line.slice(0, tab) : line;
84+
const dead = tab >= 0 && line.slice(tab + 1) === "1";
85+
state.set(name, dead);
86+
}
87+
return state;
88+
} catch {
89+
return new Map();
90+
}
91+
}
92+
93+
/** Create a new tmux window running the given command. */
94+
export async function createWindow(
95+
exec: ExecFn,
96+
config: TmuxConfig,
97+
name: string,
98+
command: string,
99+
env?: Record<string, string>,
100+
): Promise<void> {
101+
await ensureTmuxSession(exec, config);
102+
103+
const envPrefix = env
104+
? Object.entries(env)
105+
.map(([k, v]) => `${k}=${shellEscape(v)}`)
106+
.join(" ") + " "
107+
: "";
108+
109+
await tmuxExec(exec, config, [
110+
"new-window", "-t", config.sessionName, "-n", name,
111+
"zsh", "-c", `${envPrefix}${command}`,
112+
]);
113+
await tmuxExec(exec, config, [
114+
"set-option", "-t", `${config.sessionName}:${name}`,
115+
"remain-on-exit", "on",
116+
]);
117+
}
118+
119+
/** Capture last N lines from a window's tmux pane. */
120+
export async function capturePane(
121+
exec: ExecFn,
122+
config: TmuxConfig,
123+
windowName: string,
124+
lines = 200,
125+
): Promise<string> {
126+
try {
127+
return await tmuxExec(exec, config, [
128+
"capture-pane", "-p", "-J",
129+
"-t", `${config.sessionName}:${windowName}`,
130+
"-S", `-${lines}`,
131+
]);
132+
} catch {
133+
return "(unable to capture output)";
134+
}
135+
}
136+
137+
/** Kill a specific tmux window. */
138+
export async function killWindow(
139+
exec: ExecFn,
140+
config: TmuxConfig,
141+
windowName: string,
142+
): Promise<void> {
143+
try {
144+
await tmuxExec(exec, config, [
145+
"kill-window", "-t", `${config.sessionName}:${windowName}`,
146+
]);
147+
} catch {
148+
/* window may already be dead */
149+
}
150+
}
151+
152+
/** Kill the entire tmux session. */
153+
export async function killSession(
154+
exec: ExecFn,
155+
config: TmuxConfig,
156+
): Promise<void> {
157+
try {
158+
await tmuxExec(exec, config, ["kill-session", "-t", config.sessionName]);
159+
} catch {
160+
/* session may not exist */
161+
}
162+
}
163+
164+
/** List all window names in a session (excluding _control). */
165+
export async function listWindowNames(
166+
exec: ExecFn,
167+
config: TmuxConfig,
168+
): Promise<string[]> {
169+
try {
170+
const out = await tmuxExec(exec, config, [
171+
"list-windows", "-t", config.sessionName,
172+
"-F", "#{window_name}",
173+
]);
174+
return out.split("\n").filter((n) => n && n !== "_control");
175+
} catch {
176+
return [];
177+
}
178+
}
179+
180+
// ── Naming Utilities ─────────────────────────────
181+
182+
/** Subcommands that take a target argument (e.g. `npm run dev` → `npm-dev`). */
183+
export const COMPOUND_SUBCOMMANDS = new Set([
184+
"run", "start", "exec", "test", "build", "serve", "watch", "dev",
185+
]);
186+
187+
/** Derive a short, meaningful tmux window name from a command string. */
188+
export function deriveTaskName(command: string): string {
189+
const trimmed = command.trim();
190+
const parts = trimmed.split(/\s+/);
191+
const base = basename(parts[0] ?? "task");
192+
193+
if (parts.length <= 1) return sanitizeName(base);
194+
195+
const subcommand = parts[1] ?? "";
196+
if (COMPOUND_SUBCOMMANDS.has(subcommand)) {
197+
const target = parts[2] ?? subcommand;
198+
return sanitizeName(`${base}-${target}`);
199+
}
200+
201+
return sanitizeName(`${base}-${subcommand}`);
202+
}
203+
204+
/** Sanitize a string for use as a tmux window name. */
205+
export function sanitizeName(raw: string): string {
206+
return raw
207+
.replace(/[^a-zA-Z0-9._-]/g, "-")
208+
.replace(/-+/g, "-")
209+
.replace(/^-|-$/g, "")
210+
.slice(0, 30) || "task";
211+
}
212+
213+
/** Ensure the window name is unique within a set of existing names. */
214+
export function uniqueName(desired: string, existing: Set<string> | Map<string, unknown>): string {
215+
if (!existing.has(desired)) return desired;
216+
for (let i = 2; i < 100; i++) {
217+
const candidate = `${desired}-${i}`;
218+
if (!existing.has(candidate)) return candidate;
219+
}
220+
return `${desired}-${Date.now()}`;
221+
}
222+
223+
/** Format elapsed milliseconds as compact duration string. */
224+
export function formatDuration(ms: number): string {
225+
const sec = Math.floor(ms / 1000);
226+
if (sec < 60) return `${sec}s`;
227+
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
228+
const h = Math.floor(sec / 3600);
229+
const m = Math.floor((sec % 3600) / 60);
230+
return `${h}h${m}m`;
231+
}
232+
233+
/** Shell-escape a value for safe embedding in a command string. */
234+
export function shellEscape(value: string): string {
235+
return `'${value.replace(/'/g, "'\\''")}'`;
236+
}

0 commit comments

Comments
 (0)