11import { execSync , spawn } from "node:child_process" ;
22import { createInterface } from "node:readline" ;
3- import { mkdirSync , createWriteStream } from "node:fs" ;
3+ import { mkdirSync , writeFileSync , unlinkSync } from "node:fs" ;
44import { dirname } from "node:path" ;
55import type { ActivityContent , LinearAgentApi } from "../api/linear-api.js" ;
66import 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 */
5155export 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