From 5ac33464ee119dd93557c34f0966d622cafbab08 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Wed, 18 Mar 2026 13:10:01 -0500 Subject: [PATCH 1/4] fix: add --output-format stream-json to REQUEST_CHANGES for streaming log output --- apps/desktop/src/server/operations/symphony-loop.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 68147c54..956a2187 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -1142,7 +1142,11 @@ async function handleLoopRequest( // Must use -p (headless mode) so --allowedTools grants full permission // without prompting — without -p, Claude runs interactively and the // detached process hangs waiting for permission approval. - const claudeArgs: string[] = ["-p"]; + const claudeArgs: string[] = [ + "-p", + "--output-format", "stream-json", + "--verbose", + ]; // Grant tool permissions matching harness + run-loop.sh claudeArgs.push( From 2bb2f82f7f4020b1cb5459f883bc20fed7b78cc0 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Wed, 18 Mar 2026 14:20:58 -0500 Subject: [PATCH 2/4] fix: DECOMPOSE and REQUEST_CHANGES parity with run-loop.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands now match the run-loop.sh spawn pattern: - -p flag for headless mode (permissions without prompting) - --output-format stream-json for streaming log output - --verbose for detailed logging - --allowedTools with full tool set - --max-turns 200 Output is piped through stream_formatter.py (same pipeline as run-loop.sh) for human-readable logs instead of raw JSON. Also removes the settings.local.json write that attempted to grant Edit/Write permissions for .claude/work/ — this doesn't work because .claude/ is a hardcoded protected path in Claude Code. --- .../src/server/operations/symphony-loop.ts | 131 ++++++++++++------ 1 file changed, 90 insertions(+), 41 deletions(-) diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index 956a2187..b4760f6a 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -1,6 +1,6 @@ import { execSync, spawn } from "node:child_process"; import crypto from "node:crypto"; -import { closeSync, existsSync, openSync, readFileSync } from "node:fs"; +import { closeSync, existsSync, openSync, readFileSync, readdirSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -105,6 +105,73 @@ function shellEscape(value: string): string { return "'" + value.replaceAll("'", String.raw`'\''`) + "'"; } +/** + * Find the stream_formatter.py script from the code plugin. + * Falls back to null if not installed — caller should degrade gracefully. + */ +function findStreamFormatter(): string | null { + const cacheRoot = path.join(os.homedir(), ".claude", "plugins", "cache", "closedloop-ai", "code"); + try { + const versions = readdirSync(cacheRoot) + .filter((e: string) => /^\d+\.\d+\.\d+/.test(e)) + .sort((a: string, b: string) => { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + const diff = (pb[i] ?? 0) - (pa[i] ?? 0); + if (diff !== 0) { return diff; } + } + return 0; + }); + for (const v of versions) { + const p = path.join(cacheRoot, v, "tools", "python", "stream_formatter.py"); + if (existsSync(p)) { return p; } + } + } catch { + // Plugin not installed + } + return null; +} + +/** + * Build a bash pipeline command that runs claude with stream-json output, + * filters JSON lines, tees to a jsonl log, and formats for human reading. + * Falls back to raw claude if formatter is not available. + */ +function buildClaudePipeline( + claudeArgs: string[], + claudeWorkDir: string, + stdinFile?: string +): { cmd: string; args: string[] } { + const formatter = findStreamFormatter(); + const stderrFile = path.join(claudeWorkDir, "claude-stderr.log"); + const jsonlFile = path.join(claudeWorkDir, "claude-output.jsonl"); + + // Build the claude command with properly escaped args + const escapedArgs = claudeArgs.map(shellEscape).join(" "); + const claudeCmd = stdinFile + ? `claude ${escapedArgs} < ${shellEscape(stdinFile)}` + : `claude ${escapedArgs}`; + + if (formatter) { + // Full pipeline matching run-loop.sh: + // claude ... 2>stderr | grep JSON | tee jsonl | formatter + const pipeline = [ + `${claudeCmd} 2>${shellEscape(stderrFile)}`, + "grep --line-buffered '^{'", + `tee -a ${shellEscape(jsonlFile)}`, + `python3 ${shellEscape(formatter)}`, + ].join(" | "); + return { cmd: "bash", args: ["-c", pipeline] }; + } + + // No formatter — run claude directly (raw stream-json to stdout) + if (stdinFile) { + return { cmd: "bash", args: ["-c", claudeCmd] }; + } + return { cmd: "claude", args: claudeArgs }; +} + /** * Validate apiBaseUrl to prevent SSRF to private/metadata/loopback endpoints. * Uses deny-by-default for IP literals: extracts the IPv4 address (including @@ -1013,22 +1080,6 @@ async function handleLoopRequest( claudeWorkDir = path.join(worktreeDir, ".claude", "work"); await fs.mkdir(claudeWorkDir, { recursive: true }); - // Grant edit/write permissions for .claude/work/ — Claude Code treats - // files under .claude/ as sensitive and blocks edits even with --allowedTools. - // This settings.local.json allows headless (-p) runs to modify plan.json etc. - const settingsLocalPath = path.join(worktreeDir, ".claude", "settings.local.json"); - const settingsLocal = { - permissions: { - allow: [ - `Edit(path:.claude/work/**)`, - `Write(path:.claude/work/**)`, - `Edit(path:${claudeWorkDir}/**)`, - `Write(path:${claudeWorkDir}/**)`, - ], - }, - }; - await fs.writeFile(settingsLocalPath, JSON.stringify(settingsLocal, null, 2)); - if (body.command === "PLAN") { await writeArtifactsForPlan(claudeWorkDir, body.artifacts, body.prompt); } else if (body.command === "EXECUTE") { @@ -1124,37 +1175,34 @@ async function handleLoopRequest( const promptFile = path.join(claudeWorkDir, "decompose-prompt.txt"); await fs.writeFile(promptFile, decomposePrompt); - const promptFd = openSync(promptFile, "r"); - try { - child = spawn("claude", ["-p", "-", "--output-format", "json"], { - cwd: claudeWorkDir, - detached: true, - stdio: [promptFd, logFd, logFd], - env: spawnEnv, - }); - child.unref(); - } finally { - closeSync(promptFd); - } + const claudeArgs = [ + "-p", "-", + "--output-format", "stream-json", + "--verbose", + "--allowedTools", + "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite", + "--max-turns", "200", + ]; + const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir, promptFile); + child = spawn(pipeline.cmd, pipeline.args, { + cwd: claudeWorkDir, + detached: true, + stdio: ["ignore", logFd, logFd], + env: spawnEnv, + }); + child.unref(); } else if (body.command === "REQUEST_CHANGES") { - // REQUEST_CHANGES: use claude directly with /code:amend-plan - // Matches ECS harness buildClaudeDirectArgs() for REQUEST_CHANGES. + // REQUEST_CHANGES: use claude directly with /code:amend-plan. // Must use -p (headless mode) so --allowedTools grants full permission - // without prompting — without -p, Claude runs interactively and the - // detached process hangs waiting for permission approval. + // without prompting. Pipes through stream_formatter.py for readable logs. const claudeArgs: string[] = [ "-p", "--output-format", "stream-json", "--verbose", - ]; - - // Grant tool permissions matching harness + run-loop.sh - claudeArgs.push( "--allowedTools", "Bash,Glob,Grep,Read,Write,Edit,Task,Skill,SlashCommand,TodoWrite", - "--max-turns", - "200" - ); + "--max-turns", "200", + ]; // Resume from parent session if available (matches harness --resume) if (body.parentSessionId) { @@ -1176,7 +1224,8 @@ async function handleLoopRequest( `/code:amend-plan --workdir ${claudeWorkDir} --message "${sanitized}"` ); - child = spawn("claude", claudeArgs, { + const pipeline = buildClaudePipeline(claudeArgs, claudeWorkDir); + child = spawn(pipeline.cmd, pipeline.args, { cwd: worktreeDir!, detached: true, stdio: ["ignore", logFd, logFd], From 32a1bfb64a329fbbabbd2a73994d6d391c76fff7 Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Wed, 18 Mar 2026 14:21:26 -0500 Subject: [PATCH 3/4] chore: bump desktop version to 0.4.6 --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c59a38f8..36a579aa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.4.5", + "version": "0.4.6", "description": "ClosedLoop Desktop", "author": "ClosedLoop AI ", "private": true, From 69ee8cc180680a90fe96b7a567e909561417f2fd Mon Sep 17 00:00:00 2001 From: Thadeus Burgess Date: Wed, 18 Mar 2026 14:41:17 -0500 Subject: [PATCH 4/4] refactor: reuse plugin-cache.ts helpers instead of duplicating semver sort --- .../src/server/operations/symphony-loop.ts | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/server/operations/symphony-loop.ts b/apps/desktop/src/server/operations/symphony-loop.ts index b4760f6a..de862ca9 100644 --- a/apps/desktop/src/server/operations/symphony-loop.ts +++ b/apps/desktop/src/server/operations/symphony-loop.ts @@ -1,6 +1,6 @@ import { execSync, spawn } from "node:child_process"; import crypto from "node:crypto"; -import { closeSync, existsSync, openSync, readFileSync, readdirSync } from "node:fs"; +import { closeSync, existsSync, openSync, readFileSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -9,7 +9,7 @@ import type { OperationRequestContext, } from "../operation-dispatcher.js"; import { assertPathAllowed, DirectoryNotAllowedError } from "../security.js"; -import { findPluginScript } from "./plugin-cache.js"; +import { findPluginScript, findPluginVersions, getPluginCacheRoot } from "./plugin-cache.js"; import { expandHome, resolveWorktreeParentDir, @@ -107,28 +107,15 @@ function shellEscape(value: string): string { /** * Find the stream_formatter.py script from the code plugin. + * Reuses getPluginCacheRoot() and findPluginVersions() from plugin-cache.ts. * Falls back to null if not installed — caller should degrade gracefully. */ function findStreamFormatter(): string | null { - const cacheRoot = path.join(os.homedir(), ".claude", "plugins", "cache", "closedloop-ai", "code"); - try { - const versions = readdirSync(cacheRoot) - .filter((e: string) => /^\d+\.\d+\.\d+/.test(e)) - .sort((a: string, b: string) => { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - for (let i = 0; i < 3; i++) { - const diff = (pb[i] ?? 0) - (pa[i] ?? 0); - if (diff !== 0) { return diff; } - } - return 0; - }); - for (const v of versions) { - const p = path.join(cacheRoot, v, "tools", "python", "stream_formatter.py"); - if (existsSync(p)) { return p; } - } - } catch { - // Plugin not installed + const pluginDir = path.join(getPluginCacheRoot(), "code"); + const versions = findPluginVersions(pluginDir); + for (const v of versions) { + const p = path.join(pluginDir, v, "tools", "python", "stream_formatter.py"); + if (existsSync(p)) { return p; } } return null; }