Skip to content

Commit 79a8476

Browse files
fix: run tee inside tmux session so JSONL events stream to Linear
The tee pipe was running in the outer shell, not inside the tmux session. Since tmux detaches with -d, the outer tee captured nothing and the log file stayed empty — zero events streamed to Linear. Fix: write a shell wrapper script containing the command + tee pipeline, then have tmux execute that script. This ensures tee runs inside the session and captures all JSONL output. Also: add Codex event shape support, resolved guard against double-resolve, and session.completed detection for Codex.
1 parent 869279a commit 79a8476

1 file changed

Lines changed: 50 additions & 14 deletions

File tree

src/infra/tmux-runner.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { execSync, spawn } from "node:child_process";
22
import { createInterface } from "node:readline";
3-
import { mkdirSync, createWriteStream } from "node:fs";
3+
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
44
import { dirname } from "node:path";
55
import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
66
import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
@@ -45,8 +45,12 @@ export function getActiveTmuxSession(issueId: string): TmuxSession | null {
4545
}
4646

4747
/**
48-
* Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
48+
* Run a command inside a tmux session with output piped to a JSONL log.
4949
* Monitors the log file for events and streams them to Linear.
50+
*
51+
* The command + tee are wrapped in a shell script so that tee runs INSIDE
52+
* the tmux session (not in the outer shell). This ensures JSONL output
53+
* from the CLI subprocess is captured to the log file.
5054
*/
5155
export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
5256
const {
@@ -70,6 +74,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
7074
// Ensure log directory exists
7175
mkdirSync(dirname(logPath), { recursive: true });
7276

77+
// Touch the log file so tail -f can start immediately
78+
writeFileSync(logPath, "", { flag: "a" });
79+
7380
// Register active session
7481
const session: TmuxSession = {
7582
sessionName,
@@ -83,14 +90,25 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
8390
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
8491
progress.emitHeader();
8592

93+
// Write a shell wrapper script so the entire pipeline (command | tee)
94+
// runs inside the tmux session. This avoids quoting hell and ensures
95+
// tee captures the subprocess output, not tmux's own stdout.
96+
const scriptPath = `${logPath}.run.sh`;
97+
8698
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(" ");
99+
writeFileSync(scriptPath, [
100+
"#!/bin/sh",
101+
`exec ${command} 2>&1 | tee -a ${shellEscape(logPath)}`,
102+
"",
103+
].join("\n"), { mode: 0o755 });
92104

93-
execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
105+
// Start tmux session running the wrapper script
106+
execSync(
107+
`tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)} ${shellEscape(scriptPath)}`,
108+
{ stdio: "ignore", timeout: 10_000 },
109+
);
110+
111+
logger.info(`tmux session started: ${sessionName} (log: ${logPath})`);
94112

95113
// Tail the log file and process JSONL events
96114
return await new Promise<CliResult>((resolve) => {
@@ -100,6 +118,7 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
100118

101119
let killed = false;
102120
let killedByWatchdog = false;
121+
let resolved = false;
103122
const collectedMessages: string[] = [];
104123

105124
const timer = setTimeout(() => {
@@ -120,6 +139,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
120139
watchdog.start();
121140

122141
function cleanup(reason: string) {
142+
if (resolved) return;
143+
resolved = true;
144+
123145
clearTimeout(timer);
124146
watchdog.stop();
125147
tail.kill();
@@ -132,6 +154,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
132154
});
133155
} catch { /* session may already be gone */ }
134156

157+
// Clean up wrapper script
158+
try { unlinkSync(scriptPath); } catch { /* best effort */ }
159+
135160
activeSessions.delete(issueId);
136161

137162
const output = collectedMessages.join("\n\n") || "(no output)";
@@ -169,8 +194,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
169194
return;
170195
}
171196

172-
// Collect text for output
197+
// Collect text for output — handle both Claude and Codex event shapes
173198
if (event.type === "assistant") {
199+
// Claude stream-json shape
174200
const content = event.message?.content;
175201
if (Array.isArray(content)) {
176202
for (const block of content) {
@@ -180,6 +206,11 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
180206
}
181207
}
182208
}
209+
if (event.item?.type === "agent_message" || event.item?.type === "message") {
210+
// Codex --json shape
211+
const text = event.item.text ?? event.item.content ?? "";
212+
if (text) collectedMessages.push(text);
213+
}
183214

184215
// Stream to Linear
185216
const activity = mapEvent(event);
@@ -192,28 +223,33 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
192223
progress.push(formatActivityLogLine(activity));
193224
}
194225

195-
// Detect completion
196-
if (event.type === "result") {
226+
// Detect completion — Claude uses "result", Codex uses "session.completed"
227+
if (event.type === "result" || event.type === "session.completed") {
197228
cleanup("done");
198229
rl.close();
199230
}
200231
});
201232

202-
// Handle tail process ending (tmux session completed)
233+
// Handle tail process ending (tmux session exited)
203234
tail.on("close", () => {
204-
if (!killed) {
235+
if (!resolved) {
205236
cleanup("done");
206237
}
207238
rl.close();
208239
});
209240

210241
tail.on("error", (err) => {
211242
logger.error(`tmux tail error: ${err}`);
212-
cleanup("error");
243+
if (!resolved) {
244+
cleanup("error");
245+
}
213246
rl.close();
214247
});
215248
});
216249
} catch (err) {
250+
// Clean up wrapper script on failure
251+
try { unlinkSync(scriptPath); } catch { /* best effort */ }
252+
217253
activeSessions.delete(issueId);
218254
logger.error(`runInTmux failed: ${err}`);
219255
return {

0 commit comments

Comments
 (0)