@gethmy/agent 1.0.0 → 1.0.2

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 (52) hide show
  1. package/README.md +7 -6
  2. package/dist/board-helpers.d.ts +31 -0
  3. package/dist/board-helpers.js +150 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +2 -11761
  6. package/dist/completion.d.ts +14 -0
  7. package/dist/completion.js +142 -0
  8. package/dist/config.d.ts +23 -0
  9. package/dist/config.js +91 -0
  10. package/dist/git-pr.d.ts +25 -0
  11. package/dist/git-pr.js +305 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +169 -11730
  14. package/dist/log.d.ts +10 -0
  15. package/dist/log.js +35 -0
  16. package/dist/merge-monitor.d.ts +23 -0
  17. package/dist/merge-monitor.js +167 -0
  18. package/dist/pm.d.ts +14 -0
  19. package/dist/pm.js +63 -0
  20. package/dist/pool.d.ts +40 -0
  21. package/dist/pool.js +157 -0
  22. package/dist/progress-tracker.d.ts +64 -0
  23. package/dist/progress-tracker.js +361 -0
  24. package/dist/prompt.d.ts +5 -0
  25. package/dist/prompt.js +40 -0
  26. package/dist/queue.d.ts +37 -0
  27. package/dist/queue.js +96 -0
  28. package/dist/reconcile.d.ts +21 -0
  29. package/dist/reconcile.js +114 -0
  30. package/dist/review-completion.d.ts +31 -0
  31. package/dist/review-completion.js +253 -0
  32. package/dist/review-knowledge.d.ts +14 -0
  33. package/dist/review-knowledge.js +89 -0
  34. package/dist/review-prompt.d.ts +12 -0
  35. package/dist/review-prompt.js +103 -0
  36. package/dist/review-worker.d.ts +46 -0
  37. package/dist/review-worker.js +437 -0
  38. package/dist/review-worktree.d.ts +12 -0
  39. package/dist/review-worktree.js +83 -0
  40. package/dist/stream-parser.d.ts +31 -0
  41. package/dist/stream-parser.js +95 -0
  42. package/dist/types.d.ts +76 -0
  43. package/dist/types.js +56 -0
  44. package/dist/verification.d.ts +16 -0
  45. package/dist/verification.js +251 -0
  46. package/dist/watcher.d.ts +27 -0
  47. package/dist/watcher.js +74 -0
  48. package/dist/worker.d.ts +43 -0
  49. package/dist/worker.js +327 -0
  50. package/dist/worktree.d.ts +13 -0
  51. package/dist/worktree.js +115 -0
  52. package/package.json +8 -7
@@ -0,0 +1,437 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
3
+ import { log } from "./log.js";
4
+ import { ProgressTracker } from "./progress-tracker.js";
5
+ import { parseReviewOutput, runReviewCompletion } from "./review-completion.js";
6
+ import { buildReviewSystemPrompt, buildReviewUserPrompt, } from "./review-prompt.js";
7
+ import { checkoutExistingBranch, extractBranchFromDescription, } from "./review-worktree.js";
8
+ import { StreamParser } from "./stream-parser.js";
9
+ import { AGENT_NAME, agentIdentifier, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR, } from "./types.js";
10
+ import { waitForDevServer } from "./verification.js";
11
+ import { cleanupWorktree } from "./worktree.js";
12
+ const TAG = "review-worker";
13
+ const CANCEL_SIGINT_TIMEOUT = 30_000;
14
+ const CANCEL_SIGTERM_TIMEOUT = 10_000;
15
+ export class ReviewWorker {
16
+ config;
17
+ client;
18
+ onDone;
19
+ id;
20
+ state = "idle";
21
+ cardId = null;
22
+ branchName = null;
23
+ worktreePath = null;
24
+ startedAt = null;
25
+ process = null;
26
+ devServerProcess = null;
27
+ timeoutTimer = null;
28
+ progressTracker = null;
29
+ aborted = false;
30
+ constructor(id, config, client, _userEmail, onDone) {
31
+ this.config = config;
32
+ this.client = client;
33
+ this.onDone = onDone;
34
+ this.id = id;
35
+ }
36
+ get tag() {
37
+ return `${TAG}:${this.id}`;
38
+ }
39
+ get isIdle() {
40
+ return this.state === "idle";
41
+ }
42
+ get reviewPort() {
43
+ return this.config.review.devServerPort + (this.id - this.config.poolSize);
44
+ }
45
+ get isActive() {
46
+ return (this.state === "preparing" ||
47
+ this.state === "running" ||
48
+ this.state === "verifying" ||
49
+ this.state === "completing");
50
+ }
51
+ /**
52
+ * Start reviewing a card. Runs the full lifecycle:
53
+ * PREPARING → REVIEWING → COMPLETING → IDLE
54
+ */
55
+ async run(card, column, labels, subtasks) {
56
+ this.aborted = false;
57
+ this.cardId = card.id;
58
+ this.startedAt = Date.now();
59
+ try {
60
+ // --- PREPARING ---
61
+ this.state = "preparing";
62
+ log.info(this.tag, `Preparing review for #${card.short_id} "${card.title}"`);
63
+ // Extract branch name from card description
64
+ this.branchName = extractBranchFromDescription(card.description);
65
+ const localMode = !this.branchName;
66
+ if (localMode) {
67
+ // LOCAL FALLBACK: no branch → review local working tree
68
+ log.info(this.tag, `No branch found for #${card.short_id}, attempting local review`);
69
+ // Use repo root as working directory
70
+ this.worktreePath = execFileSync("git", ["rev-parse", "--show-toplevel"], {
71
+ cwd: this.config.worktree.basePath,
72
+ encoding: "utf-8",
73
+ timeout: 5_000,
74
+ }).trim();
75
+ // Check if dev server is already running on the configured port
76
+ const serverRunning = await this.checkDevServer(this.reviewPort);
77
+ if (!serverRunning) {
78
+ log.info(this.tag, `No dev server on port ${this.reviewPort} — adding "${NEED_REVIEW_LABEL}" label and moving to "${this.config.review.failColumn}"`);
79
+ await Promise.all([
80
+ addLabelByName(this.client, card, NEED_REVIEW_LABEL, NEED_REVIEW_LABEL_COLOR),
81
+ moveCardToColumn(this.client, card, this.config.review.failColumn),
82
+ ]);
83
+ return;
84
+ }
85
+ }
86
+ if (this.branchName) {
87
+ log.info(this.tag, `Review branch: ${this.branchName}`);
88
+ }
89
+ // Start agent session and make it visible on the board
90
+ await this.client.startAgentSession(card.id, {
91
+ agentIdentifier: agentIdentifier(this.id),
92
+ agentName: `${AGENT_NAME} (Review)`,
93
+ status: "working",
94
+ currentTask: localMode
95
+ ? "Reviewing local changes"
96
+ : "Setting up review worktree",
97
+ progressPercent: 5,
98
+ });
99
+ // Fire label addition concurrently with sync worktree checkout
100
+ const labelPromise = addLabelByName(this.client, card, "agent", "#8b5cf6");
101
+ if (!localMode) {
102
+ // Checkout existing branch into worktree (sync)
103
+ this.worktreePath = checkoutExistingBranch(this.config.worktree.basePath, this.branchName);
104
+ }
105
+ await labelPromise;
106
+ if (this.aborted)
107
+ return;
108
+ // --- REVIEWING ---
109
+ this.state = "running";
110
+ if (!this.worktreePath) {
111
+ throw new Error("worktreePath not set before review phase");
112
+ }
113
+ // Start dev server (only in branch mode — local mode uses existing server)
114
+ const port = this.reviewPort;
115
+ const cwd = this.worktreePath;
116
+ if (!localMode) {
117
+ log.info(this.tag, `Starting dev server on port ${port}...`);
118
+ this.devServerProcess = spawn("bun", ["run", "dev", "--", "--port", String(port)], { cwd, stdio: ["ignore", "pipe", "pipe"] });
119
+ await waitForDevServer(this.devServerProcess, 30_000);
120
+ log.info(this.tag, `Dev server ready on port ${port}`);
121
+ }
122
+ if (this.aborted)
123
+ return;
124
+ // Get diff
125
+ let diff = "";
126
+ try {
127
+ if (localMode) {
128
+ // Local mode: show uncommitted changes or last commit's diff
129
+ const uncommitted = execFileSync("git", ["diff"], {
130
+ cwd,
131
+ encoding: "utf-8",
132
+ timeout: 30_000,
133
+ });
134
+ const staged = execFileSync("git", ["diff", "--cached"], {
135
+ cwd,
136
+ encoding: "utf-8",
137
+ timeout: 30_000,
138
+ });
139
+ diff = [staged, uncommitted].filter(Boolean).join("\n");
140
+ if (!diff) {
141
+ // No uncommitted changes — get last commit's diff
142
+ diff = execFileSync("git", ["diff", "HEAD~1..HEAD"], {
143
+ cwd,
144
+ encoding: "utf-8",
145
+ timeout: 30_000,
146
+ });
147
+ }
148
+ }
149
+ else {
150
+ diff = execFileSync("git", ["diff", `origin/${this.config.worktree.baseBranch}..HEAD`], { cwd, encoding: "utf-8", timeout: 30_000 });
151
+ }
152
+ }
153
+ catch {
154
+ diff = "(unable to retrieve diff)";
155
+ }
156
+ const previewUrl = `http://localhost:${port}`;
157
+ const enriched = {
158
+ card,
159
+ column,
160
+ labels,
161
+ subtasks,
162
+ mode: "review",
163
+ };
164
+ const systemPrompt = buildReviewSystemPrompt();
165
+ const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
166
+ await this.client.updateAgentProgress(card.id, {
167
+ agentIdentifier: agentIdentifier(this.id),
168
+ agentName: `${AGENT_NAME} (Review)`,
169
+ status: "working",
170
+ currentTask: "Running Claude review",
171
+ progressPercent: 20,
172
+ });
173
+ // Start timeout watchdog
174
+ this.timeoutTimer = setTimeout(() => {
175
+ log.warn(this.tag, `Review timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
176
+ this.cancel();
177
+ }, this.config.review.maxTimeout);
178
+ // Set up progress tracking
179
+ this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
180
+ // Spawn Claude CLI for review with streaming
181
+ const stdout = await this.spawnClaude(userPrompt, systemPrompt, this.progressTracker);
182
+ this.progressTracker.stop();
183
+ this.progressTracker = null;
184
+ if (this.aborted)
185
+ return;
186
+ // --- COMPLETING ---
187
+ this.state = "completing";
188
+ log.info(this.tag, `Claude review finished for #${card.short_id}`);
189
+ // Kill dev server (only if we started one)
190
+ if (!localMode) {
191
+ this.killDevServer();
192
+ }
193
+ // Parse findings
194
+ const result = parseReviewOutput(stdout);
195
+ log.info(this.tag, `Review verdict: ${result.verdict} (${result.findings.length} finding(s))`);
196
+ await this.client.updateAgentProgress(card.id, {
197
+ agentIdentifier: agentIdentifier(this.id),
198
+ agentName: `${AGENT_NAME} (Review)`,
199
+ status: "working",
200
+ currentTask: `Processing ${result.verdict} verdict`,
201
+ progressPercent: 80,
202
+ });
203
+ // Run review completion pipeline
204
+ await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName);
205
+ }
206
+ catch (err) {
207
+ this.state = "error";
208
+ const msg = err instanceof Error ? err.message : String(err);
209
+ log.error(this.tag, `Error reviewing #${card.short_id}: ${msg}`);
210
+ // End session as paused on error
211
+ try {
212
+ await this.client.endAgentSession(card.id, { status: "paused" });
213
+ }
214
+ catch {
215
+ // best-effort
216
+ }
217
+ // Move card out of Review to break the re-enqueue loop
218
+ try {
219
+ await moveCardToColumn(this.client, card, this.config.review.failColumn);
220
+ log.info(this.tag, `Moved #${card.short_id} to "${this.config.review.failColumn}" after error`);
221
+ }
222
+ catch {
223
+ log.warn(this.tag, "Failed to move card to fail column after error");
224
+ }
225
+ }
226
+ finally {
227
+ this.cleanup();
228
+ this.state = "idle";
229
+ this.onDone(this);
230
+ }
231
+ }
232
+ /**
233
+ * Pause the current review by suspending the Claude process (SIGSTOP).
234
+ */
235
+ async pause() {
236
+ if (!this.isActive || !this.process || this.process.killed)
237
+ return;
238
+ log.info(this.tag, `Pausing review on ${this.cardId}`);
239
+ this.process.kill("SIGSTOP");
240
+ if (this.timeoutTimer) {
241
+ clearTimeout(this.timeoutTimer);
242
+ this.timeoutTimer = null;
243
+ }
244
+ // Update agent session so the UI reflects the paused state
245
+ if (this.cardId) {
246
+ try {
247
+ await this.client.updateAgentProgress(this.cardId, {
248
+ agentIdentifier: agentIdentifier(this.id),
249
+ agentName: AGENT_NAME,
250
+ status: "paused",
251
+ });
252
+ }
253
+ catch {
254
+ log.warn(this.tag, "Failed to update agent session to paused");
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * Resume the Claude process after a pause (SIGCONT).
260
+ */
261
+ async resume() {
262
+ if (!this.isActive || !this.process || this.process.killed)
263
+ return;
264
+ log.info(this.tag, `Resuming review on ${this.cardId}`);
265
+ this.process.kill("SIGCONT");
266
+ this.timeoutTimer = setTimeout(() => {
267
+ log.warn(this.tag, `Timeout reached (${this.config.review.maxTimeout}ms), cancelling`);
268
+ this.cancel();
269
+ }, this.config.review.maxTimeout);
270
+ // Update agent session so the UI reflects the resumed state
271
+ if (this.cardId) {
272
+ try {
273
+ await this.client.updateAgentProgress(this.cardId, {
274
+ agentIdentifier: agentIdentifier(this.id),
275
+ agentName: AGENT_NAME,
276
+ status: "working",
277
+ });
278
+ }
279
+ catch {
280
+ log.warn(this.tag, "Failed to update agent session to working");
281
+ }
282
+ }
283
+ }
284
+ /**
285
+ * Cancel the current review. Sends escalating signals to both processes.
286
+ */
287
+ async cancel() {
288
+ if (!this.isActive)
289
+ return;
290
+ this.aborted = true;
291
+ this.state = "cancelling";
292
+ log.info(this.tag, `Cancelling review on ${this.cardId}`);
293
+ // Stop progress tracking
294
+ if (this.progressTracker) {
295
+ this.progressTracker.stop();
296
+ this.progressTracker = null;
297
+ }
298
+ // Kill dev server first
299
+ this.killDevServer();
300
+ // Then kill Claude process with escalating signals
301
+ if (this.process && !this.process.killed) {
302
+ // Resume first in case the process is suspended
303
+ this.process.kill("SIGCONT");
304
+ this.process.kill("SIGINT");
305
+ log.debug(this.tag, "Sent SIGINT to Claude");
306
+ const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
307
+ if (!sigintDead) {
308
+ this.process.kill("SIGTERM");
309
+ log.debug(this.tag, "Sent SIGTERM to Claude");
310
+ const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
311
+ if (!sigtermDead) {
312
+ this.process.kill("SIGKILL");
313
+ log.warn(this.tag, "Sent SIGKILL to Claude");
314
+ }
315
+ }
316
+ }
317
+ // End agent session as paused
318
+ if (this.cardId) {
319
+ try {
320
+ await this.client.endAgentSession(this.cardId, { status: "paused" });
321
+ }
322
+ catch {
323
+ // best-effort
324
+ }
325
+ }
326
+ }
327
+ async checkDevServer(port) {
328
+ try {
329
+ const resp = await fetch(`http://localhost:${port}`, {
330
+ signal: AbortSignal.timeout(3_000),
331
+ });
332
+ return resp.ok;
333
+ }
334
+ catch {
335
+ return false;
336
+ }
337
+ }
338
+ spawnClaude(prompt, systemPrompt, tracker) {
339
+ return new Promise((resolve, reject) => {
340
+ const args = [
341
+ "--output-format",
342
+ "stream-json",
343
+ "--verbose",
344
+ "--model",
345
+ this.config.claude.model,
346
+ "--max-turns",
347
+ String(this.config.claude.maxTurns),
348
+ "--allowedTools",
349
+ "Bash(readonly),Read,Glob,Grep,Agent,mcp__harmony__*",
350
+ ...(systemPrompt ? ["--append-system-prompt", systemPrompt] : []),
351
+ ...this.config.claude.additionalArgs,
352
+ "--",
353
+ prompt,
354
+ ];
355
+ log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
356
+ this.process = spawn("claude", args, {
357
+ cwd: this.worktreePath,
358
+ stdio: ["ignore", "pipe", "pipe"],
359
+ });
360
+ // Stream parser for progress tracking + text reconstruction for verdict
361
+ const parser = new StreamParser();
362
+ tracker.attach(parser);
363
+ const textChunks = [];
364
+ parser.on("text", (content) => {
365
+ textChunks.push(content);
366
+ });
367
+ // Attach parser to stdout (single consumer)
368
+ if (this.process.stdout) {
369
+ parser.attach(this.process.stdout);
370
+ }
371
+ let stderr = "";
372
+ this.process.stderr?.on("data", (data) => {
373
+ stderr += data.toString();
374
+ });
375
+ this.process.on("error", (err) => {
376
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
377
+ });
378
+ this.process.on("close", (code) => {
379
+ this.process = null;
380
+ // Reconstruct text from stream parser text events
381
+ const stdout = textChunks.join("");
382
+ if (this.aborted) {
383
+ resolve(stdout);
384
+ }
385
+ else if (code === 0) {
386
+ resolve(stdout);
387
+ }
388
+ else {
389
+ reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
390
+ }
391
+ });
392
+ });
393
+ }
394
+ waitForExit(timeout) {
395
+ return new Promise((resolve) => {
396
+ if (!this.process || this.process.killed) {
397
+ resolve(true);
398
+ return;
399
+ }
400
+ const timer = setTimeout(() => {
401
+ resolve(false);
402
+ }, timeout);
403
+ this.process.once("close", () => {
404
+ clearTimeout(timer);
405
+ resolve(true);
406
+ });
407
+ });
408
+ }
409
+ killDevServer() {
410
+ if (this.devServerProcess && !this.devServerProcess.killed) {
411
+ this.devServerProcess.kill("SIGTERM");
412
+ this.devServerProcess = null;
413
+ log.debug(this.tag, "Killed dev server");
414
+ }
415
+ }
416
+ cleanup() {
417
+ if (this.timeoutTimer) {
418
+ clearTimeout(this.timeoutTimer);
419
+ this.timeoutTimer = null;
420
+ }
421
+ this.killDevServer();
422
+ // Clean up worktree on error (only if we created one — skip in local mode)
423
+ if (this.worktreePath && this.state === "error" && this.branchName) {
424
+ try {
425
+ cleanupWorktree(this.worktreePath, this.branchName);
426
+ }
427
+ catch {
428
+ log.warn(this.tag, "Failed to cleanup review worktree");
429
+ }
430
+ }
431
+ this.process = null;
432
+ this.cardId = null;
433
+ this.branchName = null;
434
+ this.worktreePath = null;
435
+ this.startedAt = null;
436
+ }
437
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Checkout an existing remote branch into a worktree for review.
3
+ * Unlike createWorktree() which creates a new branch, this checks out
4
+ * an existing branch that was pushed by the implementation worker.
5
+ */
6
+ export declare function checkoutExistingBranch(basePath: string, branchName: string): string;
7
+ /**
8
+ * Extract branch name from card description's completion summary.
9
+ * Looks for: Branch: `agent/...`
10
+ * Validates that the extracted name contains only safe characters.
11
+ */
12
+ export declare function extractBranchFromDescription(description: string | null | undefined): string | null;
@@ -0,0 +1,83 @@
1
+ import { execFileSync, execSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { log } from "./log.js";
5
+ import { installCommand } from "./pm.js";
6
+ import { cleanupWorktree } from "./worktree.js";
7
+ const TAG = "review-worktree";
8
+ /**
9
+ * Checkout an existing remote branch into a worktree for review.
10
+ * Unlike createWorktree() which creates a new branch, this checks out
11
+ * an existing branch that was pushed by the implementation worker.
12
+ */
13
+ export function checkoutExistingBranch(basePath, branchName) {
14
+ const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
15
+ encoding: "utf-8",
16
+ }).trim();
17
+ const worktreeDir = resolve(repoRoot, basePath, `review-${branchName}`);
18
+ if (existsSync(worktreeDir)) {
19
+ log.warn(TAG, `Review worktree already exists at ${worktreeDir}, cleaning up`);
20
+ cleanupWorktree(worktreeDir);
21
+ }
22
+ // Fetch latest to ensure we have the remote branch
23
+ try {
24
+ execFileSync("git", ["fetch", "origin", branchName], {
25
+ cwd: repoRoot,
26
+ stdio: "pipe",
27
+ });
28
+ }
29
+ catch {
30
+ throw new Error(`Failed to fetch remote branch: ${branchName}`);
31
+ }
32
+ // Delete stale local branch if it exists
33
+ try {
34
+ execFileSync("git", ["branch", "-D", branchName], {
35
+ cwd: repoRoot,
36
+ stdio: "pipe",
37
+ });
38
+ }
39
+ catch {
40
+ // Branch doesn't exist locally — that's fine
41
+ }
42
+ // Create worktree from the remote branch
43
+ log.info(TAG, `Creating review worktree: ${worktreeDir} (branch: ${branchName})`);
44
+ execFileSync("git", [
45
+ "worktree",
46
+ "add",
47
+ "--track",
48
+ "-b",
49
+ branchName,
50
+ worktreeDir,
51
+ `origin/${branchName}`,
52
+ ], { cwd: repoRoot, stdio: "pipe" });
53
+ // Install dependencies
54
+ log.info(TAG, "Installing dependencies in review worktree...");
55
+ try {
56
+ execSync(installCommand(), {
57
+ cwd: worktreeDir,
58
+ stdio: "pipe",
59
+ timeout: 60_000,
60
+ });
61
+ }
62
+ catch {
63
+ log.warn(TAG, "Install failed (may be fine if deps are hoisted)");
64
+ }
65
+ return worktreeDir;
66
+ }
67
+ /**
68
+ * Extract branch name from card description's completion summary.
69
+ * Looks for: Branch: `agent/...`
70
+ * Validates that the extracted name contains only safe characters.
71
+ */
72
+ export function extractBranchFromDescription(description) {
73
+ if (!description)
74
+ return null;
75
+ const match = description.match(/Branch:\s*`([^`]+)`/);
76
+ const branch = match?.[1] ?? null;
77
+ // Validate branch name contains only safe git ref characters
78
+ if (branch && !/^[a-zA-Z0-9/_.-]+$/.test(branch)) {
79
+ log.warn(TAG, `Extracted branch name contains unsafe characters: ${branch}`);
80
+ return null;
81
+ }
82
+ return branch;
83
+ }
@@ -0,0 +1,31 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { Readable } from "node:stream";
3
+ export interface CostUpdate {
4
+ totalCostUsd: number;
5
+ totalInputTokens: number;
6
+ totalOutputTokens: number;
7
+ durationMs: number;
8
+ durationApiMs: number;
9
+ numTurns: number;
10
+ }
11
+ export interface StreamParserEvents {
12
+ tool_start: [name: string, input: unknown];
13
+ tool_end: [name: string, toolUseId: string, content: string | undefined];
14
+ text: [content: string];
15
+ result: [stopReason: string];
16
+ cost_update: [cost: CostUpdate];
17
+ parse_error: [msg: string];
18
+ }
19
+ export declare class StreamParser extends EventEmitter<StreamParserEvents> {
20
+ private buffer;
21
+ private attached;
22
+ /**
23
+ * Attach a readable stream (Claude CLI stdout) to the parser.
24
+ * Parses NDJSON lines and emits typed events.
25
+ * Each instance must only be attached once.
26
+ */
27
+ attach(stream: Readable): void;
28
+ private flush;
29
+ private parseLine;
30
+ private handleMessage;
31
+ }
@@ -0,0 +1,95 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { log } from "./log.js";
3
+ const TAG = "stream-parser";
4
+ export class StreamParser extends EventEmitter {
5
+ buffer = "";
6
+ attached = false;
7
+ /**
8
+ * Attach a readable stream (Claude CLI stdout) to the parser.
9
+ * Parses NDJSON lines and emits typed events.
10
+ * Each instance must only be attached once.
11
+ */
12
+ attach(stream) {
13
+ if (this.attached) {
14
+ throw new Error("StreamParser already attached to a stream");
15
+ }
16
+ this.attached = true;
17
+ stream.on("data", (chunk) => {
18
+ this.buffer += chunk.toString();
19
+ this.flush();
20
+ });
21
+ stream.on("end", () => {
22
+ // Process any remaining buffer
23
+ if (this.buffer.trim()) {
24
+ this.flush();
25
+ }
26
+ });
27
+ }
28
+ flush() {
29
+ const lines = this.buffer.split("\n");
30
+ // Keep incomplete last line in buffer
31
+ this.buffer = lines.pop() ?? "";
32
+ for (const line of lines) {
33
+ const trimmed = line.trim();
34
+ if (!trimmed)
35
+ continue;
36
+ this.parseLine(trimmed);
37
+ }
38
+ }
39
+ parseLine(line) {
40
+ let msg;
41
+ try {
42
+ msg = JSON.parse(line);
43
+ }
44
+ catch {
45
+ // Not valid JSON — skip silently (could be stray output)
46
+ log.debug(TAG, `Non-JSON line: ${line.slice(0, 100)}`);
47
+ return;
48
+ }
49
+ try {
50
+ this.handleMessage(msg);
51
+ }
52
+ catch (err) {
53
+ const errMsg = err instanceof Error ? err.message : String(err);
54
+ log.warn(TAG, `Error handling stream event: ${errMsg}`);
55
+ this.emit("parse_error", errMsg);
56
+ }
57
+ }
58
+ handleMessage(msg) {
59
+ switch (msg.type) {
60
+ case "assistant": {
61
+ if (msg.subtype === "tool_use" && msg.tool_name) {
62
+ this.emit("tool_start", msg.tool_name, msg.input);
63
+ }
64
+ else if (msg.subtype === "text" && msg.content) {
65
+ this.emit("text", msg.content);
66
+ }
67
+ break;
68
+ }
69
+ case "tool_result": {
70
+ if (msg.tool_use_id && msg.tool_name) {
71
+ this.emit("tool_end", msg.tool_name, msg.tool_use_id, msg.content);
72
+ }
73
+ break;
74
+ }
75
+ case "result": {
76
+ this.emit("result", msg.stop_reason ?? "unknown");
77
+ break;
78
+ }
79
+ case "cost_update": {
80
+ if (msg.total_cost_usd != null) {
81
+ this.emit("cost_update", {
82
+ totalCostUsd: msg.total_cost_usd,
83
+ totalInputTokens: msg.total_input_tokens ?? 0,
84
+ totalOutputTokens: msg.total_output_tokens ?? 0,
85
+ durationMs: msg.duration_ms ?? 0,
86
+ durationApiMs: msg.duration_api_ms ?? 0,
87
+ numTurns: msg.num_turns ?? 0,
88
+ });
89
+ }
90
+ break;
91
+ }
92
+ // Ignore system, ping, etc.
93
+ }
94
+ }
95
+ }