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

Commit 3fc1867

Browse files
authored
Merge pull request #40 from closedloop-ai/symphony/plan-73
PLAN-73: Add intermediate event reporting to Electron loop harness
2 parents b2e3e8c + b59b75a commit 3fc1867

5 files changed

Lines changed: 959 additions & 13 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "desktop",
3-
"version": "0.8.1",
3+
"version": "0.8.2",
44
"description": "ClosedLoop Desktop",
55
"author": "ClosedLoop AI <support@closedloop.ai>",
66
"private": true,
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { openSync, readSync, closeSync, existsSync } from "node:fs";
2+
import { randomUUID } from "node:crypto";
3+
4+
export function isRecord(v: unknown): v is Record<string, unknown> {
5+
return typeof v === "object" && v !== null && !Array.isArray(v);
6+
}
7+
8+
// ---------------------------------------------------------------------------
9+
// JSONL record types (Claude CLI streaming output)
10+
// ---------------------------------------------------------------------------
11+
12+
type TextBlock = { type: "text"; text: string };
13+
type ToolUseBlock = { type: "tool_use"; name: string; input?: Record<string, unknown> };
14+
type ThinkingBlock = { type: "thinking" };
15+
type ToolResultBlock = { type: "tool_result"; is_error?: boolean; content?: string | unknown[] };
16+
17+
type ContentBlock = TextBlock | ToolUseBlock | ThinkingBlock | ToolResultBlock;
18+
19+
type AssistantRecord = {
20+
type: "assistant";
21+
message: { content: ContentBlock[] };
22+
};
23+
24+
type UserRecord = {
25+
type: "user";
26+
message: { content: ContentBlock[] };
27+
};
28+
29+
type ContentBlockDeltaRecord = {
30+
type: "content_block_delta";
31+
delta: { type: "text_delta"; text: string };
32+
};
33+
34+
type ResultRecord = {
35+
type: "result";
36+
subtype?: "success" | "error";
37+
is_error?: boolean;
38+
result?: string;
39+
error?: string;
40+
};
41+
42+
export type JsonlRecord = AssistantRecord | UserRecord | ContentBlockDeltaRecord | ResultRecord;
43+
44+
function truncate(s: string, n: number): string {
45+
return s.length > n ? s.slice(0, n) + "..." : s;
46+
}
47+
48+
function redactSensitive(input: string): string {
49+
return input
50+
.replace(/AKIA[A-Z0-9]{16}/g, "[REDACTED]")
51+
.replace(/sk-ant-[A-Za-z0-9\-_]+/g, "[REDACTED]")
52+
.replace(/sk-[A-Za-z0-9]{32,}/g, "[REDACTED]")
53+
.replace(/Bearer [A-Za-z0-9._\-]+/g, "Bearer [REDACTED]")
54+
.replace(/-----BEGIN [A-Z ]+ KEY-----/g, "[REDACTED]");
55+
}
56+
57+
function summarizeToolInput(name: string, input: Record<string, unknown>): string {
58+
const filePath = input.file_path ?? input.path;
59+
if (typeof filePath === "string") return `Tool: ${name}(${truncate(filePath, 80)})`;
60+
if (typeof input.command === "string") return `Tool: ${name}(${truncate(input.command, 80)})`;
61+
if (typeof input.pattern === "string") return `Tool: ${name}(${truncate(input.pattern, 80)})`;
62+
return `Tool: ${name}`;
63+
}
64+
65+
function summarizeToolResult(block: ToolResultBlock): string {
66+
if (block.is_error === true) return "Tool error";
67+
const content = block.content;
68+
if (typeof content === "string" && content.length > 0) return `Tool result: ${truncate(content, 120)}`;
69+
if (Array.isArray(content)) {
70+
for (const part of content) {
71+
if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
72+
return `Tool result: ${truncate(part.text, 120)}`;
73+
}
74+
}
75+
}
76+
return "Tool result";
77+
}
78+
79+
/** Accepts a parsed JSONL record (untrusted) and returns a display summary, or null to skip. */
80+
export function summarizeJsonlRecord(record: Record<string, unknown>): string | null {
81+
const typed = record as JsonlRecord;
82+
83+
switch (typed.type) {
84+
case "assistant":
85+
case "user": {
86+
const message = isRecord(typed.message) ? typed.message : null;
87+
if (!message) return null;
88+
const content = Array.isArray(message.content) ? (message.content as ContentBlock[]) : [];
89+
for (const block of content) {
90+
if (!isRecord(block)) continue;
91+
switch (block.type) {
92+
case "tool_use": {
93+
const b = block as ToolUseBlock;
94+
const input = isRecord(b.input) ? b.input : {};
95+
return redactSensitive(summarizeToolInput(String(b.name ?? "unknown"), input));
96+
}
97+
case "text":
98+
return redactSensitive(truncate(String((block as TextBlock).text ?? ""), 200));
99+
case "thinking":
100+
return redactSensitive("Thinking...");
101+
case "tool_result":
102+
return redactSensitive(summarizeToolResult(block as ToolResultBlock));
103+
}
104+
}
105+
return null;
106+
}
107+
case "content_block_delta": {
108+
const delta = isRecord(typed.delta) ? typed.delta : null;
109+
if (delta && (delta as ContentBlockDeltaRecord["delta"]).type === "text_delta") {
110+
return redactSensitive(truncate(String((delta as ContentBlockDeltaRecord["delta"]).text ?? ""), 200));
111+
}
112+
return null;
113+
}
114+
case "result": {
115+
const r = typed as ResultRecord;
116+
if (r.subtype === "success") {
117+
return redactSensitive("Turn complete");
118+
}
119+
if (r.subtype === "error" || r.is_error === true) {
120+
return redactSensitive(
121+
`Error: ${truncate(String(r.result ?? r.error ?? ""), 200)}`
122+
);
123+
}
124+
return null;
125+
}
126+
default:
127+
return null;
128+
}
129+
}
130+
131+
// ---------------------------------------------------------------------------
132+
// API communication
133+
// ---------------------------------------------------------------------------
134+
135+
async function postLoopEvent(
136+
apiBaseUrl: string,
137+
loopId: string,
138+
token: string,
139+
event: { type: string; data: { chunk: string } }
140+
): Promise<void> {
141+
try {
142+
await fetch(`${apiBaseUrl}/loops/${loopId}/events`, {
143+
method: "POST",
144+
headers: {
145+
"Authorization": `Bearer ${token}`,
146+
"Content-Type": "application/json",
147+
"x-loop-event-nonce": randomUUID(),
148+
},
149+
body: JSON.stringify({
150+
type: event.type,
151+
data: { chunk: event.data.chunk },
152+
timestamp: new Date().toISOString(),
153+
}),
154+
});
155+
} catch (err) {
156+
console.error("[output-tailer] Failed to post loop event:", err);
157+
}
158+
}
159+
160+
// ---------------------------------------------------------------------------
161+
// Output tailer
162+
// ---------------------------------------------------------------------------
163+
164+
export function startOutputTailer(
165+
jsonlPath: string,
166+
apiBaseUrl: string,
167+
loopId: string,
168+
token: string,
169+
initialByteOffset: number
170+
): { stop: () => void; flush: () => Promise<void> } {
171+
let stopped = false;
172+
let byteOffset = initialByteOffset;
173+
let pendingRemainder = Buffer.alloc(0);
174+
let lastSentAt: number | null = null;
175+
176+
async function pollOnce(): Promise<void> {
177+
if (stopped) return;
178+
if (!existsSync(jsonlPath)) return;
179+
let fd: number | null = null;
180+
try {
181+
fd = openSync(jsonlPath, "r");
182+
const chunkSize = 65536;
183+
const chunk = Buffer.alloc(chunkSize);
184+
let bytesRead: number;
185+
while ((bytesRead = readSync(fd, chunk, 0, chunkSize, byteOffset)) > 0) {
186+
byteOffset += bytesRead;
187+
pendingRemainder = Buffer.concat([pendingRemainder, chunk.subarray(0, bytesRead)]);
188+
}
189+
} catch {
190+
return;
191+
} finally {
192+
if (fd !== null) closeSync(fd);
193+
}
194+
195+
const newlineIndex = pendingRemainder.lastIndexOf(10); // 0x0a = newline
196+
if (newlineIndex === -1) return;
197+
const completeLines = pendingRemainder.subarray(0, newlineIndex).toString("utf8");
198+
pendingRemainder = pendingRemainder.subarray(newlineIndex + 1);
199+
200+
let lastDisplay: string | null = null;
201+
for (const line of completeLines.split("\n")) {
202+
const trimmed = line.trim();
203+
if (!trimmed) continue;
204+
let parsed: unknown;
205+
try {
206+
parsed = JSON.parse(trimmed);
207+
} catch {
208+
continue;
209+
}
210+
if (!isRecord(parsed)) continue;
211+
const display = summarizeJsonlRecord(parsed);
212+
if (!display) continue;
213+
lastDisplay = display;
214+
}
215+
216+
if (lastDisplay !== null) {
217+
const now = Date.now();
218+
if (lastSentAt === null || now - lastSentAt >= 5000) {
219+
lastSentAt = now;
220+
await postLoopEvent(apiBaseUrl, loopId, token, { type: "output", data: { chunk: lastDisplay } });
221+
}
222+
}
223+
}
224+
225+
const intervalId = setInterval(() => { pollOnce().catch(() => {}); }, 2000);
226+
227+
return {
228+
stop: () => { stopped = true; clearInterval(intervalId); },
229+
flush: async () => {
230+
clearInterval(intervalId);
231+
await pollOnce();
232+
stopped = true;
233+
},
234+
};
235+
}

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execSync, spawn } from "node:child_process";
22
import { gatewayLog } from "../../main/gateway-logger.js";
33
import crypto from "node:crypto";
4-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
55
import fs from "node:fs/promises";
66
import os from "node:os";
77
import path from "node:path";
@@ -23,6 +23,7 @@ import {
2323
resolveWorktreeParentDir,
2424
tryAssertRepoAllowed,
2525
} from "./symphony-utils.js";
26+
import { startOutputTailer } from "./output-tailer.js";
2627

2728
// ---------------------------------------------------------------------------
2829
// Types
@@ -216,11 +217,13 @@ function buildClaudePipeline(
216217
return { cmd: "bash", args: ["-c", pipeline] };
217218
}
218219

219-
// No formatter — run claude directly (raw stream-json to stdout)
220-
if (stdinFile) {
221-
return { cmd: "bash", args: ["-c", claudeCmd] };
222-
}
223-
return { cmd: "claude", args: claudeArgs };
220+
// No formatter — wrap in bash pipeline so grep|tee still writes claude-output.jsonl
221+
const pipeline = [
222+
`${claudeCmd} 2>${shellEscape(stderrFile)}`,
223+
"grep --line-buffered '^{'",
224+
`tee -a ${shellEscape(jsonlFile)}`,
225+
].join(" | ");
226+
return { cmd: "bash", args: ["-c", pipeline] };
224227
}
225228

226229
/** Find the local repo path for a given fullName (e.g. "org/repo"). */
@@ -1765,14 +1768,24 @@ async function handleLoopRequest(
17651768
}
17661769
closeSync(logFd);
17671770

1771+
const tailerJsonlPath = path.join(claudeWorkDir, "claude-output.jsonl");
1772+
const jsonlPreSpawnOffset = existsSync(tailerJsonlPath) ? statSync(tailerJsonlPath).size : 0;
1773+
17681774
// Guard against double-firing: both 'error' and 'exit' can emit.
17691775
let completionHandled = false;
1770-
const onceComplete = (code: number) => {
1771-
if (completionHandled) {
1772-
return;
1773-
}
1776+
let stopTailer: { stop: () => void; flush: () => Promise<void> } = {
1777+
stop: () => {},
1778+
flush: () => Promise.resolve(),
1779+
};
1780+
const onceComplete = async (code: number): Promise<void> => {
1781+
if (completionHandled) return;
17741782
completionHandled = true;
17751783
loopLog(body.loopId, `onceComplete fired, code=${code}`);
1784+
try {
1785+
await stopTailer.flush();
1786+
} catch (err) {
1787+
loopError(body.loopId, "Tailer flush error:", err);
1788+
}
17761789
handleProcessCompletion(
17771790
code,
17781791
body,
@@ -1793,15 +1806,15 @@ async function handleLoopRequest(
17931806
// between pre-flight check and spawn) from crashing Electron.
17941807
child.on("error", (err) => {
17951808
loopError(body.loopId, "Spawn error:", err.message);
1796-
onceComplete(1);
1809+
void onceComplete(1);
17971810
});
17981811

17991812
// Use 'exit' instead of 'close' — with detached processes using
18001813
// inherited file descriptors (not pipes), 'close' may never fire
18011814
// because there are no Node.js streams to track closure of.
18021815
child.on("exit", (code) => {
18031816
loopLog(body.loopId, `Process exit event, code=${code}`);
1804-
onceComplete(code ?? 1);
1817+
void onceComplete(code ?? 1);
18051818
});
18061819

18071820
const pid = child.pid ?? null;
@@ -1815,6 +1828,13 @@ async function handleLoopRequest(
18151828
// Replace sentinel with real entry — storing `child` prevents GC of the
18161829
// ChildProcess handle which would silently drop the exit listener.
18171830
runningLoops.set(body.loopId, { pid, child });
1831+
stopTailer = startOutputTailer(
1832+
tailerJsonlPath,
1833+
apiBaseUrl,
1834+
body.loopId,
1835+
body.closedLoopAuthToken,
1836+
jsonlPreSpawnOffset
1837+
);
18181838
spawnedSuccessfully = true;
18191839
loopLog(body.loopId, `Spawned pid=${pid}, worktree=${worktreeDir}`);
18201840
gatewayLog.debug("loop-harness", `Spawned ${body.command} pid=${pid}, loopId=${body.loopId}, worktree=${worktreeDir}`);

0 commit comments

Comments
 (0)