Skip to content

Commit 7fa949a

Browse files
fix: add missing tmux and steering-tools modules that broke CI
Three source files were imported but never committed: - src/infra/tmux.ts (isTmuxAvailable, buildSessionName, shellEscape, capturePane) - src/infra/tmux-runner.ts (runInTmux, getActiveTmuxSession) - src/tools/steering-tools.ts (steer_agent, capture_agent_output, abort_agent) This caused ERR_MODULE_NOT_FOUND in all test files that transitively imported webhook.ts or the CLI tool modules, failing CI since the code_run refactor.
1 parent 3a43936 commit 7fa949a

3 files changed

Lines changed: 410 additions & 0 deletions

File tree

src/infra/tmux-runner.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import { execSync, spawn } from "node:child_process";
2+
import { createInterface } from "node:readline";
3+
import { mkdirSync, createWriteStream } from "node:fs";
4+
import { dirname } from "node:path";
5+
import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
6+
import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
7+
import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-shared.js";
8+
import { InactivityWatchdog } from "../agent/watchdog.js";
9+
import { shellEscape } from "./tmux.js";
10+
11+
export interface TmuxSession {
12+
sessionName: string;
13+
backend: string;
14+
issueIdentifier: string;
15+
issueId: string;
16+
steeringMode: string;
17+
}
18+
19+
export interface RunInTmuxOptions {
20+
issueId: string;
21+
issueIdentifier: string;
22+
sessionName: string;
23+
command: string;
24+
cwd: string;
25+
timeoutMs: number;
26+
watchdogMs: number;
27+
logPath: string;
28+
mapEvent: (event: any) => ActivityContent | null;
29+
linearApi?: LinearAgentApi;
30+
agentSessionId?: string;
31+
steeringMode: "stdin-pipe" | "one-shot";
32+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
33+
onUpdate?: OnProgressUpdate;
34+
progressHeader: string;
35+
}
36+
37+
// Track active tmux sessions by issueId
38+
const activeSessions = new Map<string, TmuxSession>();
39+
40+
/**
41+
* Get the active tmux session for a given issueId, or null if none.
42+
*/
43+
export function getActiveTmuxSession(issueId: string): TmuxSession | null {
44+
return activeSessions.get(issueId) ?? null;
45+
}
46+
47+
/**
48+
* Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
49+
* Monitors the log file for events and streams them to Linear.
50+
*/
51+
export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
52+
const {
53+
issueId,
54+
issueIdentifier,
55+
sessionName,
56+
command,
57+
cwd,
58+
timeoutMs,
59+
watchdogMs,
60+
logPath,
61+
mapEvent,
62+
linearApi,
63+
agentSessionId,
64+
steeringMode,
65+
logger,
66+
onUpdate,
67+
progressHeader,
68+
} = opts;
69+
70+
// Ensure log directory exists
71+
mkdirSync(dirname(logPath), { recursive: true });
72+
73+
// Register active session
74+
const session: TmuxSession = {
75+
sessionName,
76+
backend: sessionName.split("-").slice(-2, -1)[0] ?? "unknown",
77+
issueIdentifier,
78+
issueId,
79+
steeringMode,
80+
};
81+
activeSessions.set(issueId, session);
82+
83+
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
84+
progress.emitHeader();
85+
86+
try {
87+
// Create tmux session running the command, piping output to logPath
88+
const tmuxCmd = [
89+
`tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)}`,
90+
`${shellEscape(command)} 2>&1 | tee ${shellEscape(logPath)}`,
91+
].join(" ");
92+
93+
execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
94+
95+
// Tail the log file and process JSONL events
96+
return await new Promise<CliResult>((resolve) => {
97+
const tail = spawn("tail", ["-f", "-n", "+1", logPath], {
98+
stdio: ["ignore", "pipe", "ignore"],
99+
});
100+
101+
let killed = false;
102+
let killedByWatchdog = false;
103+
const collectedMessages: string[] = [];
104+
105+
const timer = setTimeout(() => {
106+
killed = true;
107+
cleanup("timeout");
108+
}, timeoutMs);
109+
110+
const watchdog = new InactivityWatchdog({
111+
inactivityMs: watchdogMs,
112+
label: `tmux:${sessionName}`,
113+
logger,
114+
onKill: () => {
115+
killedByWatchdog = true;
116+
killed = true;
117+
cleanup("inactivity_timeout");
118+
},
119+
});
120+
watchdog.start();
121+
122+
function cleanup(reason: string) {
123+
clearTimeout(timer);
124+
watchdog.stop();
125+
tail.kill();
126+
127+
// Kill the tmux session
128+
try {
129+
execSync(`tmux kill-session -t ${shellEscape(sessionName)}`, {
130+
stdio: "ignore",
131+
timeout: 5_000,
132+
});
133+
} catch { /* session may already be gone */ }
134+
135+
activeSessions.delete(issueId);
136+
137+
const output = collectedMessages.join("\n\n") || "(no output)";
138+
139+
if (reason === "inactivity_timeout") {
140+
logger.warn(`tmux session ${sessionName} killed by inactivity watchdog`);
141+
resolve({
142+
success: false,
143+
output: `Agent killed by inactivity watchdog (no I/O for ${Math.round(watchdogMs / 1000)}s). Partial output:\n${output}`,
144+
error: "inactivity_timeout",
145+
});
146+
} else if (reason === "timeout") {
147+
logger.warn(`tmux session ${sessionName} timed out after ${Math.round(timeoutMs / 1000)}s`);
148+
resolve({
149+
success: false,
150+
output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
151+
error: "timeout",
152+
});
153+
} else {
154+
// Normal completion
155+
resolve({ success: true, output });
156+
}
157+
}
158+
159+
const rl = createInterface({ input: tail.stdout! });
160+
rl.on("line", (line) => {
161+
if (!line.trim()) return;
162+
watchdog.tick();
163+
164+
let event: any;
165+
try {
166+
event = JSON.parse(line);
167+
} catch {
168+
collectedMessages.push(line);
169+
return;
170+
}
171+
172+
// Collect text for output
173+
if (event.type === "assistant") {
174+
const content = event.message?.content;
175+
if (Array.isArray(content)) {
176+
for (const block of content) {
177+
if (block.type === "text" && block.text) {
178+
collectedMessages.push(block.text);
179+
}
180+
}
181+
}
182+
}
183+
184+
// Stream to Linear
185+
const activity = mapEvent(event);
186+
if (activity) {
187+
if (linearApi && agentSessionId) {
188+
linearApi.emitActivity(agentSessionId, activity).catch((err) => {
189+
logger.warn(`Failed to emit tmux activity: ${err}`);
190+
});
191+
}
192+
progress.push(formatActivityLogLine(activity));
193+
}
194+
195+
// Detect completion
196+
if (event.type === "result") {
197+
cleanup("done");
198+
rl.close();
199+
}
200+
});
201+
202+
// Handle tail process ending (tmux session completed)
203+
tail.on("close", () => {
204+
if (!killed) {
205+
cleanup("done");
206+
}
207+
rl.close();
208+
});
209+
210+
tail.on("error", (err) => {
211+
logger.error(`tmux tail error: ${err}`);
212+
cleanup("error");
213+
rl.close();
214+
});
215+
});
216+
} catch (err) {
217+
activeSessions.delete(issueId);
218+
logger.error(`runInTmux failed: ${err}`);
219+
return {
220+
success: false,
221+
output: `Failed to start tmux session: ${err}`,
222+
error: String(err),
223+
};
224+
}
225+
}

src/infra/tmux.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { execSync } from "node:child_process";
2+
3+
/**
4+
* Check if tmux is available on the system.
5+
*/
6+
export function isTmuxAvailable(): boolean {
7+
try {
8+
execSync("tmux -V", { stdio: "ignore" });
9+
return true;
10+
} catch {
11+
return false;
12+
}
13+
}
14+
15+
/**
16+
* Build a deterministic tmux session name from issue identifier, backend, and index.
17+
*/
18+
export function buildSessionName(identifier: string, backend: string, index: number): string {
19+
// tmux session names can't contain dots or colons — sanitize
20+
const safe = `${identifier}-${backend}-${index}`.replace(/[^a-zA-Z0-9_-]/g, "_");
21+
return `claw-${safe}`;
22+
}
23+
24+
/**
25+
* Escape a string for safe shell interpolation (single-quote wrapping).
26+
*/
27+
export function shellEscape(value: string): string {
28+
// Wrap in single quotes, escaping any embedded single quotes
29+
return `'${value.replace(/'/g, "'\\''")}'`;
30+
}
31+
32+
/**
33+
* Capture the last N lines from a tmux session pane.
34+
*/
35+
export function capturePane(sessionName: string, lines: number): string {
36+
try {
37+
return execSync(
38+
`tmux capture-pane -t ${shellEscape(sessionName)} -p -S -${lines}`,
39+
{ encoding: "utf8", timeout: 5_000 },
40+
).trimEnd();
41+
} catch {
42+
return "";
43+
}
44+
}

0 commit comments

Comments
 (0)