@gethmy/agent 1.1.0 → 1.1.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { MergeMonitor } from "../merge-monitor.js";
3
+ vi.mock("../git-pr.js", () => ({
4
+ detectGitProvider: () => "github",
5
+ extractPrUrl: () => "https://example/pr/1",
6
+ checkPrMergeStatus: vi.fn().mockResolvedValue("merged"),
7
+ }));
8
+ vi.mock("node:child_process", () => ({
9
+ execFile: (_a, _b, _c, cb) => cb(null),
10
+ }));
11
+ function makeCard(overrides = {}) {
12
+ return {
13
+ id: "card-1",
14
+ short_id: 42,
15
+ project_id: "proj",
16
+ title: "Card",
17
+ description: "branch: agent/foo\n\nhttps://example/pr/1",
18
+ column_id: "col-review",
19
+ labelIds: ["lbl-approved"],
20
+ done: false,
21
+ archived_at: null,
22
+ ...overrides,
23
+ };
24
+ }
25
+ function makeConfig() {
26
+ return {
27
+ review: {
28
+ enabled: true,
29
+ pickupColumns: ["Review"],
30
+ moveToColumn: "Done",
31
+ failColumn: "To Do",
32
+ devServerPort: 4300,
33
+ maxTimeout: 600_000,
34
+ postFindings: true,
35
+ maxReviewCycles: 3,
36
+ createPR: true,
37
+ approvedLabel: "Ready to Merge",
38
+ approvedLabelColor: "#22c55e",
39
+ mergeMonitor: true,
40
+ mergedLabel: "Merged",
41
+ mergedLabelColor: "#6366f1",
42
+ },
43
+ };
44
+ }
45
+ function makeBoard(markCardsDone) {
46
+ return {
47
+ columns: [
48
+ { id: "col-review", name: "Review", mark_cards_done: false },
49
+ { id: "col-done", name: "Done", mark_cards_done: markCardsDone },
50
+ ],
51
+ labels: [
52
+ { id: "lbl-approved", name: "Ready to Merge" },
53
+ { id: "lbl-merged", name: "Merged" },
54
+ ],
55
+ cards: [makeCard()],
56
+ };
57
+ }
58
+ function makeClient(markCardsDone) {
59
+ return {
60
+ getBoard: vi.fn().mockResolvedValue(makeBoard(markCardsDone)),
61
+ moveCard: vi.fn().mockResolvedValue({}),
62
+ addLabelToCard: vi.fn().mockResolvedValue({}),
63
+ removeLabelFromCard: vi.fn().mockResolvedValue({}),
64
+ createLabel: vi.fn().mockResolvedValue({ label: { id: "lbl-new" } }),
65
+ updateCard: vi.fn().mockResolvedValue({}),
66
+ };
67
+ }
68
+ function approvedLabel() {
69
+ return { id: "lbl-approved", name: "Ready to Merge" };
70
+ }
71
+ function asPrivate(monitor) {
72
+ return monitor;
73
+ }
74
+ describe("MergeMonitor.completeMergedCard", () => {
75
+ beforeEach(() => {
76
+ vi.clearAllMocks();
77
+ });
78
+ it("does NOT force done:true when Done column has mark_cards_done=false", async () => {
79
+ const client = makeClient(false);
80
+ const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
81
+ await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
82
+ const updateCalls = client.updateCard.mock.calls;
83
+ for (const [, payload] of updateCalls) {
84
+ expect(payload).not.toHaveProperty("done");
85
+ }
86
+ });
87
+ it("moves to the configured Done column and updates description with merge timestamp", async () => {
88
+ const client = makeClient(true);
89
+ const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
90
+ await monitor.completeMergedCard(makeCard(), [approvedLabel()]);
91
+ expect(client.moveCard).toHaveBeenCalledWith("card-1", "col-done");
92
+ const descUpdate = client.updateCard.mock.calls.find(([, p]) => typeof p.description === "string" &&
93
+ p.description.includes("Merged at"));
94
+ expect(descUpdate).toBeDefined();
95
+ });
96
+ it("does not re-append merge timestamp when description already has one", async () => {
97
+ const client = makeClient(true);
98
+ const monitor = asPrivate(new MergeMonitor(client, "proj", makeConfig()));
99
+ const card = makeCard({
100
+ description: "branch: agent/foo\n\nMerged at 2026-01-01T00:00:00Z",
101
+ });
102
+ await monitor.completeMergedCard(card, [approvedLabel()]);
103
+ for (const [, payload] of client.updateCard.mock.calls) {
104
+ expect(payload).not.toHaveProperty("description");
105
+ }
106
+ });
107
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { StreamParser } from "../stream-parser.js";
3
+ import { verifyStreamParserFormat } from "../stream-parser-selftest.js";
4
+ describe("verifyStreamParserFormat", () => {
5
+ it("passes with the current stream-parser implementation", () => {
6
+ expect(() => verifyStreamParserFormat()).not.toThrow();
7
+ });
8
+ it("throws a descriptive error if the parser is silently broken", () => {
9
+ // Simulate CLI-format drift: stub StreamParser so `feed` is a no-op.
10
+ // The canary should notice that no events fired and fail loud.
11
+ const spy = vi
12
+ .spyOn(StreamParser.prototype, "feed")
13
+ .mockImplementation(() => {
14
+ // drop every line
15
+ });
16
+ try {
17
+ expect(() => verifyStreamParserFormat()).toThrow(/canary failed/i);
18
+ }
19
+ finally {
20
+ spy.mockRestore();
21
+ }
22
+ });
23
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,196 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { StreamParser } from "../stream-parser.js";
3
+ function collect(parser) {
4
+ const texts = [];
5
+ const toolStarts = [];
6
+ const toolEnds = [];
7
+ const results = [];
8
+ const costs = [];
9
+ const errors = [];
10
+ parser.on("text", (c) => texts.push(c));
11
+ parser.on("tool_start", (name, input) => toolStarts.push({ name, input }));
12
+ parser.on("tool_end", (name, id, content) => toolEnds.push({ name, id, content }));
13
+ parser.on("result", (stop) => results.push(stop));
14
+ parser.on("cost_update", (c) => costs.push(c));
15
+ parser.on("parse_error", (m) => errors.push(m));
16
+ return { texts, toolStarts, toolEnds, results, costs, errors };
17
+ }
18
+ describe("StreamParser", () => {
19
+ it("emits text from assistant content blocks (Claude CLI stream-json format)", () => {
20
+ const parser = new StreamParser();
21
+ const events = collect(parser);
22
+ parser.feed(`${JSON.stringify({
23
+ type: "assistant",
24
+ message: {
25
+ content: [
26
+ {
27
+ type: "text",
28
+ text: '```json\n{"verdict":"approved","summary":"ok"}\n```',
29
+ },
30
+ ],
31
+ },
32
+ })}\n`);
33
+ expect(events.texts).toHaveLength(1);
34
+ expect(events.texts[0]).toContain('"verdict":"approved"');
35
+ });
36
+ it("emits tool_start for tool_use blocks and tool_end for tool_result with matching name", () => {
37
+ const parser = new StreamParser();
38
+ const events = collect(parser);
39
+ parser.feed(`${JSON.stringify({
40
+ type: "assistant",
41
+ message: {
42
+ content: [
43
+ {
44
+ type: "tool_use",
45
+ id: "toolu_1",
46
+ name: "Bash",
47
+ input: { command: "ls" },
48
+ },
49
+ ],
50
+ },
51
+ })}\n`);
52
+ parser.feed(`${JSON.stringify({
53
+ type: "user",
54
+ message: {
55
+ content: [
56
+ {
57
+ type: "tool_result",
58
+ tool_use_id: "toolu_1",
59
+ content: "file1\nfile2",
60
+ },
61
+ ],
62
+ },
63
+ })}\n`);
64
+ expect(events.toolStarts).toEqual([
65
+ { name: "Bash", input: { command: "ls" } },
66
+ ]);
67
+ expect(events.toolEnds).toEqual([
68
+ { name: "Bash", id: "toolu_1", content: "file1\nfile2" },
69
+ ]);
70
+ });
71
+ it("ignores thinking blocks", () => {
72
+ const parser = new StreamParser();
73
+ const events = collect(parser);
74
+ parser.feed(`${JSON.stringify({
75
+ type: "assistant",
76
+ message: {
77
+ content: [
78
+ { type: "thinking", thinking: "pondering..." },
79
+ { type: "text", text: "actual output" },
80
+ ],
81
+ },
82
+ })}\n`);
83
+ expect(events.texts).toEqual(["actual output"]);
84
+ });
85
+ it("falls back to result.result when no assistant text was streamed", () => {
86
+ const parser = new StreamParser();
87
+ const events = collect(parser);
88
+ parser.feed(`${JSON.stringify({
89
+ type: "result",
90
+ subtype: "success",
91
+ result: "final verdict text",
92
+ total_cost_usd: 0.5,
93
+ usage: {
94
+ input_tokens: 10,
95
+ output_tokens: 20,
96
+ cache_read_input_tokens: 100,
97
+ cache_creation_input_tokens: 5,
98
+ },
99
+ duration_ms: 1000,
100
+ duration_api_ms: 900,
101
+ num_turns: 3,
102
+ })}\n`);
103
+ expect(events.texts).toEqual(["final verdict text"]);
104
+ expect(events.costs).toEqual([
105
+ {
106
+ totalCostUsd: 0.5,
107
+ totalInputTokens: 115,
108
+ totalOutputTokens: 20,
109
+ durationMs: 1000,
110
+ durationApiMs: 900,
111
+ numTurns: 3,
112
+ },
113
+ ]);
114
+ expect(events.results).toEqual(["success"]);
115
+ });
116
+ it("does NOT duplicate text from result.result when assistant text was already emitted", () => {
117
+ const parser = new StreamParser();
118
+ const events = collect(parser);
119
+ parser.feed(`${JSON.stringify({
120
+ type: "assistant",
121
+ message: {
122
+ content: [{ type: "text", text: "the verdict" }],
123
+ },
124
+ })}\n${JSON.stringify({
125
+ type: "result",
126
+ subtype: "success",
127
+ result: "the verdict",
128
+ total_cost_usd: 0,
129
+ })}\n`);
130
+ expect(events.texts).toEqual(["the verdict"]);
131
+ });
132
+ it("handles split NDJSON chunks (partial line buffering)", () => {
133
+ const parser = new StreamParser();
134
+ const events = collect(parser);
135
+ const line = JSON.stringify({
136
+ type: "assistant",
137
+ message: { content: [{ type: "text", text: "hello" }] },
138
+ });
139
+ parser.feed(line.slice(0, 20));
140
+ parser.feed(`${line.slice(20)}\n`);
141
+ expect(events.texts).toEqual(["hello"]);
142
+ });
143
+ it("handles tool_result content as array of blocks", () => {
144
+ const parser = new StreamParser();
145
+ const events = collect(parser);
146
+ parser.feed(`${JSON.stringify({
147
+ type: "assistant",
148
+ message: {
149
+ content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }],
150
+ },
151
+ })}\n${JSON.stringify({
152
+ type: "user",
153
+ message: {
154
+ content: [
155
+ {
156
+ type: "tool_result",
157
+ tool_use_id: "t1",
158
+ content: [{ type: "text", text: "line1" }],
159
+ },
160
+ ],
161
+ },
162
+ })}\n`);
163
+ expect(events.toolEnds[0].content).toBe("line1");
164
+ });
165
+ it("skips non-JSON lines silently (no parse_error event)", () => {
166
+ const parser = new StreamParser();
167
+ const events = collect(parser);
168
+ parser.feed("not-json garbage\n");
169
+ parser.feed(`${JSON.stringify({
170
+ type: "assistant",
171
+ message: { content: [{ type: "text", text: "ok" }] },
172
+ })}\n`);
173
+ expect(events.errors).toHaveLength(0);
174
+ expect(events.texts).toEqual(["ok"]);
175
+ });
176
+ it("throws if attached twice", async () => {
177
+ const parser = new StreamParser();
178
+ const { Readable } = await import("node:stream");
179
+ parser.attach(Readable.from([]));
180
+ expect(() => parser.attach(Readable.from([]))).toThrow("StreamParser already attached");
181
+ });
182
+ it("reconstructs the full verdict JSON across multiple streamed text blocks", () => {
183
+ // Simulates the common case where Claude streams a final message with
184
+ // a preamble + fenced JSON block in a single text block. The StreamParser
185
+ // MUST surface that text so parseReviewOutput can pick up the verdict.
186
+ const parser = new StreamParser();
187
+ const events = collect(parser);
188
+ const finalText = 'All findings complete. Outputting verdict.\n\n```json\n{\n "verdict": "approved",\n "summary": "ok",\n "findings": []\n}\n```';
189
+ parser.feed(`${JSON.stringify({
190
+ type: "assistant",
191
+ message: { content: [{ type: "text", text: finalText }] },
192
+ })}\n`);
193
+ const joined = events.texts.join("");
194
+ expect(joined).toContain('"verdict": "approved"');
195
+ });
196
+ });
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { createRequire } from "node:module";
3
4
  import { buildLabelMap, hasLabel, resolveCardLabels } from "./board-helpers.js";
4
5
  import { createApiClient, fetchRealtimeCredentials, loadDaemonConfig, } from "./config.js";
5
6
  import { ConfigValidationError, validateColumnReferences, } from "./config-validation.js";
@@ -12,9 +13,12 @@ import { Reconciler } from "./reconcile.js";
12
13
  import { recoverOrphans } from "./recovery.js";
13
14
  import { extractBranchFromDescription } from "./review-worktree.js";
14
15
  import { StateStore } from "./state-store.js";
16
+ import { verifyStreamParserFormat } from "./stream-parser-selftest.js";
17
+ import { AGENT_NAME } from "./types.js";
15
18
  import { Watcher } from "./watcher.js";
16
19
  import { WorktreeGc } from "./worktree-gc.js";
17
20
  const TAG = "daemon";
21
+ const { version: PKG_VERSION } = createRequire(import.meta.url)("../package.json");
18
22
  // ============ STARTUP VALIDATION ============
19
23
  async function validatePrerequisites(config) {
20
24
  // 1. Check claude CLI
@@ -27,6 +31,10 @@ async function validatePrerequisites(config) {
27
31
  catch {
28
32
  throw new Error("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code");
29
33
  }
34
+ // 1b. Stream parser canary — if the CLI stream-json envelope has drifted,
35
+ // every review would silently park with a parse error (see #128).
36
+ verifyStreamParserFormat();
37
+ log.info(TAG, "Stream parser canary: ok");
30
38
  // 2. Check git provider CLI (if PR creation enabled in either pipeline)
31
39
  if (config.agent.completion.createPR || config.agent.review.createPR) {
32
40
  const provider = detectGitProvider();
@@ -79,7 +87,7 @@ async function validatePrerequisites(config) {
79
87
  // ============ MAIN DAEMON ============
80
88
  export { validatePrerequisites };
81
89
  export async function main() {
82
- log.info(TAG, "Harmony Agent Daemon starting...");
90
+ log.info(TAG, `Harmony Agent Daemon v${PKG_VERSION} starting...`);
83
91
  // Load config
84
92
  const config = loadDaemonConfig();
85
93
  log.info(TAG, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
@@ -193,7 +201,12 @@ export async function main() {
193
201
  })
194
202
  : null;
195
203
  // Create watcher (broadcast events from harmony-api + agent commands from UI)
196
- const watcher = new Watcher(realtimeCreds, config.projectId, async (event) => {
204
+ const watcher = new Watcher(realtimeCreds, config.projectId, {
205
+ userId: agentUserId,
206
+ userEmail: config.userEmail,
207
+ agentIdentifier: "harmony-daemon",
208
+ agentName: AGENT_NAME,
209
+ }, async (event) => {
197
210
  await handleBroadcast(event, client, pool, config, agentUserId);
198
211
  }, async (command) => {
199
212
  await pool.handleAgentCommand(command.cardId, command.command);
@@ -134,20 +134,23 @@ export class MergeMonitor {
134
134
  log.warn(TAG, `Failed to remove label: ${err instanceof Error ? err.message : err}`);
135
135
  }
136
136
  }
137
- // 4. Mark done + append merge timestamp to description (idempotent)
137
+ // 4. Append merge timestamp to description (idempotent).
138
+ // Do NOT force done=true here — the column's `mark_cards_done` flag is the
139
+ // user's source of truth, and the backend `moveCard` in step 1 already
140
+ // applied it. Overriding here would ignore the user's column setting (#129).
138
141
  const existing = card.description || "";
139
142
  const alreadyStamped = existing.includes("Merged at");
140
- try {
141
- const update = { done: true };
142
- if (!alreadyStamped) {
143
+ if (!alreadyStamped) {
144
+ try {
143
145
  const timestamp = new Date().toISOString();
144
146
  const separator = existing ? "\n" : "";
145
- update.description = `${existing}${separator}Merged at ${timestamp}`;
147
+ await this.client.updateCard(card.id, {
148
+ description: `${existing}${separator}Merged at ${timestamp}`,
149
+ });
150
+ }
151
+ catch (err) {
152
+ log.warn(TAG, `Failed to update card: ${err instanceof Error ? err.message : err}`);
146
153
  }
147
- await this.client.updateCard(card.id, update);
148
- }
149
- catch (err) {
150
- log.warn(TAG, `Failed to update card: ${err instanceof Error ? err.message : err}`);
151
154
  }
152
155
  // 5. Best-effort: clean up local branch (uses shared extraction with validation)
153
156
  const branchName = extractBranchFromDescription(card.description);
@@ -36,4 +36,4 @@ export declare function parseReviewOutput(stdout: string): ReviewResult;
36
36
  * Handles approved/rejected verdicts, creates subtasks for findings,
37
37
  * and moves the card to the appropriate column.
38
38
  */
39
- export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null): Promise<void>;
39
+ export declare function runReviewCompletion(client: HarmonyApiClient, card: Card, result: ReviewResult, config: AgentConfig, worktreePath: string, branchName: string | null, sessionStats?: SessionStats | null, runLogPath?: string | null): Promise<void>;
@@ -1,3 +1,4 @@
1
+ import { readFileSync, statSync } from "node:fs";
1
2
  import { addLabelByName, moveCardToColumn } from "./board-helpers.js";
2
3
  import { buildTokenPayload } from "./completion.js";
3
4
  import { createPullRequest, detectGitProvider, pushBranch } from "./git-pr.js";
@@ -7,6 +8,24 @@ import { cleanupWorktree } from "./worktree.js";
7
8
  const TAG = "review-completion";
8
9
  const MAX_FINDINGS = 10;
9
10
  const REVIEW_MARKER = "---\n**Review:";
11
+ const RUN_LOG_TAIL_BYTES = 2048;
12
+ /**
13
+ * Read the last N bytes of a file as UTF-8. Returns null on any IO failure —
14
+ * the parse-error surfacing is best-effort diagnostic, it must not throw.
15
+ */
16
+ function tailRunLog(path, bytes = RUN_LOG_TAIL_BYTES) {
17
+ try {
18
+ const size = statSync(path).size;
19
+ if (size === 0)
20
+ return null;
21
+ const start = Math.max(0, size - bytes);
22
+ const buf = readFileSync(path);
23
+ return buf.subarray(start).toString("utf-8");
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
10
29
  /**
11
30
  * Extract structured fields from a parsed JSON object into a ReviewResult.
12
31
  */
@@ -163,7 +182,7 @@ function stripReviewSummary(description) {
163
182
  * Handles approved/rejected verdicts, creates subtasks for findings,
164
183
  * and moves the card to the appropriate column.
165
184
  */
166
- export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats) {
185
+ export async function runReviewCompletion(client, card, result, config, worktreePath, branchName, sessionStats, runLogPath) {
167
186
  // Re-fetch card for fresh description (avoids stale data from enqueue time)
168
187
  let freshDesc;
169
188
  try {
@@ -189,10 +208,24 @@ export async function runReviewCompletion(client, card, result, config, worktree
189
208
  }
190
209
  if (config.review.postFindings) {
191
210
  const baseDesc = stripReviewSummary(freshDesc);
211
+ const rawTail = runLogPath ? tailRunLog(runLogPath) : null;
212
+ // Log content routinely contains ```json fences from Claude's own
213
+ // output; embedding it inside a 3-backtick fence would break the card's
214
+ // markdown. Use a 4-backtick fence and downgrade any 4+-backtick runs.
215
+ const runLogTail = rawTail
216
+ ? rawTail.replace(/`{4,}/g, (_m) => "`".repeat(3))
217
+ : null;
218
+ const runLogHint = runLogPath
219
+ ? `\nRun log: \`${runLogPath}\``
220
+ : "\nRun log: (not captured)";
192
221
  const summary = [
193
222
  `\n\n${REVIEW_MARKER} Parse error**`,
194
- '\nThe review agent\'s output could not be parsed. Card stays in Review with the "Need Review" label — check the run log in ~/.harmony-mcp/runs/ for diagnosis.',
223
+ '\nThe review agent\'s output could not be parsed. Card stays in Review with the "Need Review" label — inspect the run log below to diagnose.',
224
+ runLogHint,
195
225
  result.summary ? `\n\nRaw output (truncated):\n${result.summary}` : "",
226
+ runLogTail
227
+ ? `\n\nRun log tail (last ${RUN_LOG_TAIL_BYTES}B):\n\`\`\`\`\n${runLogTail}\n\`\`\`\``
228
+ : "",
196
229
  ].join("");
197
230
  try {
198
231
  await client.updateCard(card.id, { description: baseDesc + summary });
@@ -21,6 +21,7 @@ export declare class ReviewWorker {
21
21
  private lastSessionStats;
22
22
  private aborted;
23
23
  private runId;
24
+ private lastRunLogPath;
24
25
  constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: ReviewWorker) => void, stateStore: StateStore);
25
26
  private startHeartbeat;
26
27
  private stopHeartbeat;
@@ -36,6 +36,7 @@ export class ReviewWorker {
36
36
  lastSessionStats = null;
37
37
  aborted = false;
38
38
  runId = null;
39
+ lastRunLogPath = null;
39
40
  constructor(id, config, client, _userEmail, onDone, stateStore) {
40
41
  this.config = config;
41
42
  this.client = client;
@@ -255,7 +256,7 @@ export class ReviewWorker {
255
256
  progressPercent: 80,
256
257
  });
257
258
  // Run review completion pipeline
258
- await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats);
259
+ await runReviewCompletion(this.client, card, result, this.config, cwd, this.branchName, sessionStats, this.lastRunLogPath);
259
260
  }
260
261
  catch (err) {
261
262
  this.state = "error";
@@ -439,6 +440,7 @@ export class ReviewWorker {
439
440
  ];
440
441
  log.info(this.tag, `Spawning review: claude ${args.slice(0, 5).join(" ")} ...`);
441
442
  const runLog = openRunLog(this.tag, this.runId, shortId);
443
+ this.lastRunLogPath = runLog?.path ?? null;
442
444
  if (runLog) {
443
445
  log.info(this.tag, `Run log: ${runLog.path}`);
444
446
  runLog.stream.write(`# run=${this.runId} card=#${shortId} pipeline=review started=${new Date().toISOString()}\n` +
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Feed a minimal Claude-CLI stream-json fixture through StreamParser and
3
+ * assert the expected events fire. Intended as a startup canary: if the CLI
4
+ * ever changes its envelope shape, the daemon fails loud on boot instead of
5
+ * silently parking every card with a parse error (see card #128).
6
+ *
7
+ * Throws on any missing event; returns silently on success.
8
+ */
9
+ export declare function verifyStreamParserFormat(): void;
@@ -0,0 +1,97 @@
1
+ import { StreamParser } from "./stream-parser.js";
2
+ /**
3
+ * Feed a minimal Claude-CLI stream-json fixture through StreamParser and
4
+ * assert the expected events fire. Intended as a startup canary: if the CLI
5
+ * ever changes its envelope shape, the daemon fails loud on boot instead of
6
+ * silently parking every card with a parse error (see card #128).
7
+ *
8
+ * Throws on any missing event; returns silently on success.
9
+ */
10
+ export function verifyStreamParserFormat() {
11
+ const parser = new StreamParser();
12
+ let textSeen = null;
13
+ let toolStart = null;
14
+ let toolEnd = null;
15
+ let resultSeen = null;
16
+ let costSeen = false;
17
+ parser.on("text", (c) => {
18
+ textSeen = c;
19
+ });
20
+ parser.on("tool_start", (name, input) => {
21
+ toolStart = { name, input };
22
+ });
23
+ parser.on("tool_end", (name, id, content) => {
24
+ toolEnd = { name, id, content };
25
+ });
26
+ parser.on("result", (stop) => {
27
+ resultSeen = stop;
28
+ });
29
+ parser.on("cost_update", () => {
30
+ costSeen = true;
31
+ });
32
+ const fixture = [
33
+ {
34
+ type: "assistant",
35
+ message: {
36
+ content: [{ type: "text", text: "canary-text" }],
37
+ },
38
+ },
39
+ {
40
+ type: "assistant",
41
+ message: {
42
+ content: [
43
+ { type: "tool_use", id: "tu_canary", name: "Read", input: { x: 1 } },
44
+ ],
45
+ },
46
+ },
47
+ {
48
+ type: "user",
49
+ message: {
50
+ content: [
51
+ {
52
+ type: "tool_result",
53
+ tool_use_id: "tu_canary",
54
+ content: "ok",
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ {
60
+ type: "result",
61
+ subtype: "success",
62
+ result: "done",
63
+ stop_reason: "end_turn",
64
+ total_cost_usd: 0.001,
65
+ usage: { input_tokens: 1, output_tokens: 1 },
66
+ },
67
+ ];
68
+ for (const line of fixture) {
69
+ parser.feed(`${JSON.stringify(line)}\n`);
70
+ }
71
+ const failures = [];
72
+ if (textSeen !== "canary-text") {
73
+ failures.push(`text event missing or wrong (got ${JSON.stringify(textSeen)})`);
74
+ }
75
+ const ts = toolStart;
76
+ if (!ts || ts.name !== "Read") {
77
+ failures.push("tool_start event missing or wrong");
78
+ }
79
+ const te = toolEnd;
80
+ if (!te ||
81
+ te.name !== "Read" ||
82
+ te.id !== "tu_canary" ||
83
+ te.content !== "ok") {
84
+ failures.push("tool_end event missing or wrong");
85
+ }
86
+ if (resultSeen !== "end_turn") {
87
+ failures.push(`result event missing or wrong (got ${JSON.stringify(resultSeen)})`);
88
+ }
89
+ if (!costSeen) {
90
+ failures.push("cost_update event missing");
91
+ }
92
+ if (failures.length > 0) {
93
+ throw new Error("StreamParser canary failed — Claude CLI stream-json format may have drifted. " +
94
+ "Review pipeline will silently park every card until this is fixed.\n" +
95
+ failures.map((f) => ` - ${f}`).join("\n"));
96
+ }
97
+ }
@@ -19,12 +19,19 @@ export interface StreamParserEvents {
19
19
  export declare class StreamParser extends EventEmitter<StreamParserEvents> {
20
20
  private buffer;
21
21
  private attached;
22
+ private toolNames;
23
+ private hasEmittedText;
22
24
  /**
23
25
  * Attach a readable stream (Claude CLI stdout) to the parser.
24
26
  * Parses NDJSON lines and emits typed events.
25
27
  * Each instance must only be attached once.
26
28
  */
27
29
  attach(stream: Readable): void;
30
+ /**
31
+ * Feed a raw NDJSON chunk directly. Exposed for tests and any caller
32
+ * that doesn't have a Readable stream handy.
33
+ */
34
+ feed(chunk: string): void;
28
35
  private flush;
29
36
  private parseLine;
30
37
  private handleMessage;
@@ -4,6 +4,8 @@ const TAG = "stream-parser";
4
4
  export class StreamParser extends EventEmitter {
5
5
  buffer = "";
6
6
  attached = false;
7
+ toolNames = new Map();
8
+ hasEmittedText = false;
7
9
  /**
8
10
  * Attach a readable stream (Claude CLI stdout) to the parser.
9
11
  * Parses NDJSON lines and emits typed events.
@@ -25,6 +27,14 @@ export class StreamParser extends EventEmitter {
25
27
  }
26
28
  });
27
29
  }
30
+ /**
31
+ * Feed a raw NDJSON chunk directly. Exposed for tests and any caller
32
+ * that doesn't have a Readable stream handy.
33
+ */
34
+ feed(chunk) {
35
+ this.buffer += chunk;
36
+ this.flush();
37
+ }
28
38
  flush() {
29
39
  const lines = this.buffer.split("\n");
30
40
  // Keep incomplete last line in buffer
@@ -58,38 +68,94 @@ export class StreamParser extends EventEmitter {
58
68
  handleMessage(msg) {
59
69
  switch (msg.type) {
60
70
  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);
71
+ const blocks = msg.message?.content;
72
+ if (!Array.isArray(blocks))
73
+ break;
74
+ for (const block of blocks) {
75
+ if (block.type === "text" && typeof block.text === "string") {
76
+ this.emit("text", block.text);
77
+ this.hasEmittedText = true;
78
+ }
79
+ else if (block.type === "tool_use" &&
80
+ typeof block.name === "string") {
81
+ if (typeof block.id === "string") {
82
+ this.toolNames.set(block.id, block.name);
83
+ }
84
+ this.emit("tool_start", block.name, block.input);
85
+ }
86
+ // thinking blocks, redacted_thinking, etc. — ignored
66
87
  }
67
88
  break;
68
89
  }
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);
90
+ case "user": {
91
+ const blocks = msg.message?.content;
92
+ if (!Array.isArray(blocks))
93
+ break;
94
+ for (const block of blocks) {
95
+ if (block.type === "tool_result" &&
96
+ typeof block.tool_use_id === "string") {
97
+ const name = this.toolNames.get(block.tool_use_id) ?? "";
98
+ this.toolNames.delete(block.tool_use_id);
99
+ const content = normalizeToolResultContent(block.content);
100
+ this.emit("tool_end", name, block.tool_use_id, content);
101
+ }
72
102
  }
73
103
  break;
74
104
  }
75
105
  case "result": {
76
- this.emit("result", msg.stop_reason ?? "unknown");
77
- break;
78
- }
79
- case "cost_update": {
80
- if (msg.total_cost_usd != null) {
106
+ // Fallback: if the CLI ended without streaming any assistant text
107
+ // (e.g. hit max-turns), the final `result` field still carries the
108
+ // last assistant message. Emit it so the caller can parse the verdict.
109
+ if (!this.hasEmittedText &&
110
+ typeof msg.result === "string" &&
111
+ msg.result.length > 0) {
112
+ this.emit("text", msg.result);
113
+ this.hasEmittedText = true;
114
+ }
115
+ if (typeof msg.total_cost_usd === "number") {
116
+ const usage = msg.usage;
117
+ const totalInputTokens = (usage?.input_tokens ?? 0) +
118
+ (usage?.cache_creation_input_tokens ?? 0) +
119
+ (usage?.cache_read_input_tokens ?? 0);
81
120
  this.emit("cost_update", {
82
121
  totalCostUsd: msg.total_cost_usd,
83
- totalInputTokens: msg.total_input_tokens ?? 0,
84
- totalOutputTokens: msg.total_output_tokens ?? 0,
122
+ totalInputTokens,
123
+ totalOutputTokens: usage?.output_tokens ?? 0,
85
124
  durationMs: msg.duration_ms ?? 0,
86
125
  durationApiMs: msg.duration_api_ms ?? 0,
87
126
  numTurns: msg.num_turns ?? 0,
88
127
  });
89
128
  }
129
+ this.emit("result", msg.stop_reason ?? msg.subtype ?? "unknown");
90
130
  break;
91
131
  }
92
132
  // Ignore system, ping, etc.
93
133
  }
94
134
  }
95
135
  }
136
+ function normalizeToolResultContent(raw) {
137
+ if (raw == null)
138
+ return undefined;
139
+ if (typeof raw === "string")
140
+ return raw;
141
+ if (Array.isArray(raw)) {
142
+ // Anthropic sometimes returns content as a list of typed blocks
143
+ // ({ type: "text", text: "..." }); flatten text blocks, fall back to JSON.
144
+ const parts = [];
145
+ for (const block of raw) {
146
+ if (block &&
147
+ typeof block === "object" &&
148
+ "text" in block &&
149
+ typeof block.text === "string") {
150
+ parts.push(block.text);
151
+ }
152
+ }
153
+ return parts.length > 0 ? parts.join("") : JSON.stringify(raw);
154
+ }
155
+ try {
156
+ return JSON.stringify(raw);
157
+ }
158
+ catch {
159
+ return String(raw);
160
+ }
161
+ }
package/dist/watcher.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface AgentCommandEvent {
9
9
  }
10
10
  export type CardBroadcastHandler = (event: CardBroadcastEvent) => void;
11
11
  export type AgentCommandHandler = (event: AgentCommandEvent) => void;
12
+ export interface PresenceIdentity {
13
+ userId: string;
14
+ userEmail: string;
15
+ agentIdentifier: string;
16
+ agentName: string;
17
+ }
12
18
  /**
13
19
  * Subscribes to Supabase broadcast events on the board channel.
14
20
  * The harmony-api broadcasts card_update and card_created events
@@ -17,6 +23,7 @@ export type AgentCommandHandler = (event: AgentCommandEvent) => void;
17
23
  export declare class Watcher {
18
24
  private credentials;
19
25
  private projectId;
26
+ private identity;
20
27
  private onCardBroadcast;
21
28
  private onAgentCommand?;
22
29
  private channel;
@@ -25,7 +32,7 @@ export declare class Watcher {
25
32
  private daemonId;
26
33
  private connected;
27
34
  get isConnected(): boolean;
28
- constructor(credentials: RealtimeCredentials, projectId: string, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
35
+ constructor(credentials: RealtimeCredentials, projectId: string, identity: PresenceIdentity, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
29
36
  start(): Promise<void>;
30
37
  stop(): Promise<void>;
31
38
  }
package/dist/watcher.js CHANGED
@@ -10,6 +10,7 @@ const TAG = "watcher";
10
10
  export class Watcher {
11
11
  credentials;
12
12
  projectId;
13
+ identity;
13
14
  onCardBroadcast;
14
15
  onAgentCommand;
15
16
  channel = null;
@@ -20,9 +21,10 @@ export class Watcher {
20
21
  get isConnected() {
21
22
  return this.connected;
22
23
  }
23
- constructor(credentials, projectId, onCardBroadcast, onAgentCommand) {
24
+ constructor(credentials, projectId, identity, onCardBroadcast, onAgentCommand) {
24
25
  this.credentials = credentials;
25
26
  this.projectId = projectId;
27
+ this.identity = identity;
26
28
  this.onCardBroadcast = onCardBroadcast;
27
29
  this.onAgentCommand = onAgentCommand;
28
30
  }
@@ -85,6 +87,10 @@ export class Watcher {
85
87
  await presenceChannel.track({
86
88
  daemonId: this.daemonId,
87
89
  startedAt: new Date().toISOString(),
90
+ userId: this.identity.userId,
91
+ userEmail: this.identity.userEmail,
92
+ agentIdentifier: this.identity.agentIdentifier,
93
+ agentName: this.identity.agentName,
88
94
  });
89
95
  log.info(TAG, "Presence tracked on board-presence channel");
90
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Push-based agent daemon for Harmony — watches board assignments and spawns Claude CLI workers",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@supabase/supabase-js": "2.95.3",
48
- "@gethmy/mcp": "^2.0.0"
48
+ "@gethmy/mcp": "^2.4.3"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@harmony/shared": "workspace:*",