-
Notifications
You must be signed in to change notification settings - Fork 1
fix: DECOMPOSE and REQUEST_CHANGES parity with run-loop.sh #33
Changes from all commits
5ac3346
2bb2f82
32a1bfb
69ee8cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -105,6 +105,60 @@ function shellEscape(value: string): string { | |
| return "'" + value.replaceAll("'", String.raw`'\''`) + "'"; | ||
| } | ||
|
|
||
| /** | ||
| * 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 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; | ||
| } | ||
|
|
||
| /** | ||
| * 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 +1067,6 @@ async function handleLoopRequest( | |
| claudeWorkDir = path.join(worktreeDir, ".claude", "work"); | ||
| await fs.mkdir(claudeWorkDir, { recursive: true }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [HIGH] Correctness [P1] Removal of settings.local.json permission grant breaks writes to .claude/work/ Recommendation: Re-add the settings.local.json permission grant before the command dispatch, or add an alternative mechanism to permit writes to .claude/work/**. If the behavior was intentionally removed because a newer Claude Code version no longer needs it, add a comment explaining that. claudeWorkDir = path.join(worktreeDir, ".claude", "work");
await fs.mkdir(claudeWorkDir, { recursive: true });
// <-- settings.local.json permission grant was removed here
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Intentional removal. We verified that settings.local.json allow rules do NOT override the .claude/ sensitive path protection — it's hardcoded in Claude Code (even bypassPermissions mode still prompts for .claude/ writes, per docs). The plugin already works around this by routing file modifications through Bash(cat/sed/python) instead of the Edit/Write tools. The work directory will move to .closedloop-ai/work/ in a future change to bypass this entirely. |
||
|
|
||
| // 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,33 +1162,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. | ||
| const claudeArgs: string[] = ["-p"]; | ||
|
|
||
| // Grant tool permissions matching harness + run-loop.sh | ||
| claudeArgs.push( | ||
| // without prompting. Pipes through stream_formatter.py for readable logs. | ||
| const claudeArgs: string[] = [ | ||
| "-p", | ||
| "--output-format", "stream-json", | ||
| "--verbose", | ||
| "--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) { | ||
|
|
@@ -1172,7 +1211,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], | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MEDIUM] Code Quality
[P2] findStreamFormatter duplicates plugin-cache utilities already exported by plugin-cache.ts
Recommendation: Rewrite
findStreamFormatterto callgetPluginCacheRoot()andfindPluginVersions()from plugin-cache.ts, then iterate over versions looking for{version}/tools/python/stream_formatter.py. Alternatively, add afindPluginTool(pluginName, toolSubPath)helper to plugin-cache.ts that accepts an arbitrary sub-path.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed — now uses getPluginCacheRoot() and findPluginVersions() from plugin-cache.ts. Removed the duplicated semver sort and readdirSync import.