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.
Files changed (63) hide show
  1. package/CLAUDE.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +292 -0
  4. package/dist/web/app.css +2 -0
  5. package/dist/web/index.html +13 -0
  6. package/dist/web/main.js +235 -0
  7. package/package.json +62 -0
  8. package/src/agent/claude-runner.ts +576 -0
  9. package/src/agent/protocol.ts +2 -0
  10. package/src/agent/runner.ts +2 -0
  11. package/src/agent/session.ts +113 -0
  12. package/src/cli.ts +354 -0
  13. package/src/config/loader.ts +379 -0
  14. package/src/config/types.ts +382 -0
  15. package/src/index.ts +53 -0
  16. package/src/linear-cli.ts +414 -0
  17. package/src/logging/logger.ts +143 -0
  18. package/src/orchestrator/multi-orchestrator.ts +266 -0
  19. package/src/orchestrator/orchestrator.ts +1357 -0
  20. package/src/orchestrator/scheduler.ts +195 -0
  21. package/src/orchestrator/state.ts +201 -0
  22. package/src/prompts/github-system-prompt.md +51 -0
  23. package/src/prompts/linear-system-prompt.md +44 -0
  24. package/src/tracker/client.ts +577 -0
  25. package/src/tracker/github-issues-tracker.ts +280 -0
  26. package/src/tracker/github-pr-tracker.ts +298 -0
  27. package/src/tracker/index.ts +9 -0
  28. package/src/tracker/interface.ts +76 -0
  29. package/src/tracker/linear-tracker.ts +147 -0
  30. package/src/tracker/queries.ts +281 -0
  31. package/src/tracker/types.ts +125 -0
  32. package/src/tui/App.tsx +157 -0
  33. package/src/tui/LogView.tsx +120 -0
  34. package/src/tui/StatusBar.tsx +72 -0
  35. package/src/tui/TabBar.tsx +55 -0
  36. package/src/tui/sink.ts +47 -0
  37. package/src/tui/types.ts +6 -0
  38. package/src/tui/useOrchestrator.ts +244 -0
  39. package/src/web/server.ts +182 -0
  40. package/src/web/sink.ts +67 -0
  41. package/src/web-ui/App.tsx +60 -0
  42. package/src/web-ui/components/agent-table.tsx +57 -0
  43. package/src/web-ui/components/header.tsx +72 -0
  44. package/src/web-ui/components/log-stream.tsx +111 -0
  45. package/src/web-ui/components/retry-table.tsx +58 -0
  46. package/src/web-ui/components/stats-cards.tsx +142 -0
  47. package/src/web-ui/components/ui/badge.tsx +30 -0
  48. package/src/web-ui/components/ui/button.tsx +39 -0
  49. package/src/web-ui/components/ui/card.tsx +32 -0
  50. package/src/web-ui/globals.css +27 -0
  51. package/src/web-ui/index.html +13 -0
  52. package/src/web-ui/lib/use-sse.ts +98 -0
  53. package/src/web-ui/lib/utils.ts +25 -0
  54. package/src/web-ui/main.tsx +4 -0
  55. package/src/workspace/hooks.ts +97 -0
  56. package/src/workspace/manager.ts +211 -0
  57. package/src/workspace/render-hook.ts +13 -0
  58. package/workflows/dev.md +127 -0
  59. package/workflows/github-issues.md +107 -0
  60. package/workflows/pr-review.md +89 -0
  61. package/workflows/prd.md +170 -0
  62. package/workflows/ralph.md +95 -0
  63. 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
+ }
@@ -0,0 +1,2 @@
1
+ // Deprecated: Codex JSON-RPC protocol removed in favor of harness-based architecture.
2
+ // See claude-runner.ts for the Claude stream-json implementation.
@@ -0,0 +1,2 @@
1
+ // Deprecated: Codex app-server runner removed in favor of harness-based architecture.
2
+ // See claude-runner.ts for the Claude harness implementation.