@@ -9,7 +9,7 @@ import type {
99 OperationRequestContext ,
1010} from "../operation-dispatcher.js" ;
1111import { assertPathAllowed , DirectoryNotAllowedError } from "../security.js" ;
12- import { findPluginScript } from "./plugin-cache.js" ;
12+ import { findPluginScript , findPluginVersions , getPluginCacheRoot } from "./plugin-cache.js" ;
1313import {
1414 expandHome ,
1515 resolveWorktreeParentDir ,
@@ -105,6 +105,60 @@ function shellEscape(value: string): string {
105105 return "'" + value . replaceAll ( "'" , String . raw `'\''` ) + "'" ;
106106}
107107
108+ /**
109+ * Find the stream_formatter.py script from the code plugin.
110+ * Reuses getPluginCacheRoot() and findPluginVersions() from plugin-cache.ts.
111+ * Falls back to null if not installed — caller should degrade gracefully.
112+ */
113+ function findStreamFormatter ( ) : string | null {
114+ const pluginDir = path . join ( getPluginCacheRoot ( ) , "code" ) ;
115+ const versions = findPluginVersions ( pluginDir ) ;
116+ for ( const v of versions ) {
117+ const p = path . join ( pluginDir , v , "tools" , "python" , "stream_formatter.py" ) ;
118+ if ( existsSync ( p ) ) { return p ; }
119+ }
120+ return null ;
121+ }
122+
123+ /**
124+ * Build a bash pipeline command that runs claude with stream-json output,
125+ * filters JSON lines, tees to a jsonl log, and formats for human reading.
126+ * Falls back to raw claude if formatter is not available.
127+ */
128+ function buildClaudePipeline (
129+ claudeArgs : string [ ] ,
130+ claudeWorkDir : string ,
131+ stdinFile ?: string
132+ ) : { cmd : string ; args : string [ ] } {
133+ const formatter = findStreamFormatter ( ) ;
134+ const stderrFile = path . join ( claudeWorkDir , "claude-stderr.log" ) ;
135+ const jsonlFile = path . join ( claudeWorkDir , "claude-output.jsonl" ) ;
136+
137+ // Build the claude command with properly escaped args
138+ const escapedArgs = claudeArgs . map ( shellEscape ) . join ( " " ) ;
139+ const claudeCmd = stdinFile
140+ ? `claude ${ escapedArgs } < ${ shellEscape ( stdinFile ) } `
141+ : `claude ${ escapedArgs } ` ;
142+
143+ if ( formatter ) {
144+ // Full pipeline matching run-loop.sh:
145+ // claude ... 2>stderr | grep JSON | tee jsonl | formatter
146+ const pipeline = [
147+ `${ claudeCmd } 2>${ shellEscape ( stderrFile ) } ` ,
148+ "grep --line-buffered '^{'" ,
149+ `tee -a ${ shellEscape ( jsonlFile ) } ` ,
150+ `python3 ${ shellEscape ( formatter ) } ` ,
151+ ] . join ( " | " ) ;
152+ return { cmd : "bash" , args : [ "-c" , pipeline ] } ;
153+ }
154+
155+ // No formatter — run claude directly (raw stream-json to stdout)
156+ if ( stdinFile ) {
157+ return { cmd : "bash" , args : [ "-c" , claudeCmd ] } ;
158+ }
159+ return { cmd : "claude" , args : claudeArgs } ;
160+ }
161+
108162/**
109163 * Validate apiBaseUrl to prevent SSRF to private/metadata/loopback endpoints.
110164 * Uses deny-by-default for IP literals: extracts the IPv4 address (including
@@ -854,32 +908,6 @@ async function handleProcessCompletion(
854908 }
855909}
856910
857- /**
858- * Spawn `claude -p - --output-format json` with a prompt file as stdin.
859- * Opens the file as a raw fd to avoid E2BIG, unref()s the child so it
860- * outlives the gateway process, and always closes the fd on return.
861- */
862- function spawnClaudeFromFile (
863- promptFile : string ,
864- workDir : string ,
865- logFd : number ,
866- spawnEnv : Record < string , string >
867- ) : ReturnType < typeof spawn > {
868- const promptFd = openSync ( promptFile , "r" ) ;
869- try {
870- const child = spawn ( "claude" , [ "-p" , "-" , "--output-format" , "json" ] , {
871- cwd : workDir ,
872- detached : true ,
873- stdio : [ promptFd , logFd , logFd ] ,
874- env : spawnEnv ,
875- } ) ;
876- child . unref ( ) ;
877- return child ;
878- } finally {
879- closeSync ( promptFd ) ;
880- }
881- }
882-
883911// ---------------------------------------------------------------------------
884912// Main route handler
885913// ---------------------------------------------------------------------------
@@ -1170,12 +1198,28 @@ async function handleLoopRequest(
11701198 } ;
11711199
11721200 if ( body . command === "DECOMPOSE" ) {
1173- // Write prompt to file and pass via stdin to avoid E2BIG
1201+ // DECOMPOSE: write prompt to file and pass via stdin to avoid E2BIG
11741202 const prdContent = readTextFile ( path . join ( claudeWorkDir , "prd.md" ) ) ?? "" ;
11751203 const decomposePrompt = body . prompt ?? `Decompose the following PRD into features:\n\n${ prdContent } ` ;
11761204 const promptFile = path . join ( claudeWorkDir , "decompose-prompt.txt" ) ;
11771205 await fs . writeFile ( promptFile , decomposePrompt ) ;
1178- child = spawnClaudeFromFile ( promptFile , claudeWorkDir , logFd , spawnEnv ) ;
1206+
1207+ const claudeArgs = [
1208+ "-p" , "-" ,
1209+ "--output-format" , "stream-json" ,
1210+ "--verbose" ,
1211+ "--allowedTools" ,
1212+ "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite" ,
1213+ "--max-turns" , "200" ,
1214+ ] ;
1215+ const pipeline = buildClaudePipeline ( claudeArgs , claudeWorkDir , promptFile ) ;
1216+ child = spawn ( pipeline . cmd , pipeline . args , {
1217+ cwd : claudeWorkDir ,
1218+ detached : true ,
1219+ stdio : [ "ignore" , logFd , logFd ] ,
1220+ env : spawnEnv ,
1221+ } ) ;
1222+ child . unref ( ) ;
11791223 } else if ( body . command === "EVALUATE_PRD" ) {
11801224 // CLOSEDLOOP_WORKDIR appears in both spawnEnv and prompt text intentionally:
11811225 // spawnEnv makes it available to skills; prompt text tells the model where to look.
@@ -1188,19 +1232,35 @@ async function handleLoopRequest(
11881232 repoLine ;
11891233 const promptFile = path . join ( claudeWorkDir , "evaluate-prd-prompt.txt" ) ;
11901234 await fs . writeFile ( promptFile , evaluatePrdPrompt ) ;
1191- child = spawnClaudeFromFile ( promptFile , claudeWorkDir , logFd , spawnEnv ) ;
1192- } else if ( body . command === "REQUEST_CHANGES" ) {
1193- // REQUEST_CHANGES: use claude directly with /code:amend-plan
1194- // Matches ECS harness buildClaudeDirectArgs() for REQUEST_CHANGES
1195- const claudeArgs : string [ ] = [ ] ;
11961235
1197- // Grant tool permissions matching harness
1198- claudeArgs . push (
1236+ const claudeArgs = [
1237+ "-p" , "-" ,
1238+ "--output-format" , "stream-json" ,
1239+ "--verbose" ,
11991240 "--allowedTools" ,
12001241 "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite" ,
1201- "--max-turns" ,
1202- "200"
1203- ) ;
1242+ "--max-turns" , "200" ,
1243+ ] ;
1244+ const pipeline = buildClaudePipeline ( claudeArgs , claudeWorkDir , promptFile ) ;
1245+ child = spawn ( pipeline . cmd , pipeline . args , {
1246+ cwd : claudeWorkDir ,
1247+ detached : true ,
1248+ stdio : [ "ignore" , logFd , logFd ] ,
1249+ env : spawnEnv ,
1250+ } ) ;
1251+ child . unref ( ) ;
1252+ } else if ( body . command === "REQUEST_CHANGES" ) {
1253+ // REQUEST_CHANGES: use claude directly with /code:amend-plan.
1254+ // Must use -p (headless mode) so --allowedTools grants full permission
1255+ // without prompting. Pipes through stream_formatter.py for readable logs.
1256+ const claudeArgs : string [ ] = [
1257+ "-p" ,
1258+ "--output-format" , "stream-json" ,
1259+ "--verbose" ,
1260+ "--allowedTools" ,
1261+ "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite" ,
1262+ "--max-turns" , "200" ,
1263+ ] ;
12041264
12051265 // Resume from parent session if available (matches harness --resume)
12061266 if ( body . parentSessionId ) {
@@ -1222,7 +1282,8 @@ async function handleLoopRequest(
12221282 `/code:amend-plan --workdir ${ claudeWorkDir } --message "${ sanitized } "`
12231283 ) ;
12241284
1225- child = spawn ( "claude" , claudeArgs , {
1285+ const pipeline = buildClaudePipeline ( claudeArgs , claudeWorkDir ) ;
1286+ child = spawn ( pipeline . cmd , pipeline . args , {
12261287 cwd : worktreeDir ! ,
12271288 detached : true ,
12281289 stdio : [ "ignore" , logFd , logFd ] ,
0 commit comments