Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "desktop",
"version": "0.4.5",
"version": "0.4.6",
"description": "ClosedLoop Desktop",
"author": "ClosedLoop AI <support@closedloop.ai>",
"private": true,
Expand Down
135 changes: 94 additions & 41 deletions apps/desktop/src/server/operations/symphony-loop.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown

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 findStreamFormatter to call getPluginCacheRoot() and findPluginVersions() from plugin-cache.ts, then iterate over versions looking for {version}/tools/python/stream_formatter.py. Alternatively, add a findPluginTool(pluginName, toolSubPath) helper to plugin-cache.ts that accepts an arbitrary sub-path.

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) => { ... });

Copy link
Copy Markdown
Contributor Author

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.

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) => {

Copy link
Copy Markdown

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 compareSemverDescending from plugin-cache.ts

Recommendation: Replace the inline sort with compareSemverDescending and the version scan with findPluginVersions, both already imported from ./plugin-cache.js. The only local logic needed is the tools/python/stream_formatter.py path construction.

.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;
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in same commit as comment #1 — findStreamFormatter now uses getPluginCacheRoot() and findPluginVersions() from plugin-cache.ts. No more inline semver sort.

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
Expand Down Expand Up @@ -1013,22 +1080,6 @@ async function handleLoopRequest(
claudeWorkDir = path.join(worktreeDir, ".claude", "work");
await fs.mkdir(claudeWorkDir, { recursive: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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") {
Expand Down Expand Up @@ -1124,33 +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.
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) {
Expand All @@ -1172,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],
Expand Down
Loading