@gethmy/agent 1.1.1 → 1.2.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.
@@ -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
+ });
@@ -1,5 +1,27 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { signalGroup, spawnInGroup, terminateGroup } from "../process-group.js";
3
+ /**
4
+ * Wait until the child writes a "ready" line to stdout. This is the only
5
+ * reliable way to know a Node child has actually installed its signal
6
+ * handlers — time-based waits flake under CI/test-suite load because the
7
+ * handler may not be registered before the test sends SIGTERM, in which case
8
+ * Node's default handler terminates the process and the escalation test
9
+ * can't observe SIGKILL. Child scripts in this file print "ready\n" after
10
+ * calling process.on(...).
11
+ */
12
+ function waitForReady(proc, timeoutMs = 3000) {
13
+ return new Promise((resolve, reject) => {
14
+ const timer = setTimeout(() => reject(new Error("child never reported ready")), timeoutMs);
15
+ let buf = "";
16
+ proc.stdout?.on("data", (d) => {
17
+ buf += d.toString();
18
+ if (buf.includes("ready")) {
19
+ clearTimeout(timer);
20
+ resolve();
21
+ }
22
+ });
23
+ });
24
+ }
3
25
  describe("process-group", () => {
4
26
  it("places the child in its own process group (pid === pgid)", async () => {
5
27
  if (process.platform === "win32")
@@ -28,15 +50,13 @@ describe("process-group", () => {
28
50
  return;
29
51
  const proc = spawnInGroup(process.execPath, [
30
52
  "-e",
31
- "process.on('SIGINT', () => process.exit(0)); setInterval(()=>{}, 1000);",
53
+ "process.on('SIGINT', () => process.exit(0)); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
32
54
  ]);
33
55
  // Capture the exit state up front so we never miss it.
34
56
  const exited = new Promise((resolve) => {
35
57
  proc.once("exit", (code, signal) => resolve({ code, signal }));
36
58
  });
37
- // Give the child enough time to attach its SIGINT handler even
38
- // under contention (9 parallel test files, git spawning, etc.).
39
- await new Promise((r) => setTimeout(r, 500));
59
+ await waitForReady(proc);
40
60
  await terminateGroup(proc, {
41
61
  sigintTimeoutMs: 2000,
42
62
  sigtermTimeoutMs: 500,
@@ -52,17 +72,17 @@ describe("process-group", () => {
52
72
  return;
53
73
  const proc = spawnInGroup(process.execPath, [
54
74
  "-e",
55
- "process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); setInterval(()=>{}, 1000);",
75
+ "process.on('SIGINT', () => {}); process.on('SIGTERM', () => {}); process.stdout.write('ready\\n'); setInterval(()=>{}, 1000);",
56
76
  ]);
57
77
  const exited = new Promise((resolve) => {
58
78
  proc.once("exit", (code, signal) => resolve({ code, signal }));
59
79
  });
60
- await new Promise((r) => setTimeout(r, 150));
80
+ await waitForReady(proc);
61
81
  await terminateGroup(proc, {
62
82
  sigintTimeoutMs: 200,
63
83
  sigtermTimeoutMs: 200,
64
84
  });
65
85
  const result = await exited;
66
- expect(result.signal === "SIGKILL" || result.code !== null).toBe(true);
86
+ expect(result.signal).toBe("SIGKILL");
67
87
  });
68
88
  });
@@ -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,199 @@
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: 10,
108
+ totalOutputTokens: 20,
109
+ totalCacheCreationInputTokens: 5,
110
+ totalCacheReadInputTokens: 100,
111
+ durationMs: 1000,
112
+ durationApiMs: 900,
113
+ numTurns: 3,
114
+ modelName: undefined,
115
+ },
116
+ ]);
117
+ expect(events.results).toEqual(["success"]);
118
+ });
119
+ it("does NOT duplicate text from result.result when assistant text was already emitted", () => {
120
+ const parser = new StreamParser();
121
+ const events = collect(parser);
122
+ parser.feed(`${JSON.stringify({
123
+ type: "assistant",
124
+ message: {
125
+ content: [{ type: "text", text: "the verdict" }],
126
+ },
127
+ })}\n${JSON.stringify({
128
+ type: "result",
129
+ subtype: "success",
130
+ result: "the verdict",
131
+ total_cost_usd: 0,
132
+ })}\n`);
133
+ expect(events.texts).toEqual(["the verdict"]);
134
+ });
135
+ it("handles split NDJSON chunks (partial line buffering)", () => {
136
+ const parser = new StreamParser();
137
+ const events = collect(parser);
138
+ const line = JSON.stringify({
139
+ type: "assistant",
140
+ message: { content: [{ type: "text", text: "hello" }] },
141
+ });
142
+ parser.feed(line.slice(0, 20));
143
+ parser.feed(`${line.slice(20)}\n`);
144
+ expect(events.texts).toEqual(["hello"]);
145
+ });
146
+ it("handles tool_result content as array of blocks", () => {
147
+ const parser = new StreamParser();
148
+ const events = collect(parser);
149
+ parser.feed(`${JSON.stringify({
150
+ type: "assistant",
151
+ message: {
152
+ content: [{ type: "tool_use", id: "t1", name: "Read", input: {} }],
153
+ },
154
+ })}\n${JSON.stringify({
155
+ type: "user",
156
+ message: {
157
+ content: [
158
+ {
159
+ type: "tool_result",
160
+ tool_use_id: "t1",
161
+ content: [{ type: "text", text: "line1" }],
162
+ },
163
+ ],
164
+ },
165
+ })}\n`);
166
+ expect(events.toolEnds[0].content).toBe("line1");
167
+ });
168
+ it("skips non-JSON lines silently (no parse_error event)", () => {
169
+ const parser = new StreamParser();
170
+ const events = collect(parser);
171
+ parser.feed("not-json garbage\n");
172
+ parser.feed(`${JSON.stringify({
173
+ type: "assistant",
174
+ message: { content: [{ type: "text", text: "ok" }] },
175
+ })}\n`);
176
+ expect(events.errors).toHaveLength(0);
177
+ expect(events.texts).toEqual(["ok"]);
178
+ });
179
+ it("throws if attached twice", async () => {
180
+ const parser = new StreamParser();
181
+ const { Readable } = await import("node:stream");
182
+ parser.attach(Readable.from([]));
183
+ expect(() => parser.attach(Readable.from([]))).toThrow("StreamParser already attached");
184
+ });
185
+ it("reconstructs the full verdict JSON across multiple streamed text blocks", () => {
186
+ // Simulates the common case where Claude streams a final message with
187
+ // a preamble + fenced JSON block in a single text block. The StreamParser
188
+ // MUST surface that text so parseReviewOutput can pick up the verdict.
189
+ const parser = new StreamParser();
190
+ const events = collect(parser);
191
+ const finalText = 'All findings complete. Outputting verdict.\n\n```json\n{\n "verdict": "approved",\n "summary": "ok",\n "findings": []\n}\n```';
192
+ parser.feed(`${JSON.stringify({
193
+ type: "assistant",
194
+ message: { content: [{ type: "text", text: finalText }] },
195
+ })}\n`);
196
+ const joined = events.texts.join("");
197
+ expect(joined).toContain('"verdict": "approved"');
198
+ });
199
+ });
@@ -12,10 +12,16 @@ export declare function buildTokenPayload(stats?: SessionStats | null): {
12
12
  costCents?: undefined;
13
13
  inputTokens?: undefined;
14
14
  outputTokens?: undefined;
15
+ cacheCreationInputTokens?: undefined;
16
+ cacheReadInputTokens?: undefined;
17
+ modelName?: undefined;
15
18
  } | {
16
19
  costCents: number;
17
20
  inputTokens: number;
18
21
  outputTokens: number;
22
+ cacheCreationInputTokens: number;
23
+ cacheReadInputTokens: number;
24
+ modelName: string | undefined;
19
25
  };
20
26
  /**
21
27
  * Post-work pipeline: push branch, create PR, move card, post summary.
@@ -20,6 +20,9 @@ export function buildTokenPayload(stats) {
20
20
  costCents: Math.round(stats.cost.totalCostUsd * 100),
21
21
  inputTokens: stats.cost.totalInputTokens,
22
22
  outputTokens: stats.cost.totalOutputTokens,
23
+ cacheCreationInputTokens: stats.cost.totalCacheCreationInputTokens,
24
+ cacheReadInputTokens: stats.cost.totalCacheReadInputTokens,
25
+ modelName: stats.cost.modelName,
23
26
  };
24
27
  }
25
28
  /**
@@ -140,11 +143,18 @@ async function postSummary(client, card, branchName, worktreePath, prUrl, baseBr
140
143
  if (sessionStats.cost) {
141
144
  statParts.push(`$${sessionStats.cost.totalCostUsd.toFixed(2)} cost`);
142
145
  statParts.push(`${sessionStats.cost.numTurns} turns`);
143
- const totalTokens = sessionStats.cost.totalInputTokens +
144
- sessionStats.cost.totalOutputTokens;
146
+ const totalInput = sessionStats.cost.totalInputTokens +
147
+ sessionStats.cost.totalCacheCreationInputTokens +
148
+ sessionStats.cost.totalCacheReadInputTokens;
149
+ const totalTokens = totalInput + sessionStats.cost.totalOutputTokens;
145
150
  if (totalTokens > 0) {
146
151
  statParts.push(`${formatTokenCount(totalTokens)} tokens`);
147
152
  }
153
+ const cacheRead = sessionStats.cost.totalCacheReadInputTokens;
154
+ if (totalInput > 0 && cacheRead > 0) {
155
+ const hitPct = Math.round((cacheRead / totalInput) * 100);
156
+ statParts.push(`${hitPct}% cache hit`);
157
+ }
148
158
  }
149
159
  parts.push(`Stats: ${statParts.join(" · ")}`);
150
160
  }
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);
@@ -349,6 +349,9 @@ export class ProgressTracker {
349
349
  costCents: Math.round((this.lastCost?.totalCostUsd ?? 0) * 100),
350
350
  inputTokens: this.lastCost?.totalInputTokens ?? 0,
351
351
  outputTokens: this.lastCost?.totalOutputTokens ?? 0,
352
+ cacheCreationInputTokens: this.lastCost?.totalCacheCreationInputTokens ?? 0,
353
+ cacheReadInputTokens: this.lastCost?.totalCacheReadInputTokens ?? 0,
354
+ modelName: this.lastCost?.modelName,
352
355
  })
353
356
  .catch((err) => {
354
357
  log.warn(TAG, `Failed to send progress update: ${err}`);
@@ -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
+ }
@@ -2,11 +2,15 @@ import { EventEmitter } from "node:events";
2
2
  import type { Readable } from "node:stream";
3
3
  export interface CostUpdate {
4
4
  totalCostUsd: number;
5
+ /** Fresh input tokens only — does NOT include cache_read or cache_creation. */
5
6
  totalInputTokens: number;
6
7
  totalOutputTokens: number;
8
+ totalCacheCreationInputTokens: number;
9
+ totalCacheReadInputTokens: number;
7
10
  durationMs: number;
8
11
  durationApiMs: number;
9
12
  numTurns: number;
13
+ modelName?: string;
10
14
  }
11
15
  export interface StreamParserEvents {
12
16
  tool_start: [name: string, input: unknown];
@@ -19,12 +23,20 @@ export interface StreamParserEvents {
19
23
  export declare class StreamParser extends EventEmitter<StreamParserEvents> {
20
24
  private buffer;
21
25
  private attached;
26
+ private toolNames;
27
+ private hasEmittedText;
28
+ private observedModel?;
22
29
  /**
23
30
  * Attach a readable stream (Claude CLI stdout) to the parser.
24
31
  * Parses NDJSON lines and emits typed events.
25
32
  * Each instance must only be attached once.
26
33
  */
27
34
  attach(stream: Readable): void;
35
+ /**
36
+ * Feed a raw NDJSON chunk directly. Exposed for tests and any caller
37
+ * that doesn't have a Readable stream handy.
38
+ */
39
+ feed(chunk: string): void;
28
40
  private flush;
29
41
  private parseLine;
30
42
  private handleMessage;
@@ -4,6 +4,9 @@ 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;
9
+ observedModel;
7
10
  /**
8
11
  * Attach a readable stream (Claude CLI stdout) to the parser.
9
12
  * Parses NDJSON lines and emits typed events.
@@ -25,6 +28,14 @@ export class StreamParser extends EventEmitter {
25
28
  }
26
29
  });
27
30
  }
31
+ /**
32
+ * Feed a raw NDJSON chunk directly. Exposed for tests and any caller
33
+ * that doesn't have a Readable stream handy.
34
+ */
35
+ feed(chunk) {
36
+ this.buffer += chunk;
37
+ this.flush();
38
+ }
28
39
  flush() {
29
40
  const lines = this.buffer.split("\n");
30
41
  // Keep incomplete last line in buffer
@@ -56,40 +67,108 @@ export class StreamParser extends EventEmitter {
56
67
  }
57
68
  }
58
69
  handleMessage(msg) {
70
+ // Capture model from any envelope that carries it. The Claude CLI exposes
71
+ // `model` on the top-level `system` envelope and inside each assistant
72
+ // envelope's message, but the final `result` envelope does not.
73
+ if (!this.observedModel) {
74
+ if (typeof msg.model === "string") {
75
+ this.observedModel = msg.model;
76
+ }
77
+ else if (typeof msg.message?.model === "string") {
78
+ this.observedModel = msg.message.model;
79
+ }
80
+ }
59
81
  switch (msg.type) {
60
82
  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);
83
+ const blocks = msg.message?.content;
84
+ if (!Array.isArray(blocks))
85
+ break;
86
+ for (const block of blocks) {
87
+ if (block.type === "text" && typeof block.text === "string") {
88
+ this.emit("text", block.text);
89
+ this.hasEmittedText = true;
90
+ }
91
+ else if (block.type === "tool_use" &&
92
+ typeof block.name === "string") {
93
+ if (typeof block.id === "string") {
94
+ this.toolNames.set(block.id, block.name);
95
+ }
96
+ this.emit("tool_start", block.name, block.input);
97
+ }
98
+ // thinking blocks, redacted_thinking, etc. — ignored
66
99
  }
67
100
  break;
68
101
  }
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);
102
+ case "user": {
103
+ const blocks = msg.message?.content;
104
+ if (!Array.isArray(blocks))
105
+ break;
106
+ for (const block of blocks) {
107
+ if (block.type === "tool_result" &&
108
+ typeof block.tool_use_id === "string") {
109
+ const name = this.toolNames.get(block.tool_use_id) ?? "";
110
+ this.toolNames.delete(block.tool_use_id);
111
+ const content = normalizeToolResultContent(block.content);
112
+ this.emit("tool_end", name, block.tool_use_id, content);
113
+ }
72
114
  }
73
115
  break;
74
116
  }
75
117
  case "result": {
76
- this.emit("result", msg.stop_reason ?? "unknown");
77
- break;
78
- }
79
- case "cost_update": {
80
- if (msg.total_cost_usd != null) {
118
+ // Fallback: if the CLI ended without streaming any assistant text
119
+ // (e.g. hit max-turns), the final `result` field still carries the
120
+ // last assistant message. Emit it so the caller can parse the verdict.
121
+ if (!this.hasEmittedText &&
122
+ typeof msg.result === "string" &&
123
+ msg.result.length > 0) {
124
+ this.emit("text", msg.result);
125
+ this.hasEmittedText = true;
126
+ }
127
+ if (typeof msg.total_cost_usd === "number") {
128
+ const usage = msg.usage;
81
129
  this.emit("cost_update", {
82
130
  totalCostUsd: msg.total_cost_usd,
83
- totalInputTokens: msg.total_input_tokens ?? 0,
84
- totalOutputTokens: msg.total_output_tokens ?? 0,
131
+ totalInputTokens: usage?.input_tokens ?? 0,
132
+ totalOutputTokens: usage?.output_tokens ?? 0,
133
+ totalCacheCreationInputTokens: usage?.cache_creation_input_tokens ?? 0,
134
+ totalCacheReadInputTokens: usage?.cache_read_input_tokens ?? 0,
85
135
  durationMs: msg.duration_ms ?? 0,
86
136
  durationApiMs: msg.duration_api_ms ?? 0,
87
137
  numTurns: msg.num_turns ?? 0,
138
+ modelName: this.observedModel ??
139
+ (typeof msg.model === "string" ? msg.model : undefined),
88
140
  });
89
141
  }
142
+ this.emit("result", msg.stop_reason ?? msg.subtype ?? "unknown");
90
143
  break;
91
144
  }
92
145
  // Ignore system, ping, etc.
93
146
  }
94
147
  }
95
148
  }
149
+ function normalizeToolResultContent(raw) {
150
+ if (raw == null)
151
+ return undefined;
152
+ if (typeof raw === "string")
153
+ return raw;
154
+ if (Array.isArray(raw)) {
155
+ // Anthropic sometimes returns content as a list of typed blocks
156
+ // ({ type: "text", text: "..." }); flatten text blocks, fall back to JSON.
157
+ const parts = [];
158
+ for (const block of raw) {
159
+ if (block &&
160
+ typeof block === "object" &&
161
+ "text" in block &&
162
+ typeof block.text === "string") {
163
+ parts.push(block.text);
164
+ }
165
+ }
166
+ return parts.length > 0 ? parts.join("") : JSON.stringify(raw);
167
+ }
168
+ try {
169
+ return JSON.stringify(raw);
170
+ }
171
+ catch {
172
+ return String(raw);
173
+ }
174
+ }
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/dist/worktree.js CHANGED
@@ -15,32 +15,67 @@ export function createWorktree(basePath, baseBranch, branchName) {
15
15
  const worktreeDir = resolve(repoRoot, basePath, branchName);
16
16
  if (existsSync(worktreeDir)) {
17
17
  log.warn(TAG, `Worktree already exists at ${worktreeDir}, cleaning up`);
18
- cleanupWorktree(worktreeDir);
18
+ cleanupWorktree(worktreeDir, branchName);
19
19
  }
20
- // Fetch latest from remote to ensure base branch is up to date
20
+ // Prune stale worktree metadata. If a previous daemon crashed or its
21
+ // worktree dir was deleted externally, git may still think the branch is
22
+ // checked out, which blocks `git branch -D` and `git worktree add`.
21
23
  try {
22
- execFileSync("git", ["fetch", "origin", baseBranch], {
24
+ execFileSync("git", ["worktree", "prune"], {
23
25
  cwd: repoRoot,
24
26
  stdio: "pipe",
25
27
  });
26
28
  }
27
29
  catch {
28
- log.warn(TAG, "Failed to fetch latest — continuing with local state");
30
+ // non-fatal
29
31
  }
30
- // Delete stale branch if it exists from a previous run
32
+ // Fetch latest from remote to ensure base branch is up to date
31
33
  try {
32
- execFileSync("git", ["branch", "-D", branchName], {
34
+ execFileSync("git", ["fetch", "origin", baseBranch], {
33
35
  cwd: repoRoot,
34
36
  stdio: "pipe",
35
37
  });
36
- log.info(TAG, `Deleted stale branch: ${branchName}`);
37
38
  }
38
39
  catch {
39
- // Branch doesn't existthat's fine
40
+ log.warn(TAG, "Failed to fetch latest continuing with local state");
40
41
  }
41
- // Create worktree with a new branch based on origin/<baseBranch>
42
+ // Create worktree with a fresh branch based on origin/<baseBranch>.
43
+ // `-B` resets the branch if it already exists — agent branches are owned
44
+ // per-attempt, so starting fresh from origin is the desired behavior.
42
45
  log.info(TAG, `Creating worktree: ${worktreeDir} (branch: ${branchName})`);
43
- execFileSync("git", ["worktree", "add", "-b", branchName, worktreeDir, `origin/${baseBranch}`], { cwd: repoRoot, stdio: "pipe" });
46
+ try {
47
+ execFileSync("git", [
48
+ "worktree",
49
+ "add",
50
+ "-B",
51
+ branchName,
52
+ worktreeDir,
53
+ `origin/${baseBranch}`,
54
+ ], { cwd: repoRoot, stdio: "pipe" });
55
+ }
56
+ catch (err) {
57
+ // Last-resort recovery: if `-B` still fails (e.g. branch checked out in
58
+ // another registered worktree), force-delete the branch and retry.
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ log.warn(TAG, `worktree add failed, attempting forced recovery: ${msg}`);
61
+ try {
62
+ execFileSync("git", ["branch", "-D", branchName], {
63
+ cwd: repoRoot,
64
+ stdio: "pipe",
65
+ });
66
+ }
67
+ catch {
68
+ // ignore; retry will surface the real error
69
+ }
70
+ execFileSync("git", [
71
+ "worktree",
72
+ "add",
73
+ "-B",
74
+ branchName,
75
+ worktreeDir,
76
+ `origin/${baseBranch}`,
77
+ ], { cwd: repoRoot, stdio: "pipe" });
78
+ }
44
79
  // Install dependencies in the worktree
45
80
  log.info(TAG, "Installing dependencies in worktree...");
46
81
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/agent",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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",