@@ -50,8 +50,6 @@ interface LoopRequestBody {
5050 committer ?: LoopCommitter ;
5151 parentBranchName ?: string ;
5252 parentSessionId ?: string ;
53- /** The loop's own session ID from a previous run (for --resume). */
54- sessionId ?: string ;
5553 prompt ?: string ;
5654}
5755
@@ -213,30 +211,29 @@ function findLocalRepo(
213211
214212/**
215213 * Resolve worktree directory for a loop.
216- * Prefers session ID for stable naming (matches ECS harness behavior).
217- * Falls back to full artifact or loop ID if session ID is unavailable.
214+ * Uses full untruncated stable ID for directory naming.
218215 */
219216function resolveLoopWorktreeDir (
220217 expandedRepoPath : string ,
221218 stableId : string
222219) : string {
223220 const repoName = path . basename ( expandedRepoPath ) ;
224- const shortId = stableId . slice ( 0 , 8 ) ;
225221 return path . join (
226222 resolveWorktreeParentDir ( expandedRepoPath ) ,
227- `${ repoName } -loop-${ shortId } `
223+ `${ repoName } -loop-${ stableId } `
228224 ) ;
229225}
230226
231227/**
232- * Pick the best stable ID for worktree naming.
233- * Priority: sessionId > artifactId > loopId (all full, untruncated UUIDs).
228+ * Pick the stable ID for worktree/branch naming.
229+ * Uses loopId (matching ECS harness branch/run-dir naming).
230+ * Slugified the same way as the harness: lowercase, non-alnum to dashes, max 50 chars.
234231 */
235232function pickStableId ( body : LoopRequestBody ) : string {
236- if ( body . sessionId ) {
237- return body . sessionId ;
238- }
239- return body . artifacts [ 0 ] ?. id ?? body . loopId ;
233+ return body . loopId
234+ . toLowerCase ( )
235+ . replace ( / [ ^ a - z 0 - 9 - ] / g , "-" )
236+ . slice ( 0 , 50 ) ;
240237}
241238
242239// ---------------------------------------------------------------------------
@@ -407,16 +404,32 @@ function findExistingLoopWorktree(
407404// Per-command artifact writing
408405// ---------------------------------------------------------------------------
409406
407+ /**
408+ * Write PRD for PLAN command.
409+ * Matches ECS harness writePrdFile(): prompt first, then PRD artifact, then FEATURE.
410+ */
410411async function writeArtifactsForPlan (
411412 claudeWorkDir : string ,
412- artifacts : LoopArtifact [ ]
413+ artifacts : LoopArtifact [ ] ,
414+ prompt ?: string
413415) : Promise < void > {
414- const prdTypes = new Set ( [ "prd" , "PRD" , "artifact" , "FEATURE" ] ) ;
415- for ( const artifact of artifacts ) {
416- if ( prdTypes . has ( artifact . type ) ) {
417- await fs . writeFile ( path . join ( claudeWorkDir , "prd.md" ) , artifact . content ) ;
416+ // Priority: explicit prompt > PRD artifact > FEATURE artifact (matches harness)
417+ let prdContent = prompt ?? null ;
418+
419+ if ( ! prdContent ) {
420+ const prdArtifact = artifacts . find ( ( a ) => a . type === "PRD" || a . type === "prd" ) ;
421+ const featureArtifact = prdArtifact
422+ ? null
423+ : artifacts . find ( ( a ) => a . type === "FEATURE" || a . type === "artifact" ) ;
424+ const source = prdArtifact ?? featureArtifact ;
425+ if ( source ?. content ) {
426+ prdContent = source . content ;
418427 }
419428 }
429+
430+ if ( prdContent ) {
431+ await fs . writeFile ( path . join ( claudeWorkDir , "prd.md" ) , prdContent ) ;
432+ }
420433}
421434
422435async function writeArtifactsForExecuteOrAmend (
@@ -464,13 +477,26 @@ async function writeArtifactsForExecuteOrAmend(
464477
465478async function writeArtifactsForDecompose (
466479 tmpDir : string ,
467- artifacts : LoopArtifact [ ]
480+ artifacts : LoopArtifact [ ] ,
481+ prompt ?: string
468482) : Promise < void > {
469- for ( const artifact of artifacts ) {
470- if ( artifact . type === "prd" || artifact . type === "artifact" ) {
471- await fs . writeFile ( path . join ( tmpDir , "prd.md" ) , artifact . content ) ;
483+ // Same priority as writeArtifactsForPlan: prompt > PRD > FEATURE
484+ let prdContent = prompt ?? null ;
485+
486+ if ( ! prdContent ) {
487+ const prdArtifact = artifacts . find ( ( a ) => a . type === "PRD" || a . type === "prd" ) ;
488+ const featureArtifact = prdArtifact
489+ ? null
490+ : artifacts . find ( ( a ) => a . type === "FEATURE" || a . type === "artifact" ) ;
491+ const source = prdArtifact ?? featureArtifact ;
492+ if ( source ?. content ) {
493+ prdContent = source . content ;
472494 }
473495 }
496+
497+ if ( prdContent ) {
498+ await fs . writeFile ( path . join ( tmpDir , "prd.md" ) , prdContent ) ;
499+ }
474500}
475501
476502// ---------------------------------------------------------------------------
@@ -696,9 +722,12 @@ async function handleProcessCompletion(
696722
697723 if ( exitCode !== 0 ) {
698724 loopError ( loopId , `Process failed with exit code ${ exitCode } ` ) ;
725+ // Error shape matches ECS harness: top-level code/message, not nested error object
699726 await postLoopEvent ( apiBaseUrl , loopId , closedLoopAuthToken , {
700727 type : "error" ,
701- error : { code : "PROCESS_FAILED" , message : `Process exited with code ${ exitCode } ` } ,
728+ code : "PROCESS_FAILED" ,
729+ message : `Process exited with code ${ exitCode } ` ,
730+ loopId,
702731 } ) ;
703732 return ;
704733 }
@@ -756,29 +785,32 @@ async function handleProcessCompletion(
756785 const tokensUsed = parseTokenUsage ( claudeWorkDir ) ;
757786 loopLog ( loopId , `Tokens used: input=${ tokensUsed . input } , output=${ tokensUsed . output } ` ) ;
758787
759- // Post completed event
760- const completedEvent : Record < string , unknown > = {
761- type : "completed" ,
762- result : { subtype : command . toLowerCase ( ) } ,
763- tokensUsed,
764- timestamp : new Date ( ) . toISOString ( ) ,
788+ // Post completed event — shape matches ECS harness reportFinalStatus()
789+ const result : Record < string , unknown > = {
790+ exitCode,
791+ subtype : command . toLowerCase ( ) ,
765792 } ;
766793
767794 if ( command === "EXECUTE" && artifacts . executionResult ) {
768795 const execResult = artifacts . executionResult as Record < string , unknown > ;
769- completedEvent . result = {
770- subtype : "execute" ,
771- pr_url : execResult . pr_url ,
772- pr_number : execResult . pr_number ,
773- branch_name : execResult . branch_name ,
774- has_changes : execResult . has_changes ?? false ,
775- } ;
796+ result . pr_url = execResult . pr_url ;
797+ result . pr_number = execResult . pr_number ;
798+ result . branch_name = execResult . branch_name ;
799+ result . has_changes = execResult . has_changes ?? false ;
776800 }
777801
802+ // sessionId inside result (matches harness)
778803 if ( metadata . sessionId ) {
779- completedEvent . sessionId = metadata . sessionId ;
804+ result . sessionId = metadata . sessionId ;
780805 }
781806
807+ const completedEvent : Record < string , unknown > = {
808+ type : "completed" ,
809+ result,
810+ tokensUsed,
811+ loopId,
812+ } ;
813+
782814 loopLog ( loopId , "Posting completed event..." ) ;
783815 await postLoopEvent ( apiBaseUrl , loopId , closedLoopAuthToken , completedEvent ) ;
784816 loopLog ( loopId , "Loop completed successfully" ) ;
@@ -842,7 +874,7 @@ async function handleLoopRequest(
842874 // Claim the loopId immediately to prevent concurrent requests from racing
843875 // past the has() check. Replaced with real entry after spawn succeeds.
844876 runningLoops . set ( body . loopId , { pid : - 1 , child : null as unknown as ReturnType < typeof spawn > } ) ;
845- loopLog ( body . loopId , `Received ${ body . command } request, repo=${ body . repo ?. fullName ?? "none" } , stableId=${ pickStableId ( body ) . slice ( 0 , 8 ) } , sessionId= ${ body . sessionId ?? "none" } , parentSessionId=${ body . parentSessionId ?? "none" } ` ) ;
877+ loopLog ( body . loopId , `Received ${ body . command } request, repo=${ body . repo ?. fullName ?? "none" } , stableId=${ pickStableId ( body ) } , parentSessionId=${ body . parentSessionId ?? "none" } ` ) ;
846878
847879 let spawnedSuccessfully = false ;
848880 try {
@@ -879,7 +911,7 @@ async function handleLoopRequest(
879911 ) ;
880912 await fs . mkdir ( tmpDir , { recursive : true } ) ;
881913 claudeWorkDir = tmpDir ;
882- await writeArtifactsForDecompose ( claudeWorkDir , body . artifacts ) ;
914+ await writeArtifactsForDecompose ( claudeWorkDir , body . artifacts , body . prompt ) ;
883915 } else if ( ! expandedRepoPath ) {
884916 json ( context , 400 , {
885917 error : "Repository required for PLAN, EXECUTE, and REQUEST_CHANGES commands" ,
@@ -900,7 +932,7 @@ async function handleLoopRequest(
900932 throw e ;
901933 }
902934 } else {
903- const loopBranch = `symphony/loop-${ pickStableId ( body ) . slice ( 0 , 8 ) } ` ;
935+ const loopBranch = `symphony/loop-${ pickStableId ( body ) } ` ;
904936 worktreeDir = resolveLoopWorktreeDir ( expandedRepoPath , pickStableId ( body ) ) ;
905937 await ensureWorktree (
906938 expandedRepoPath ,
@@ -912,7 +944,7 @@ async function handleLoopRequest(
912944 }
913945 claudeWorkDir = path . join ( worktreeDir , ".claude" , "work" ) ;
914946 await fs . mkdir ( claudeWorkDir , { recursive : true } ) ;
915- await writeArtifactsForPlan ( claudeWorkDir , body . artifacts ) ;
947+ await writeArtifactsForPlan ( claudeWorkDir , body . artifacts , body . prompt ) ;
916948 } else if ( body . command === "EXECUTE" || body . command === "REQUEST_CHANGES" ) {
917949 // EXECUTE/REQUEST_CHANGES: reuse parent worktree if possible
918950 if ( body . parentBranchName ) {
@@ -934,7 +966,7 @@ async function handleLoopRequest(
934966 }
935967 if ( ! worktreeDir ) {
936968 // Create new worktree
937- const loopBranch = `symphony/loop-${ pickStableId ( body ) . slice ( 0 , 8 ) } ` ;
969+ const loopBranch = `symphony/loop-${ pickStableId ( body ) } ` ;
938970 worktreeDir = resolveLoopWorktreeDir ( expandedRepoPath , pickStableId ( body ) ) ;
939971 await ensureWorktree (
940972 expandedRepoPath ,
@@ -960,9 +992,13 @@ async function handleLoopRequest(
960992 return ;
961993 }
962994
963- // Pre-flight: verify required binary exists BEFORE posting 'started' event
995+ // Pre-flight: verify required binary exists BEFORE posting 'started' event.
996+ // PLAN and EXECUTE use run-loop.sh; REQUEST_CHANGES and DECOMPOSE use claude CLI directly.
997+ const usesRunLoop = body . command === "PLAN" || body . command === "EXECUTE" ;
998+ const usesClaude = body . command === "REQUEST_CHANGES" || body . command === "DECOMPOSE" ;
964999 let scriptPath : string | null = null ;
965- if ( body . command === "DECOMPOSE" ) {
1000+
1001+ if ( usesClaude ) {
9661002 try {
9671003 execSync ( "which claude" , { stdio : "pipe" , timeout : 5000 } ) ;
9681004 } catch {
@@ -972,13 +1008,13 @@ async function handleLoopRequest(
9721008 body . closedLoopAuthToken ,
9731009 {
9741010 type : "error" ,
975- error : { code : "BINARY_NOT_FOUND" , message : "claude CLI not found in PATH" } ,
1011+ code : "BINARY_NOT_FOUND" , message : "claude CLI not found in PATH" ,
9761012 }
9771013 ) ;
9781014 json ( context , 500 , { error : "claude CLI not found in PATH" } ) ;
9791015 return ;
9801016 }
981- } else {
1017+ } else if ( usesRunLoop ) {
9821018 scriptPath = findPluginScript ( "code" , "run-loop.sh" ) ;
9831019 if ( ! scriptPath ) {
9841020 await postLoopEvent (
@@ -987,7 +1023,7 @@ async function handleLoopRequest(
9871023 body . closedLoopAuthToken ,
9881024 {
9891025 type : "error" ,
990- error : { code : "SCRIPT_NOT_FOUND" , message : "run-loop.sh not found in plugin cache" } ,
1026+ code : "SCRIPT_NOT_FOUND" , message : "run-loop.sh not found in plugin cache" ,
9911027 }
9921028 ) ;
9931029 json ( context , 500 , { error : "run-loop.sh not found in plugin cache" } ) ;
@@ -1013,14 +1049,20 @@ async function handleLoopRequest(
10131049 const msg = logErr instanceof Error ? logErr . message : String ( logErr ) ;
10141050 await postLoopEvent ( body . apiBaseUrl , body . loopId , body . closedLoopAuthToken , {
10151051 type : "error" ,
1016- error : { code : "SPAWN_FAILED" , message : `Cannot open log file: ${ msg } ` } ,
1052+ code : "SPAWN_FAILED" , message : `Cannot open log file: ${ msg } ` ,
10171053 } ) ;
10181054 json ( context , 500 , { error : `Cannot open log file: ${ msg } ` } ) ;
10191055 return ;
10201056 }
10211057 let child : ReturnType < typeof spawn > ;
10221058
10231059 try {
1060+ const spawnEnv : Record < string , string > = {
1061+ ...( process . env as Record < string , string > ) ,
1062+ CLOSEDLOOP_WORKDIR : claudeWorkDir ,
1063+ PATH : `${ process . env . PATH } :/opt/homebrew/bin:/usr/local/bin` ,
1064+ } ;
1065+
10241066 if ( body . command === "DECOMPOSE" ) {
10251067 // DECOMPOSE: write prompt to file and pass via stdin to avoid E2BIG
10261068 const prdContent = readTextFile ( path . join ( claudeWorkDir , "prd.md" ) ) ?? "" ;
@@ -1034,33 +1076,58 @@ async function handleLoopRequest(
10341076 cwd : claudeWorkDir ,
10351077 detached : true ,
10361078 stdio : [ promptFd , logFd , logFd ] ,
1037- env : {
1038- ...process . env ,
1039- PATH : `${ process . env . PATH } :/opt/homebrew/bin:/usr/local/bin` ,
1040- } ,
1079+ env : spawnEnv ,
10411080 } ) ;
10421081 child . unref ( ) ;
10431082 } finally {
10441083 closeSync ( promptFd ) ;
10451084 }
1046- } else {
1047- // PLAN, EXECUTE, REQUEST_CHANGES: spawn run-loop.sh
1048- const spawnEnv : Record < string , string > = {
1049- ...( process . env as Record < string , string > ) ,
1050- CLOSEDLOOP_WORKDIR : claudeWorkDir ,
1051- PATH : `${ process . env . PATH } :/opt/homebrew/bin:/usr/local/bin` ,
1052- } ;
1053-
1054- // Prefer loop's own session ID (re-run/resume), fall back to parent's
1055- const resumeSessionId = body . sessionId ?? body . parentSessionId ;
1056- if ( resumeSessionId ) {
1057- spawnEnv . CLOSEDLOOP_SESSION_ID = resumeSessionId ;
1085+ } else if ( body . command === "REQUEST_CHANGES" ) {
1086+ // REQUEST_CHANGES: use claude directly with /code:amend-plan
1087+ // Matches ECS harness buildClaudeDirectArgs() for REQUEST_CHANGES
1088+ const claudeArgs : string [ ] = [ ] ;
1089+
1090+ // Grant tool permissions matching harness
1091+ claudeArgs . push (
1092+ "--allowedTools" ,
1093+ "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite" ,
1094+ "--max-turns" ,
1095+ "200"
1096+ ) ;
1097+
1098+ // Resume from parent session if available (matches harness --resume)
1099+ if ( body . parentSessionId ) {
1100+ claudeArgs . push ( "--resume" , body . parentSessionId ) ;
10581101 }
10591102
1103+ // Build /code:amend-plan invocation matching harness
1104+ const promptFile = path . join ( claudeWorkDir , "prompt.md" ) ;
1105+ let amendPrompt = "Please amend the plan based on the requested changes." ;
1106+ if ( existsSync ( promptFile ) ) {
1107+ amendPrompt = readFileSync ( promptFile , "utf-8" ) ;
1108+ }
1109+ // Sanitize prompt matching harness's prepare-message step
1110+ const sanitized = amendPrompt
1111+ . replace ( / [ \n \r ] + / g, " " )
1112+ . replace ( / \s { 2 , } / g, " " )
1113+ . replace ( / " / g, '\\"' ) ;
1114+ claudeArgs . push (
1115+ `/code:amend-plan --workdir ${ claudeWorkDir } --message "${ sanitized } "`
1116+ ) ;
1117+
1118+ child = spawn ( "claude" , claudeArgs , {
1119+ cwd : worktreeDir ! ,
1120+ detached : true ,
1121+ stdio : [ "ignore" , logFd , logFd ] ,
1122+ env : spawnEnv ,
1123+ } ) ;
1124+ child . unref ( ) ;
1125+ } else {
1126+ // PLAN, EXECUTE: spawn run-loop.sh
10601127 // Build args matching ECS harness-agent's buildRunLoopArgs():
10611128 // 1. workdir (positional)
1062- // 2. --max-iterations (EXECUTE=150, others =50)
1063- // 3. --prd (always, when prd.md exists)
1129+ // 2. --max-iterations (EXECUTE=150, PLAN =50)
1130+ // 3. --prd (when prd.md exists)
10641131 const scriptArgs = [ claudeWorkDir ] ;
10651132
10661133 const maxIterations = body . command === "EXECUTE" ? "150" : "50" ;
@@ -1084,7 +1151,7 @@ async function handleLoopRequest(
10841151 const msg = spawnErr instanceof Error ? spawnErr . message : String ( spawnErr ) ;
10851152 await postLoopEvent ( body . apiBaseUrl , body . loopId , body . closedLoopAuthToken , {
10861153 type : "error" ,
1087- error : { code : "SPAWN_FAILED" , message : msg } ,
1154+ code : "SPAWN_FAILED" , message : msg ,
10881155 } ) ;
10891156 json ( context , 500 , { error : `Failed to spawn process: ${ msg } ` } ) ;
10901157 return ;
0 commit comments