Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit 9f7439b

Browse files
aponamarevclaude
andcommitted
FEAT-73: port buildClaudePipeline to DECOMPOSE, EVALUATE_PRD, REQUEST_CHANGES
PR #30 branched before PRs #31-#33 landed, leaving all three commands on the old spawnClaudeFromFile path (--output-format json, no streaming, no token tracking, no --allowedTools, no --max-turns). This commit brings parity with the fixes merged to main: - Add findStreamFormatter() and buildClaudePipeline() (ported from main) - DECOMPOSE: switch from spawnClaudeFromFile to buildClaudePipeline with -p -, --output-format stream-json, --verbose, --allowedTools, --max-turns 200 - EVALUATE_PRD: same treatment — produces claude-output.jsonl for token tracking, claude-stderr.log for debugging, and readable formatted logs - REQUEST_CHANGES: add -p, --output-format stream-json, --verbose, and route through buildClaudePipeline (was spawning claude directly) - Remove now-dead spawnClaudeFromFile helper Testing: `just desktop-typecheck` clean; all 242 tests pass Risks: None — behaviour change is intentional parity fix; formatter falls back gracefully when stream_formatter.py is not installed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3140f16 commit 9f7439b

1 file changed

Lines changed: 101 additions & 40 deletions

File tree

apps/desktop/src/server/operations/symphony-loop.ts

Lines changed: 101 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type {
99
OperationRequestContext,
1010
} from "../operation-dispatcher.js";
1111
import { assertPathAllowed, DirectoryNotAllowedError } from "../security.js";
12-
import { findPluginScript } from "./plugin-cache.js";
12+
import { findPluginScript, findPluginVersions, getPluginCacheRoot } from "./plugin-cache.js";
1313
import {
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

Comments
 (0)