better-symphony 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- package/workflows/smoke.md +66 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "better-symphony",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Better Symphony - Headless coding agent orchestrator with configurable workflows",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/SabatinoMasala/better-symphony.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude",
|
|
13
|
+
"ai",
|
|
14
|
+
"agent",
|
|
15
|
+
"orchestrator",
|
|
16
|
+
"coding-agent",
|
|
17
|
+
"linear",
|
|
18
|
+
"github"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"better-symphony": "./src/cli.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"dist/web",
|
|
29
|
+
"workflows",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"CLAUDE.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"start": "bun run src/cli.ts",
|
|
36
|
+
"dev": "bun run --watch src/cli.ts",
|
|
37
|
+
"linear": "bun run src/linear-cli.ts",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"build:web": "bunx @tailwindcss/cli -i src/web-ui/globals.css -o dist/web/app.css --minify && bun build src/web-ui/main.tsx --outdir dist/web --minify && cp src/web-ui/index.html dist/web/index.html"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@opentui/core": "^0.1.88",
|
|
43
|
+
"@opentui/react": "^0.1.88",
|
|
44
|
+
"chokidar": "^4.0.3",
|
|
45
|
+
"class-variance-authority": "^0.7.1",
|
|
46
|
+
"clsx": "^2.1.1",
|
|
47
|
+
"liquidjs": "^10.21.0",
|
|
48
|
+
"lucide-react": "^0.577.0",
|
|
49
|
+
"react": "^19.0.0",
|
|
50
|
+
"react-dom": "^19.2.4",
|
|
51
|
+
"tailwind-merge": "^3.5.0",
|
|
52
|
+
"yaml": "^2.7.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@tailwindcss/cli": "^4.2.1",
|
|
56
|
+
"@types/bun": "^1.3.9",
|
|
57
|
+
"@types/react": "^19.0.0",
|
|
58
|
+
"@types/react-dom": "^19.2.3",
|
|
59
|
+
"tailwindcss": "^4.2.1",
|
|
60
|
+
"typescript": "^5.8.3"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Runner
|
|
3
|
+
* Launches Claude CLI in print mode with stream-json output.
|
|
4
|
+
* Parses structured JSON events for real-time monitoring.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, appendFileSync, writeFileSync } from "fs";
|
|
8
|
+
import type {
|
|
9
|
+
ServiceConfig,
|
|
10
|
+
Issue,
|
|
11
|
+
AgentEvent,
|
|
12
|
+
AgentEventType,
|
|
13
|
+
LiveSession,
|
|
14
|
+
} from "../config/types.js";
|
|
15
|
+
import { AgentError } from "../config/types.js";
|
|
16
|
+
import { logger } from "../logging/logger.js";
|
|
17
|
+
import { createSession, updateSessionEvent, updateSessionTokens } from "./session.js";
|
|
18
|
+
|
|
19
|
+
// Built-in system prompt for Linear CLI access
|
|
20
|
+
const LINEAR_SYSTEM_PROMPT_PATH = new URL("../prompts/linear-system-prompt.md", import.meta.url).pathname;
|
|
21
|
+
let _linearSystemPrompt: string | null = null;
|
|
22
|
+
function getLinearSystemPrompt(): string {
|
|
23
|
+
if (_linearSystemPrompt === null) {
|
|
24
|
+
_linearSystemPrompt = readFileSync(LINEAR_SYSTEM_PROMPT_PATH, "utf-8");
|
|
25
|
+
}
|
|
26
|
+
return _linearSystemPrompt;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Built-in system prompt for GitHub CLI access
|
|
30
|
+
const GITHUB_SYSTEM_PROMPT_PATH = new URL("../prompts/github-system-prompt.md", import.meta.url).pathname;
|
|
31
|
+
let _githubSystemPrompt: string | null = null;
|
|
32
|
+
function getGitHubSystemPrompt(): string {
|
|
33
|
+
if (_githubSystemPrompt === null) {
|
|
34
|
+
_githubSystemPrompt = readFileSync(GITHUB_SYSTEM_PROMPT_PATH, "utf-8");
|
|
35
|
+
}
|
|
36
|
+
return _githubSystemPrompt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Strip ANSI escape sequences
|
|
40
|
+
const ANSI_RE =
|
|
41
|
+
/[\u001B\u009B][[\]()#;?]*(?:(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nq-uy=><~]|\u001B\].*?\u0007)/g;
|
|
42
|
+
|
|
43
|
+
function stripAnsi(str: string): string {
|
|
44
|
+
return str.replace(ANSI_RE, "").replace(/\r/g, "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Safely extract text from an array of content blocks (handles nested/object values) */
|
|
48
|
+
function extractBlockText(blocks: any[]): string {
|
|
49
|
+
return blocks
|
|
50
|
+
.map((b: any) => {
|
|
51
|
+
if (typeof b === "string") return b;
|
|
52
|
+
if (typeof b?.text === "string") return b.text;
|
|
53
|
+
if (typeof b?.content === "string") return b.content;
|
|
54
|
+
if (Array.isArray(b?.content)) return extractBlockText(b.content);
|
|
55
|
+
return "";
|
|
56
|
+
})
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join(" ")
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AgentEventCallback = (event: AgentEvent) => void;
|
|
63
|
+
|
|
64
|
+
export interface ClaudeRunnerOptions {
|
|
65
|
+
config: ServiceConfig;
|
|
66
|
+
issue: Issue;
|
|
67
|
+
workspacePath: string;
|
|
68
|
+
prompt: string;
|
|
69
|
+
attempt: number | null;
|
|
70
|
+
onEvent: AgentEventCallback;
|
|
71
|
+
abortSignal: AbortSignal;
|
|
72
|
+
/** If set, write a human-readable transcript to this file path */
|
|
73
|
+
transcriptPath?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ClaudeRunner {
|
|
77
|
+
private options: ClaudeRunnerOptions;
|
|
78
|
+
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
79
|
+
private session: LiveSession | null = null;
|
|
80
|
+
constructor(options: ClaudeRunnerOptions) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
// Initialize transcript file with header
|
|
83
|
+
if (options.transcriptPath) {
|
|
84
|
+
const header = `# Agent Transcript: ${options.issue.identifier}\nStarted: ${new Date().toISOString()}\n`;
|
|
85
|
+
writeFileSync(options.transcriptPath, header, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private writeTranscript(line: string): void {
|
|
90
|
+
if (!this.options.transcriptPath) return;
|
|
91
|
+
try {
|
|
92
|
+
appendFileSync(this.options.transcriptPath, line + "\n", "utf-8");
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getSession(): LiveSession | null {
|
|
97
|
+
return this.session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async run(): Promise<void> {
|
|
101
|
+
const { config, issue, workspacePath, prompt } = this.options;
|
|
102
|
+
|
|
103
|
+
// Create session
|
|
104
|
+
const sessionId = `claude-${Date.now()}`;
|
|
105
|
+
this.session = createSession(sessionId, "turn-1", null);
|
|
106
|
+
|
|
107
|
+
this.emitEvent("session_started", {
|
|
108
|
+
session_id: sessionId,
|
|
109
|
+
issue_identifier: issue.identifier,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await this.launchClaude(prompt);
|
|
114
|
+
} finally {
|
|
115
|
+
this.cleanup();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async launchClaude(prompt: string): Promise<void> {
|
|
120
|
+
const { config, workspacePath, issue } = this.options;
|
|
121
|
+
|
|
122
|
+
// Build Claude args: -p PROMPT --verbose --output-format stream-json --permission-mode X
|
|
123
|
+
const claudeArgs: string[] = [
|
|
124
|
+
"-p",
|
|
125
|
+
prompt,
|
|
126
|
+
"--verbose",
|
|
127
|
+
"--output-format",
|
|
128
|
+
"stream-json",
|
|
129
|
+
"--permission-mode",
|
|
130
|
+
config.agent.permission_mode,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
if (config.agent.max_turns > 0) {
|
|
134
|
+
claudeArgs.push("--max-turns", String(config.agent.max_turns));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Build system prompt: built-in CLI docs + optional user-provided prompt
|
|
138
|
+
const systemPromptParts: string[] = [getLinearSystemPrompt(), getGitHubSystemPrompt()];
|
|
139
|
+
if (config.agent.append_system_prompt) {
|
|
140
|
+
systemPromptParts.push(config.agent.append_system_prompt);
|
|
141
|
+
}
|
|
142
|
+
claudeArgs.push("--append-system-prompt", systemPromptParts.join("\n\n"));
|
|
143
|
+
|
|
144
|
+
let spawnArgs: string[];
|
|
145
|
+
|
|
146
|
+
// Build the base command: either wrapped in yolobox or direct
|
|
147
|
+
// yolobox: yolobox <binary> [...yolobox_arguments] -- <claudeArgs>
|
|
148
|
+
// direct: <binary> <claudeArgs>
|
|
149
|
+
const { binary, yolobox, yolobox_arguments } = config.agent;
|
|
150
|
+
|
|
151
|
+
// When running in yolobox, mount symphony dir and forward env vars into the container
|
|
152
|
+
const symphonyRoot = new URL("../../", import.meta.url).pathname.replace(/\/$/, "");
|
|
153
|
+
const linearCliPath = new URL("../linear-cli.ts", import.meta.url).pathname;
|
|
154
|
+
const yoloboxExtraArgs: string[] = [];
|
|
155
|
+
if (yolobox) {
|
|
156
|
+
// Mount symphony source so $SYMPHONY_LINEAR path works inside the container
|
|
157
|
+
yoloboxExtraArgs.push("--mount", `${symphonyRoot}:${symphonyRoot}`);
|
|
158
|
+
// Forward env vars that yolobox doesn't auto-forward
|
|
159
|
+
const envVars: Record<string, string> = {
|
|
160
|
+
SYMPHONY_LINEAR: linearCliPath,
|
|
161
|
+
SYMPHONY_WORKSPACE: workspacePath,
|
|
162
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
163
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
164
|
+
};
|
|
165
|
+
if (process.env.LINEAR_API_KEY) {
|
|
166
|
+
envVars.LINEAR_API_KEY = process.env.LINEAR_API_KEY;
|
|
167
|
+
}
|
|
168
|
+
for (const [key, value] of Object.entries(envVars)) {
|
|
169
|
+
yoloboxExtraArgs.push("--env", `${key}=${value}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const baseArgs = yolobox
|
|
174
|
+
? ["yolobox", binary, ...yoloboxExtraArgs, ...yolobox_arguments, "--", ...claudeArgs]
|
|
175
|
+
: [binary, ...claudeArgs];
|
|
176
|
+
|
|
177
|
+
spawnArgs = baseArgs;
|
|
178
|
+
|
|
179
|
+
logger.info("Launching Claude", {
|
|
180
|
+
issue_identifier: issue.identifier,
|
|
181
|
+
cwd: workspacePath,
|
|
182
|
+
binary,
|
|
183
|
+
yolobox,
|
|
184
|
+
permission_mode: config.agent.permission_mode,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
this.proc = Bun.spawn(spawnArgs, {
|
|
188
|
+
cwd: workspacePath,
|
|
189
|
+
stdin: "ignore",
|
|
190
|
+
stdout: "pipe",
|
|
191
|
+
stderr: "pipe",
|
|
192
|
+
env: {
|
|
193
|
+
...process.env,
|
|
194
|
+
SYMPHONY_WORKSPACE: workspacePath,
|
|
195
|
+
SYMPHONY_ISSUE_ID: issue.id,
|
|
196
|
+
SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
|
|
197
|
+
// Resolve path to linear-cli so agents can call it from any cwd
|
|
198
|
+
SYMPHONY_LINEAR: new URL("../linear-cli.ts", import.meta.url).pathname,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (this.session) {
|
|
203
|
+
this.session.process_pid = this.proc.pid?.toString() ?? null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Kill on abort
|
|
207
|
+
const killProc = () => {
|
|
208
|
+
try {
|
|
209
|
+
this.proc?.kill("SIGTERM");
|
|
210
|
+
} catch {}
|
|
211
|
+
setTimeout(() => {
|
|
212
|
+
try {
|
|
213
|
+
this.proc?.kill("SIGKILL");
|
|
214
|
+
} catch {}
|
|
215
|
+
}, 5000);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (this.options.abortSignal.aborted) {
|
|
219
|
+
killProc();
|
|
220
|
+
throw new AgentError("turn_cancelled", "Run aborted before start");
|
|
221
|
+
}
|
|
222
|
+
this.options.abortSignal.addEventListener("abort", killProc, { once: true });
|
|
223
|
+
|
|
224
|
+
// Turn timeout
|
|
225
|
+
const timeoutMs = config.agent.turn_timeout_ms;
|
|
226
|
+
let timedOut = false;
|
|
227
|
+
const timeout = setTimeout(() => {
|
|
228
|
+
timedOut = true;
|
|
229
|
+
logger.warn("Claude turn timeout", {
|
|
230
|
+
issue_identifier: issue.identifier,
|
|
231
|
+
timeout_ms: timeoutMs,
|
|
232
|
+
});
|
|
233
|
+
killProc();
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
|
|
236
|
+
// Stall detection
|
|
237
|
+
const stallTimeoutMs = config.agent.stall_timeout_ms;
|
|
238
|
+
let stallTimer: Timer | null = null;
|
|
239
|
+
const resetStallTimer = () => {
|
|
240
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
241
|
+
if (stallTimeoutMs > 0) {
|
|
242
|
+
stallTimer = setTimeout(() => {
|
|
243
|
+
logger.warn("Claude stall detected", {
|
|
244
|
+
issue_identifier: issue.identifier,
|
|
245
|
+
stall_timeout_ms: stallTimeoutMs,
|
|
246
|
+
});
|
|
247
|
+
killProc();
|
|
248
|
+
}, stallTimeoutMs);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
resetStallTimer();
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
await this.readStreamJson(resetStallTimer);
|
|
255
|
+
} finally {
|
|
256
|
+
clearTimeout(timeout);
|
|
257
|
+
if (stallTimer) clearTimeout(stallTimer);
|
|
258
|
+
this.options.abortSignal.removeEventListener("abort", killProc);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Read stderr
|
|
262
|
+
const stderrText = this.proc.stderr && typeof this.proc.stderr !== "number"
|
|
263
|
+
? await new Response(this.proc.stderr).text()
|
|
264
|
+
: "";
|
|
265
|
+
|
|
266
|
+
const exitCode = await this.proc.exited;
|
|
267
|
+
|
|
268
|
+
logger.info("Claude process exited", {
|
|
269
|
+
issue_identifier: issue.identifier,
|
|
270
|
+
exitCode,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (timedOut) {
|
|
274
|
+
this.emitEvent("turn_failed", { exitCode, reason: "timeout" });
|
|
275
|
+
throw new AgentError("turn_timeout", `Turn timed out after ${timeoutMs}ms`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (this.options.abortSignal.aborted) {
|
|
279
|
+
this.emitEvent("turn_cancelled", { exitCode });
|
|
280
|
+
throw new AgentError("turn_cancelled", "Run aborted");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (exitCode !== 0) {
|
|
284
|
+
this.emitEvent("turn_failed", { exitCode, stderr: stderrText.slice(0, 500) });
|
|
285
|
+
throw new AgentError("turn_failed", `Claude exited with code ${exitCode}: ${stderrText.slice(0, 200)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
this.emitEvent("turn_completed", { exitCode });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Read stdout as stream-json lines and dispatch events.
|
|
293
|
+
* Ported from apps/tui/src/claude/runner.ts
|
|
294
|
+
*/
|
|
295
|
+
private async readStreamJson(onActivity: () => void): Promise<void> {
|
|
296
|
+
const stdout = this.proc!.stdout;
|
|
297
|
+
if (!stdout || typeof stdout === "number") {
|
|
298
|
+
throw new AgentError("agent_not_found", "Claude stdout not available as stream");
|
|
299
|
+
}
|
|
300
|
+
const reader = stdout.getReader();
|
|
301
|
+
const decoder = new TextDecoder();
|
|
302
|
+
let buffer = "";
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
while (true) {
|
|
306
|
+
if (this.options.abortSignal.aborted) break;
|
|
307
|
+
|
|
308
|
+
const { done, value } = await reader.read();
|
|
309
|
+
if (done) break;
|
|
310
|
+
|
|
311
|
+
buffer += decoder.decode(value, { stream: true });
|
|
312
|
+
const lines = buffer.split("\n");
|
|
313
|
+
buffer = lines.pop() ?? "";
|
|
314
|
+
|
|
315
|
+
for (const line of lines) {
|
|
316
|
+
const trimmed = line.trim();
|
|
317
|
+
if (!trimmed) continue;
|
|
318
|
+
|
|
319
|
+
onActivity();
|
|
320
|
+
|
|
321
|
+
let message: any;
|
|
322
|
+
try {
|
|
323
|
+
message = JSON.parse(trimmed);
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
this.handleStreamMessage(message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} finally {
|
|
332
|
+
reader.releaseLock();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Process remaining buffer
|
|
336
|
+
if (buffer.trim()) {
|
|
337
|
+
try {
|
|
338
|
+
const message = JSON.parse(buffer.trim());
|
|
339
|
+
this.handleStreamMessage(message);
|
|
340
|
+
} catch {}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private handleStreamMessage(message: any): void {
|
|
345
|
+
const msgType = message.type;
|
|
346
|
+
|
|
347
|
+
if (msgType === "system") {
|
|
348
|
+
this.handleSystemMessage(message);
|
|
349
|
+
} else if (msgType === "assistant") {
|
|
350
|
+
this.handleAssistantMessage(message);
|
|
351
|
+
} else if (msgType === "user") {
|
|
352
|
+
this.handleToolResult(message);
|
|
353
|
+
} else if (msgType === "result") {
|
|
354
|
+
this.handleResultMessage(message);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private handleSystemMessage(message: any): void {
|
|
359
|
+
if (message.subtype === "init") {
|
|
360
|
+
const model = message.model ?? "unknown";
|
|
361
|
+
const sessionId = message.session_id;
|
|
362
|
+
|
|
363
|
+
if (sessionId && this.session) {
|
|
364
|
+
this.session.session_id = sessionId;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
logger.info("Claude session init", {
|
|
368
|
+
issue_identifier: this.options.issue.identifier,
|
|
369
|
+
model,
|
|
370
|
+
session_id: sessionId,
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
this.writeTranscript(`\n## Session Init\nModel: ${model}\n`);
|
|
374
|
+
|
|
375
|
+
if (this.session) {
|
|
376
|
+
updateSessionEvent(this.session, "system:init", `model=${model}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private handleAssistantMessage(message: any): void {
|
|
382
|
+
const content = message.message?.content ?? [];
|
|
383
|
+
const issueId = this.options.issue.identifier;
|
|
384
|
+
|
|
385
|
+
for (const block of content) {
|
|
386
|
+
if (block.type === "text" && block.text?.trim()) {
|
|
387
|
+
const text = stripAnsi(block.text.trim());
|
|
388
|
+
this.emitEvent("assistant_message", { text: text.slice(0, 500) });
|
|
389
|
+
|
|
390
|
+
logger.debug(text.slice(0, 200), { issue_identifier: issueId });
|
|
391
|
+
this.writeTranscript(`\n### Assistant\n${text}\n`);
|
|
392
|
+
|
|
393
|
+
if (this.session) {
|
|
394
|
+
updateSessionEvent(this.session, "assistant", text.slice(0, 200));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (block.type === "tool_use") {
|
|
399
|
+
const name = block.name ?? "unknown";
|
|
400
|
+
const input = block.input ?? {};
|
|
401
|
+
const detail =
|
|
402
|
+
input.command ??
|
|
403
|
+
input.file_path ??
|
|
404
|
+
input.pattern ??
|
|
405
|
+
input.description ??
|
|
406
|
+
input.query ??
|
|
407
|
+
input.url ??
|
|
408
|
+
input.prompt ??
|
|
409
|
+
"";
|
|
410
|
+
const truncated = detail ? String(detail).slice(0, 120) : "";
|
|
411
|
+
|
|
412
|
+
this.emitEvent("tool_use", { tool: name, detail: truncated });
|
|
413
|
+
|
|
414
|
+
logger.info(truncated ? `${name} ${truncated}` : name, {
|
|
415
|
+
issue_identifier: issueId,
|
|
416
|
+
});
|
|
417
|
+
this.writeTranscript(`\n### Tool: ${name}\n${truncated}\n`);
|
|
418
|
+
|
|
419
|
+
if (this.session) {
|
|
420
|
+
updateSessionEvent(this.session, `tool:${name}`, truncated);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
private handleToolResult(message: any): void {
|
|
427
|
+
const result = message.tool_use_result;
|
|
428
|
+
const issueId = this.options.issue.identifier;
|
|
429
|
+
|
|
430
|
+
if (!result) {
|
|
431
|
+
// No tool_use_result — try to extract from message content
|
|
432
|
+
const contentBlocks = message.message?.content;
|
|
433
|
+
if (Array.isArray(contentBlocks) && contentBlocks.length > 0) {
|
|
434
|
+
const isError = contentBlocks[0]?.is_error;
|
|
435
|
+
const text = extractBlockText(contentBlocks);
|
|
436
|
+
if (isError) {
|
|
437
|
+
this.emitEvent("tool_result", { error: true, message: text.slice(0, 200) });
|
|
438
|
+
logger.error(`x ${text.slice(0, 200)}`, { issue_identifier: issueId });
|
|
439
|
+
} else if (text) {
|
|
440
|
+
const summary = `-> ${text.slice(0, 200)}`;
|
|
441
|
+
this.emitEvent("tool_result", { summary });
|
|
442
|
+
logger.debug(summary, { issue_identifier: issueId });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (result.file) {
|
|
449
|
+
const path = result.file.filePath?.split("/").pop() ?? "";
|
|
450
|
+
const lines = result.file.numLines ?? "?";
|
|
451
|
+
const summary = `-> ${path} (${lines} lines)`;
|
|
452
|
+
this.emitEvent("tool_result", { summary });
|
|
453
|
+
logger.debug(summary, { issue_identifier: issueId });
|
|
454
|
+
if (this.session) updateSessionEvent(this.session, "tool_result", summary);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (result.stdout !== undefined) {
|
|
459
|
+
const output = result.stdout || result.stderr || "";
|
|
460
|
+
const summary = !output.trim()
|
|
461
|
+
? "-> (no output)"
|
|
462
|
+
: `-> (${output.trim().split("\n").length} lines)`;
|
|
463
|
+
this.emitEvent("tool_result", { summary });
|
|
464
|
+
logger.debug(summary, { issue_identifier: issueId });
|
|
465
|
+
if (this.session) updateSessionEvent(this.session, "tool_result", summary);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const isError = message.message?.content?.[0]?.is_error;
|
|
470
|
+
if (isError) {
|
|
471
|
+
const errContent = message.message.content[0].content ?? "";
|
|
472
|
+
this.emitEvent("tool_result", { error: true, message: errContent.slice(0, 200) });
|
|
473
|
+
logger.error(`x ${errContent.slice(0, 200)}`, { issue_identifier: issueId });
|
|
474
|
+
if (this.session) updateSessionEvent(this.session, "tool_result", errContent.slice(0, 200));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Fallback: extract text from message content blocks
|
|
479
|
+
const contentBlocks = message.message?.content;
|
|
480
|
+
if (Array.isArray(contentBlocks) && contentBlocks.length > 0) {
|
|
481
|
+
const text = extractBlockText(contentBlocks);
|
|
482
|
+
const summary = text
|
|
483
|
+
? `-> ${text.slice(0, 200)}`
|
|
484
|
+
: "-> (tool result)";
|
|
485
|
+
this.emitEvent("tool_result", { summary });
|
|
486
|
+
logger.debug(summary, { issue_identifier: issueId });
|
|
487
|
+
if (this.session) updateSessionEvent(this.session, "tool_result", summary.slice(0, 200));
|
|
488
|
+
} else {
|
|
489
|
+
this.emitEvent("tool_result", { summary: "-> (tool result)" });
|
|
490
|
+
logger.debug("-> (tool result)", { issue_identifier: issueId });
|
|
491
|
+
if (this.session) updateSessionEvent(this.session, "tool_result", "(tool result)");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private handleResultMessage(message: any): void {
|
|
496
|
+
const costUsd = message.total_cost_usd ?? undefined;
|
|
497
|
+
const durationMs = message.duration_ms ?? undefined;
|
|
498
|
+
const isError = message.is_error ?? false;
|
|
499
|
+
const numTurns = message.num_turns ?? undefined;
|
|
500
|
+
|
|
501
|
+
// Extract usage from result if available
|
|
502
|
+
const usage = message.usage;
|
|
503
|
+
if (usage && this.session) {
|
|
504
|
+
updateSessionTokens(this.session, {
|
|
505
|
+
input_tokens: usage.input_tokens,
|
|
506
|
+
output_tokens: usage.output_tokens,
|
|
507
|
+
total_tokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
this.emitEvent("token_usage_updated", {
|
|
511
|
+
usage,
|
|
512
|
+
cost_usd: costUsd,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (costUsd !== undefined && this.session) {
|
|
517
|
+
this.session.cost_usd = costUsd;
|
|
518
|
+
}
|
|
519
|
+
if (durationMs !== undefined && this.session) {
|
|
520
|
+
this.session.duration_ms = durationMs;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const parts: string[] = [];
|
|
524
|
+
if (costUsd !== undefined) parts.push(`$${costUsd.toFixed(4)}`);
|
|
525
|
+
if (durationMs !== undefined) parts.push(`${(durationMs / 1000).toFixed(1)}s`);
|
|
526
|
+
if (numTurns !== undefined) parts.push(`${numTurns} turns`);
|
|
527
|
+
|
|
528
|
+
const suffix = parts.length ? ` (${parts.join(", ")})` : "";
|
|
529
|
+
|
|
530
|
+
logger.info(`Claude ${isError ? "failed" : "completed"}${suffix}`, {
|
|
531
|
+
issue_identifier: this.options.issue.identifier,
|
|
532
|
+
cost_usd: costUsd,
|
|
533
|
+
duration_ms: durationMs,
|
|
534
|
+
num_turns: numTurns,
|
|
535
|
+
is_error: isError,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
this.writeTranscript(`\n## Result\n${isError ? "Failed" : "Completed"}${suffix}\n`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private emitEvent(eventType: AgentEventType, payload?: unknown): void {
|
|
542
|
+
const event: AgentEvent = {
|
|
543
|
+
event: eventType,
|
|
544
|
+
timestamp: new Date(),
|
|
545
|
+
process_pid: this.session?.process_pid || this.proc?.pid?.toString(),
|
|
546
|
+
payload,
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
if (this.session) {
|
|
550
|
+
event.usage = {
|
|
551
|
+
input_tokens: this.session.input_tokens,
|
|
552
|
+
output_tokens: this.session.output_tokens,
|
|
553
|
+
total_tokens: this.session.total_tokens,
|
|
554
|
+
};
|
|
555
|
+
event.cost_usd = this.session.cost_usd;
|
|
556
|
+
event.duration_ms = this.session.duration_ms;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.options.onEvent(event);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
terminate(): void {
|
|
563
|
+
try {
|
|
564
|
+
this.proc?.kill("SIGTERM");
|
|
565
|
+
} catch {}
|
|
566
|
+
setTimeout(() => {
|
|
567
|
+
try {
|
|
568
|
+
this.proc?.kill("SIGKILL");
|
|
569
|
+
} catch {}
|
|
570
|
+
}, 5000);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private cleanup(): void {
|
|
574
|
+
this.terminate();
|
|
575
|
+
}
|
|
576
|
+
}
|