@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.
- package/dist/__tests__/merge-monitor.test.d.ts +1 -0
- package/dist/__tests__/merge-monitor.test.js +107 -0
- package/dist/__tests__/process-group.test.js +27 -7
- package/dist/__tests__/stream-parser-selftest.test.d.ts +1 -0
- package/dist/__tests__/stream-parser-selftest.test.js +23 -0
- package/dist/__tests__/stream-parser.test.d.ts +1 -0
- package/dist/__tests__/stream-parser.test.js +199 -0
- package/dist/completion.d.ts +6 -0
- package/dist/completion.js +12 -2
- package/dist/index.js +15 -2
- package/dist/merge-monitor.js +12 -9
- package/dist/progress-tracker.js +3 -0
- package/dist/review-completion.d.ts +1 -1
- package/dist/review-completion.js +35 -2
- package/dist/review-worker.d.ts +1 -0
- package/dist/review-worker.js +3 -1
- package/dist/stream-parser-selftest.d.ts +9 -0
- package/dist/stream-parser-selftest.js +97 -0
- package/dist/stream-parser.d.ts +12 -0
- package/dist/stream-parser.js +94 -15
- package/dist/watcher.d.ts +8 -1
- package/dist/watcher.js +7 -1
- package/dist/worktree.js +45 -10
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|
package/dist/completion.d.ts
CHANGED
|
@@ -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.
|
package/dist/completion.js
CHANGED
|
@@ -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
|
|
144
|
-
sessionStats.cost.
|
|
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,
|
|
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,
|
|
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);
|
package/dist/merge-monitor.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
if (!alreadyStamped) {
|
|
143
|
+
if (!alreadyStamped) {
|
|
144
|
+
try {
|
|
143
145
|
const timestamp = new Date().toISOString();
|
|
144
146
|
const separator = existing ? "\n" : "";
|
|
145
|
-
|
|
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);
|
package/dist/progress-tracker.js
CHANGED
|
@@ -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 —
|
|
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 });
|
package/dist/review-worker.d.ts
CHANGED
|
@@ -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;
|
package/dist/review-worker.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/stream-parser.d.ts
CHANGED
|
@@ -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;
|
package/dist/stream-parser.js
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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 "
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
84
|
-
totalOutputTokens:
|
|
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
|
-
//
|
|
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", ["
|
|
24
|
+
execFileSync("git", ["worktree", "prune"], {
|
|
23
25
|
cwd: repoRoot,
|
|
24
26
|
stdio: "pipe",
|
|
25
27
|
});
|
|
26
28
|
}
|
|
27
29
|
catch {
|
|
28
|
-
|
|
30
|
+
// non-fatal
|
|
29
31
|
}
|
|
30
|
-
//
|
|
32
|
+
// Fetch latest from remote to ensure base branch is up to date
|
|
31
33
|
try {
|
|
32
|
-
execFileSync("git", ["
|
|
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
|
-
|
|
40
|
+
log.warn(TAG, "Failed to fetch latest — continuing with local state");
|
|
40
41
|
}
|
|
41
|
-
// Create worktree with a
|
|
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
|
-
|
|
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 {
|