@gethmy/agent 1.0.6 → 1.0.8
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__/progress-tracker.test.d.ts +1 -0
- package/dist/__tests__/progress-tracker.test.js +246 -0
- package/dist/progress-tracker.d.ts +15 -2
- package/dist/progress-tracker.js +82 -13
- package/dist/watcher.d.ts +2 -0
- package/dist/watcher.js +25 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +13 -1
- package/package.json +5 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
// Dynamic import so we can test the module without triggering side effects
|
|
4
|
+
// from other imports (log, etc.)
|
|
5
|
+
let ProgressTracker;
|
|
6
|
+
// Minimal mock of HarmonyApiClient
|
|
7
|
+
function makeMockClient() {
|
|
8
|
+
return {
|
|
9
|
+
updateAgentProgress: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
flushActivityLog: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
startAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
endAgentSession: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// Mock StreamParser — just an EventEmitter with the same typed interface
|
|
16
|
+
class MockParser extends EventEmitter {
|
|
17
|
+
emitToolStart(name, input) {
|
|
18
|
+
this.emit("tool_start", name, input);
|
|
19
|
+
}
|
|
20
|
+
emitToolEnd(name, toolUseId, content) {
|
|
21
|
+
this.emit("tool_end", name, toolUseId, content);
|
|
22
|
+
}
|
|
23
|
+
emitText(content) {
|
|
24
|
+
this.emit("text", content);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Access private fields via type cast
|
|
28
|
+
function getPrivate(tracker) {
|
|
29
|
+
return tracker;
|
|
30
|
+
}
|
|
31
|
+
beforeEach(async () => {
|
|
32
|
+
// Reset module to get fresh class each test (avoids timer leakage between tests)
|
|
33
|
+
const mod = await import("../progress-tracker.js");
|
|
34
|
+
ProgressTracker = mod.ProgressTracker;
|
|
35
|
+
});
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
});
|
|
39
|
+
describe("ProgressTracker", () => {
|
|
40
|
+
describe("setSessionId", () => {
|
|
41
|
+
it("stores the session ID", () => {
|
|
42
|
+
const client = makeMockClient();
|
|
43
|
+
const tracker = new ProgressTracker(client, "card-1", 0, []);
|
|
44
|
+
tracker.stop(); // prevent heartbeat
|
|
45
|
+
expect(getPrivate(tracker).sessionId).toBeNull();
|
|
46
|
+
tracker.setSessionId("session-abc");
|
|
47
|
+
expect(getPrivate(tracker).sessionId).toBe("session-abc");
|
|
48
|
+
});
|
|
49
|
+
it("triggers flush with correct sessionId when sendUpdate is called", async () => {
|
|
50
|
+
const client = makeMockClient();
|
|
51
|
+
const tracker = new ProgressTracker(client, "card-1", 0, []);
|
|
52
|
+
tracker.stop();
|
|
53
|
+
tracker.setSessionId("session-xyz");
|
|
54
|
+
// Push an entry manually so flush has something to send
|
|
55
|
+
getPrivate(tracker).logBuffer.push({
|
|
56
|
+
phase: "exploring",
|
|
57
|
+
eventType: "tool_start",
|
|
58
|
+
toolName: "Read",
|
|
59
|
+
description: "Reading src/foo.ts",
|
|
60
|
+
metadata: {},
|
|
61
|
+
createdAt: new Date().toISOString(),
|
|
62
|
+
});
|
|
63
|
+
// Trigger sendUpdate by calling the private method indirectly
|
|
64
|
+
// (Force lastUpdateAt to 0 so throttle passes)
|
|
65
|
+
tracker.lastUpdateAt = 0;
|
|
66
|
+
tracker.sendUpdate("test task");
|
|
67
|
+
await vi.waitFor(() => {
|
|
68
|
+
expect(client.flushActivityLog).toHaveBeenCalledWith("card-1", expect.objectContaining({ sessionId: "session-xyz" }));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe("tool_start events", () => {
|
|
73
|
+
it("creates a log entry in the buffer when tool has a description", () => {
|
|
74
|
+
const client = makeMockClient();
|
|
75
|
+
const tracker = new ProgressTracker(client, "card-2", 0, []);
|
|
76
|
+
tracker.stop();
|
|
77
|
+
const parser = new MockParser();
|
|
78
|
+
tracker.attach(parser);
|
|
79
|
+
parser.emitToolStart("Read", { file_path: "/src/foo.ts" });
|
|
80
|
+
const buf = getPrivate(tracker).logBuffer;
|
|
81
|
+
const entry = buf.find((e) => e.eventType === "tool_start");
|
|
82
|
+
expect(entry).toBeDefined();
|
|
83
|
+
expect(entry?.toolName).toBe("Read");
|
|
84
|
+
expect(entry?.description).toMatch(/Reading/);
|
|
85
|
+
expect(entry?.createdAt).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
it("does not create a log entry for tools with no description", () => {
|
|
88
|
+
const client = makeMockClient();
|
|
89
|
+
const tracker = new ProgressTracker(client, "card-2", 0, []);
|
|
90
|
+
tracker.stop();
|
|
91
|
+
const parser = new MockParser();
|
|
92
|
+
tracker.attach(parser);
|
|
93
|
+
// Unknown tool with no special description logic
|
|
94
|
+
parser.emitToolStart("SomeObscureUnknownTool", {});
|
|
95
|
+
const buf = getPrivate(tracker).logBuffer;
|
|
96
|
+
const entry = buf.find((e) => e.eventType === "tool_start" && e.toolName === "SomeObscureUnknownTool");
|
|
97
|
+
expect(entry).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("tool_end events", () => {
|
|
101
|
+
it("creates a log entry for every tool_end event", () => {
|
|
102
|
+
const client = makeMockClient();
|
|
103
|
+
const tracker = new ProgressTracker(client, "card-3", 0, []);
|
|
104
|
+
tracker.stop();
|
|
105
|
+
const parser = new MockParser();
|
|
106
|
+
tracker.attach(parser);
|
|
107
|
+
parser.emitToolEnd("Bash", "tool-use-1", "exit 0");
|
|
108
|
+
const buf = getPrivate(tracker).logBuffer;
|
|
109
|
+
const entry = buf.find((e) => e.eventType === "tool_end" && e.toolName === "Bash");
|
|
110
|
+
expect(entry).toBeDefined();
|
|
111
|
+
expect(entry?.description).toContain("Completed");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe("buffer cap", () => {
|
|
115
|
+
it("caps buffer at 500 entries after pushing 510", () => {
|
|
116
|
+
const client = makeMockClient();
|
|
117
|
+
const tracker = new ProgressTracker(client, "card-4", 0, []);
|
|
118
|
+
tracker.stop();
|
|
119
|
+
const parser = new MockParser();
|
|
120
|
+
tracker.attach(parser);
|
|
121
|
+
// Emit 510 tool_end events (tool_end always creates an entry)
|
|
122
|
+
for (let i = 0; i < 510; i++) {
|
|
123
|
+
parser.emitToolEnd("Read", `id-${i}`, undefined);
|
|
124
|
+
}
|
|
125
|
+
const buf = getPrivate(tracker).logBuffer;
|
|
126
|
+
expect(buf.length).toBeLessThanOrEqual(500);
|
|
127
|
+
});
|
|
128
|
+
it("keeps the most recent entries when the buffer overflows", () => {
|
|
129
|
+
const client = makeMockClient();
|
|
130
|
+
const tracker = new ProgressTracker(client, "card-4b", 0, []);
|
|
131
|
+
tracker.stop();
|
|
132
|
+
const parser = new MockParser();
|
|
133
|
+
tracker.attach(parser);
|
|
134
|
+
for (let i = 0; i < 510; i++) {
|
|
135
|
+
parser.emitToolEnd(`Tool${i}`, `id-${i}`, undefined);
|
|
136
|
+
}
|
|
137
|
+
const buf = getPrivate(tracker).logBuffer;
|
|
138
|
+
// The oldest entries (0–9) should have been shifted out
|
|
139
|
+
expect(buf[0].toolName).not.toBe("Tool0");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe("flushActivityLog", () => {
|
|
143
|
+
it("clears the buffer on successful flush", async () => {
|
|
144
|
+
const client = makeMockClient();
|
|
145
|
+
const tracker = new ProgressTracker(client, "card-5", 0, []);
|
|
146
|
+
tracker.stop();
|
|
147
|
+
tracker.setSessionId("session-flush");
|
|
148
|
+
getPrivate(tracker).logBuffer.push({
|
|
149
|
+
phase: "exploring",
|
|
150
|
+
eventType: "tool_end",
|
|
151
|
+
toolName: "Read",
|
|
152
|
+
description: "Completed: Read",
|
|
153
|
+
metadata: {},
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
});
|
|
156
|
+
// Invoke the private method
|
|
157
|
+
tracker.flushActivityLog();
|
|
158
|
+
// Wait for the async client call to resolve
|
|
159
|
+
await vi.waitFor(() => {
|
|
160
|
+
expect(client.flushActivityLog).toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
// Buffer should be empty after successful flush
|
|
163
|
+
expect(getPrivate(tracker).logBuffer).toHaveLength(0);
|
|
164
|
+
});
|
|
165
|
+
it("retains buffer entries on flush error", async () => {
|
|
166
|
+
const client = makeMockClient();
|
|
167
|
+
client.flushActivityLog.mockRejectedValue(new Error("network error"));
|
|
168
|
+
const tracker = new ProgressTracker(client, "card-6", 0, []);
|
|
169
|
+
tracker.stop();
|
|
170
|
+
tracker.setSessionId("session-retry");
|
|
171
|
+
getPrivate(tracker).logBuffer.push({
|
|
172
|
+
phase: "exploring",
|
|
173
|
+
eventType: "tool_end",
|
|
174
|
+
toolName: "Read",
|
|
175
|
+
description: "Completed: Read",
|
|
176
|
+
metadata: {},
|
|
177
|
+
createdAt: new Date().toISOString(),
|
|
178
|
+
});
|
|
179
|
+
tracker.flushActivityLog();
|
|
180
|
+
await vi.waitFor(() => {
|
|
181
|
+
// After the rejected promise is caught, entries should be back in buffer
|
|
182
|
+
expect(getPrivate(tracker).logBuffer.length).toBeGreaterThan(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
it("skips flush when no session ID is set", () => {
|
|
186
|
+
const client = makeMockClient();
|
|
187
|
+
const tracker = new ProgressTracker(client, "card-7", 0, []);
|
|
188
|
+
tracker.stop();
|
|
189
|
+
// No setSessionId called
|
|
190
|
+
getPrivate(tracker).logBuffer.push({
|
|
191
|
+
phase: "exploring",
|
|
192
|
+
eventType: "tool_end",
|
|
193
|
+
toolName: "Read",
|
|
194
|
+
description: "Completed: Read",
|
|
195
|
+
metadata: {},
|
|
196
|
+
createdAt: new Date().toISOString(),
|
|
197
|
+
});
|
|
198
|
+
tracker.flushActivityLog();
|
|
199
|
+
// Should not have called the client
|
|
200
|
+
expect(client.flushActivityLog).not.toHaveBeenCalled();
|
|
201
|
+
// Buffer should still contain the entry
|
|
202
|
+
expect(getPrivate(tracker).logBuffer).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
it("skips flush when buffer is empty", () => {
|
|
205
|
+
const client = makeMockClient();
|
|
206
|
+
const tracker = new ProgressTracker(client, "card-8", 0, []);
|
|
207
|
+
tracker.stop();
|
|
208
|
+
tracker.setSessionId("session-empty");
|
|
209
|
+
// logBuffer is empty by default
|
|
210
|
+
tracker.flushActivityLog();
|
|
211
|
+
expect(client.flushActivityLog).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
describe("sendUpdate payload", () => {
|
|
215
|
+
it("does not include recentActions in the updateAgentProgress payload", async () => {
|
|
216
|
+
const client = makeMockClient();
|
|
217
|
+
const tracker = new ProgressTracker(client, "card-9", 0, []);
|
|
218
|
+
tracker.stop();
|
|
219
|
+
tracker.lastUpdateAt = 0;
|
|
220
|
+
tracker.sendUpdate("doing stuff");
|
|
221
|
+
await vi.waitFor(() => {
|
|
222
|
+
expect(client.updateAgentProgress).toHaveBeenCalled();
|
|
223
|
+
});
|
|
224
|
+
const [, payload] = client.updateAgentProgress.mock.calls[0];
|
|
225
|
+
expect(payload).not.toHaveProperty("recentActions");
|
|
226
|
+
});
|
|
227
|
+
it("includes expected fields in updateAgentProgress payload", async () => {
|
|
228
|
+
const client = makeMockClient();
|
|
229
|
+
const tracker = new ProgressTracker(client, "card-10", 0, []);
|
|
230
|
+
tracker.stop();
|
|
231
|
+
tracker.lastUpdateAt = 0;
|
|
232
|
+
tracker.sendUpdate("building feature");
|
|
233
|
+
await vi.waitFor(() => {
|
|
234
|
+
expect(client.updateAgentProgress).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
const [cardId, payload] = client.updateAgentProgress.mock.calls[0];
|
|
237
|
+
expect(cardId).toBe("card-10");
|
|
238
|
+
expect(payload).toMatchObject({
|
|
239
|
+
status: "working",
|
|
240
|
+
currentTask: expect.any(String),
|
|
241
|
+
progressPercent: expect.any(Number),
|
|
242
|
+
phase: expect.any(String),
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
|
|
2
2
|
import type { CostUpdate, StreamParser } from "./stream-parser.js";
|
|
3
|
+
export interface ActivityLogEntry {
|
|
4
|
+
phase: string | null;
|
|
5
|
+
eventType: "tool_start" | "tool_end" | "phase_change" | "error" | "summary";
|
|
6
|
+
toolName: string | null;
|
|
7
|
+
description: string;
|
|
8
|
+
metadata: Record<string, unknown>;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
}
|
|
3
11
|
export declare class ProgressTracker {
|
|
4
12
|
private client;
|
|
5
13
|
private cardId;
|
|
@@ -20,10 +28,12 @@ export declare class ProgressTracker {
|
|
|
20
28
|
private filesEdited;
|
|
21
29
|
private filesRead;
|
|
22
30
|
private lastCost;
|
|
23
|
-
private
|
|
31
|
+
private logBuffer;
|
|
32
|
+
private sessionId;
|
|
24
33
|
constructor(client: HarmonyApiClient, cardId: string, workerId: number, subtasks: {
|
|
25
34
|
completed: boolean;
|
|
26
35
|
}[]);
|
|
36
|
+
setSessionId(id: string): void;
|
|
27
37
|
/**
|
|
28
38
|
* Wire up the parser events and start the heartbeat.
|
|
29
39
|
*/
|
|
@@ -54,9 +64,12 @@ export declare class ProgressTracker {
|
|
|
54
64
|
*/
|
|
55
65
|
private shortPath;
|
|
56
66
|
private scheduleUpdate;
|
|
57
|
-
private pushRecentAction;
|
|
58
67
|
private sendUpdate;
|
|
59
68
|
private startHeartbeat;
|
|
69
|
+
flushFinal(): void;
|
|
70
|
+
private pushLogEntry;
|
|
71
|
+
private flushActivityLog;
|
|
72
|
+
private extractToolMetadata;
|
|
60
73
|
/**
|
|
61
74
|
* Safely extract a string property from an unknown tool input.
|
|
62
75
|
*/
|
package/dist/progress-tracker.js
CHANGED
|
@@ -4,7 +4,7 @@ const TAG = "progress-tracker";
|
|
|
4
4
|
const THROTTLE_MS = 5_000;
|
|
5
5
|
const HEARTBEAT_MS = 60_000;
|
|
6
6
|
const MAX_TASK_LENGTH = 120;
|
|
7
|
-
const
|
|
7
|
+
const MAX_LOG_BUFFER = 500;
|
|
8
8
|
// Hoisted regexes — avoids recompilation on every call
|
|
9
9
|
const SENTENCE_SPLIT = /\.\s|\n/;
|
|
10
10
|
const ACTION_PREFIX = /^(Let me|I'll|I need to|Now|First|Next|Looking|Checking|Creating|Adding|Updating|Fixing|Refactoring|Moving|The |This )/i;
|
|
@@ -60,7 +60,8 @@ export class ProgressTracker {
|
|
|
60
60
|
filesEdited = new Set();
|
|
61
61
|
filesRead = new Set();
|
|
62
62
|
lastCost = null;
|
|
63
|
-
|
|
63
|
+
logBuffer = [];
|
|
64
|
+
sessionId = null;
|
|
64
65
|
constructor(client, cardId, workerId, subtasks) {
|
|
65
66
|
this.client = client;
|
|
66
67
|
this.cardId = cardId;
|
|
@@ -69,15 +70,35 @@ export class ProgressTracker {
|
|
|
69
70
|
this.subtaskCompleted = subtasks.filter((s) => s.completed).length;
|
|
70
71
|
this.subtaskMode = subtasks.length > 0;
|
|
71
72
|
}
|
|
73
|
+
setSessionId(id) {
|
|
74
|
+
this.sessionId = id;
|
|
75
|
+
}
|
|
72
76
|
/**
|
|
73
77
|
* Wire up the parser events and start the heartbeat.
|
|
74
78
|
*/
|
|
75
79
|
attach(parser) {
|
|
76
80
|
parser.on("tool_start", (name, input) => {
|
|
77
81
|
this.onToolStart(name, input);
|
|
82
|
+
const desc = this.describeToolAction(name, input);
|
|
83
|
+
if (desc) {
|
|
84
|
+
this.pushLogEntry({
|
|
85
|
+
phase: this.phase,
|
|
86
|
+
eventType: "tool_start",
|
|
87
|
+
toolName: name,
|
|
88
|
+
description: desc,
|
|
89
|
+
metadata: this.extractToolMetadata(name, input),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
78
92
|
});
|
|
79
93
|
parser.on("tool_end", (name, _id, content) => {
|
|
80
94
|
this.onToolEnd(name, content);
|
|
95
|
+
this.pushLogEntry({
|
|
96
|
+
phase: this.phase,
|
|
97
|
+
eventType: "tool_end",
|
|
98
|
+
toolName: name,
|
|
99
|
+
description: `Completed: ${name}`,
|
|
100
|
+
metadata: {},
|
|
101
|
+
});
|
|
81
102
|
});
|
|
82
103
|
parser.on("text", (content) => {
|
|
83
104
|
this.onText(content);
|
|
@@ -159,7 +180,6 @@ export class ProgressTracker {
|
|
|
159
180
|
const action = this.describeToolAction(name, input);
|
|
160
181
|
if (action) {
|
|
161
182
|
this.lastAction = action;
|
|
162
|
-
this.pushRecentAction(action);
|
|
163
183
|
}
|
|
164
184
|
this.incrementProgress();
|
|
165
185
|
}
|
|
@@ -202,6 +222,13 @@ export class ProgressTracker {
|
|
|
202
222
|
this.progress = Math.max(this.progress, PHASES[newPhase].min);
|
|
203
223
|
// Reset stale action from prior phase; new phase starts with its own label
|
|
204
224
|
this.lastAction = "";
|
|
225
|
+
this.pushLogEntry({
|
|
226
|
+
phase: newPhase,
|
|
227
|
+
eventType: "phase_change",
|
|
228
|
+
toolName: null,
|
|
229
|
+
description: `Entering ${newPhase} phase`,
|
|
230
|
+
metadata: {},
|
|
231
|
+
});
|
|
205
232
|
this.scheduleUpdate(PHASES[newPhase].label);
|
|
206
233
|
}
|
|
207
234
|
incrementProgress() {
|
|
@@ -307,15 +334,6 @@ export class ProgressTracker {
|
|
|
307
334
|
}
|
|
308
335
|
// If there's already a pending update, pendingTask is now updated — it will use the fresh value
|
|
309
336
|
}
|
|
310
|
-
pushRecentAction(action) {
|
|
311
|
-
this.recentActions.push({
|
|
312
|
-
action,
|
|
313
|
-
ts: new Date().toISOString(),
|
|
314
|
-
});
|
|
315
|
-
if (this.recentActions.length > MAX_RECENT_ACTIONS) {
|
|
316
|
-
this.recentActions.shift();
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
337
|
sendUpdate(currentTask) {
|
|
320
338
|
this.lastUpdateAt = Date.now();
|
|
321
339
|
log.debug(TAG, `Progress: ${this.progress}% — ${currentTask}`);
|
|
@@ -329,11 +347,11 @@ export class ProgressTracker {
|
|
|
329
347
|
phase: this.phase,
|
|
330
348
|
filesChanged: this.filesEdited.size,
|
|
331
349
|
costCents: Math.round((this.lastCost?.totalCostUsd ?? 0) * 100),
|
|
332
|
-
recentActions: this.recentActions,
|
|
333
350
|
})
|
|
334
351
|
.catch((err) => {
|
|
335
352
|
log.warn(TAG, `Failed to send progress update: ${err}`);
|
|
336
353
|
});
|
|
354
|
+
this.flushActivityLog();
|
|
337
355
|
}
|
|
338
356
|
startHeartbeat() {
|
|
339
357
|
if (this.heartbeatTimer) {
|
|
@@ -349,6 +367,57 @@ export class ProgressTracker {
|
|
|
349
367
|
}
|
|
350
368
|
}, HEARTBEAT_MS);
|
|
351
369
|
}
|
|
370
|
+
flushFinal() {
|
|
371
|
+
this.flushActivityLog();
|
|
372
|
+
}
|
|
373
|
+
pushLogEntry(entry) {
|
|
374
|
+
this.logBuffer.push({
|
|
375
|
+
...entry,
|
|
376
|
+
createdAt: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
if (this.logBuffer.length > MAX_LOG_BUFFER) {
|
|
379
|
+
this.logBuffer.shift();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
flushActivityLog() {
|
|
383
|
+
if (!this.sessionId || this.logBuffer.length === 0)
|
|
384
|
+
return;
|
|
385
|
+
const raw = [...this.logBuffer];
|
|
386
|
+
this.logBuffer = [];
|
|
387
|
+
this.client
|
|
388
|
+
.flushActivityLog(this.cardId, {
|
|
389
|
+
sessionId: this.sessionId,
|
|
390
|
+
entries: raw.map((e) => ({
|
|
391
|
+
...e,
|
|
392
|
+
phase: e.phase ?? undefined,
|
|
393
|
+
toolName: e.toolName ?? undefined,
|
|
394
|
+
})),
|
|
395
|
+
})
|
|
396
|
+
.catch((err) => {
|
|
397
|
+
log.warn(TAG, `Failed to flush activity log: ${err}`);
|
|
398
|
+
// Put entries back at the front of the buffer for retry
|
|
399
|
+
this.logBuffer.unshift(...raw);
|
|
400
|
+
if (this.logBuffer.length > MAX_LOG_BUFFER) {
|
|
401
|
+
this.logBuffer.length = MAX_LOG_BUFFER;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
extractToolMetadata(name, input) {
|
|
406
|
+
const meta = {};
|
|
407
|
+
const fp = this.extractString(input, "file_path");
|
|
408
|
+
if (fp)
|
|
409
|
+
meta.file_path = fp;
|
|
410
|
+
const cmd = this.extractString(input, "command");
|
|
411
|
+
if (cmd)
|
|
412
|
+
meta.command = cmd.split("\n")[0].slice(0, 200);
|
|
413
|
+
const pattern = this.extractString(input, "pattern");
|
|
414
|
+
if (pattern)
|
|
415
|
+
meta.pattern = pattern;
|
|
416
|
+
const desc = this.extractString(input, "description");
|
|
417
|
+
if (desc)
|
|
418
|
+
meta.description = desc;
|
|
419
|
+
return meta;
|
|
420
|
+
}
|
|
352
421
|
/**
|
|
353
422
|
* Safely extract a string property from an unknown tool input.
|
|
354
423
|
*/
|
package/dist/watcher.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export declare class Watcher {
|
|
|
20
20
|
private onCardBroadcast;
|
|
21
21
|
private onAgentCommand?;
|
|
22
22
|
private channel;
|
|
23
|
+
private presenceChannel;
|
|
23
24
|
private supabase;
|
|
25
|
+
private daemonId;
|
|
24
26
|
constructor(credentials: RealtimeCredentials, projectId: string, onCardBroadcast: CardBroadcastHandler, onAgentCommand?: AgentCommandHandler | undefined);
|
|
25
27
|
start(): Promise<void>;
|
|
26
28
|
stop(): Promise<void>;
|
package/dist/watcher.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { createClient } from "@supabase/supabase-js";
|
|
2
3
|
import { log } from "./log.js";
|
|
3
4
|
const TAG = "watcher";
|
|
@@ -12,7 +13,9 @@ export class Watcher {
|
|
|
12
13
|
onCardBroadcast;
|
|
13
14
|
onAgentCommand;
|
|
14
15
|
channel = null;
|
|
16
|
+
presenceChannel = null;
|
|
15
17
|
supabase = null;
|
|
18
|
+
daemonId = randomUUID();
|
|
16
19
|
constructor(credentials, projectId, onCardBroadcast, onAgentCommand) {
|
|
17
20
|
this.credentials = credentials;
|
|
18
21
|
this.projectId = projectId;
|
|
@@ -22,6 +25,9 @@ export class Watcher {
|
|
|
22
25
|
async start() {
|
|
23
26
|
log.info(TAG, "Connecting to Supabase realtime (broadcast)...");
|
|
24
27
|
this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
|
|
28
|
+
// Presence channel — separate from the broadcast channel to avoid
|
|
29
|
+
// conflicting with frontend BoardContext's board-{projectId} subscription
|
|
30
|
+
const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
|
|
25
31
|
const channel = this.supabase
|
|
26
32
|
.channel(`board-${this.projectId}`)
|
|
27
33
|
.on("broadcast", { event: "card_update" }, (msg) => {
|
|
@@ -59,8 +65,27 @@ export class Watcher {
|
|
|
59
65
|
}
|
|
60
66
|
});
|
|
61
67
|
this.channel = channel;
|
|
68
|
+
// Subscribe presence channel for daemon online indicator
|
|
69
|
+
presenceChannel
|
|
70
|
+
.on("presence", { event: "sync" }, () => {
|
|
71
|
+
log.debug(TAG, "Presence sync");
|
|
72
|
+
})
|
|
73
|
+
.subscribe(async (status) => {
|
|
74
|
+
if (status === "SUBSCRIBED") {
|
|
75
|
+
await presenceChannel.track({
|
|
76
|
+
daemonId: this.daemonId,
|
|
77
|
+
startedAt: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
log.info(TAG, "Presence tracked on board-presence channel");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
this.presenceChannel = presenceChannel;
|
|
62
83
|
}
|
|
63
84
|
async stop() {
|
|
85
|
+
if (this.presenceChannel) {
|
|
86
|
+
await this.supabase?.removeChannel(this.presenceChannel);
|
|
87
|
+
this.presenceChannel = null;
|
|
88
|
+
}
|
|
64
89
|
if (this.channel) {
|
|
65
90
|
await this.supabase?.removeChannel(this.channel);
|
|
66
91
|
this.channel = null;
|
package/dist/worker.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export declare class Worker {
|
|
|
18
18
|
private progressTracker;
|
|
19
19
|
private lastSessionStats;
|
|
20
20
|
private aborted;
|
|
21
|
+
private sessionId;
|
|
21
22
|
constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: Worker) => void, workspaceId: string, projectId: string);
|
|
22
23
|
get tag(): string;
|
|
23
24
|
get isIdle(): boolean;
|
package/dist/worker.js
CHANGED
|
@@ -27,6 +27,7 @@ export class Worker {
|
|
|
27
27
|
progressTracker = null;
|
|
28
28
|
lastSessionStats;
|
|
29
29
|
aborted = false;
|
|
30
|
+
sessionId = null;
|
|
30
31
|
constructor(id, config, client, _userEmail, onDone, workspaceId, projectId) {
|
|
31
32
|
this.config = config;
|
|
32
33
|
this.client = client;
|
|
@@ -61,13 +62,20 @@ export class Worker {
|
|
|
61
62
|
this.branchName = makeBranchName(card.short_id, card.title);
|
|
62
63
|
log.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
|
|
63
64
|
// Start agent session and make it visible on the board
|
|
64
|
-
await this.client.startAgentSession(card.id, {
|
|
65
|
+
const { session } = await this.client.startAgentSession(card.id, {
|
|
65
66
|
agentIdentifier: agentIdentifier(this.id),
|
|
66
67
|
agentName: AGENT_NAME,
|
|
67
68
|
status: "working",
|
|
68
69
|
currentTask: "Setting up worktree",
|
|
69
70
|
progressPercent: 5,
|
|
70
71
|
});
|
|
72
|
+
const sid = session && typeof session === "object" && "id" in session
|
|
73
|
+
? session.id
|
|
74
|
+
: null;
|
|
75
|
+
if (!sid) {
|
|
76
|
+
log.warn(TAG, "startAgentSession returned no session id");
|
|
77
|
+
}
|
|
78
|
+
this.sessionId = sid;
|
|
71
79
|
// Move card to "In Progress" and add "agent" label so the board shows the progress ring
|
|
72
80
|
const moved = await moveCardAndAddLabel(this.client, card, "In Progress", "agent");
|
|
73
81
|
if (!moved) {
|
|
@@ -256,6 +264,9 @@ export class Worker {
|
|
|
256
264
|
const parser = new StreamParser();
|
|
257
265
|
// Progress tracker for phase-based updates
|
|
258
266
|
this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
|
|
267
|
+
if (this.sessionId) {
|
|
268
|
+
this.progressTracker.setSessionId(this.sessionId);
|
|
269
|
+
}
|
|
259
270
|
this.progressTracker.attach(parser);
|
|
260
271
|
// Attach stdout to parser
|
|
261
272
|
if (this.process.stdout) {
|
|
@@ -274,6 +285,7 @@ export class Worker {
|
|
|
274
285
|
this.process.on("close", (code) => {
|
|
275
286
|
this.process = null;
|
|
276
287
|
this.lastSessionStats = this.progressTracker?.stats;
|
|
288
|
+
this.progressTracker?.flushFinal();
|
|
277
289
|
this.progressTracker?.stop();
|
|
278
290
|
this.progressTracker = null;
|
|
279
291
|
if (this.aborted) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
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",
|
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
"prebuild": "cd ../harmony-shared && bun run build && cd ../memory && bun run build",
|
|
41
41
|
"build": "tsc",
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
|
43
|
-
"prepublishOnly": "npm run build"
|
|
43
|
+
"prepublishOnly": "npm run build",
|
|
44
|
+
"test": "vitest run"
|
|
44
45
|
},
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@supabase/supabase-js": "2.95.3",
|
|
@@ -49,6 +50,7 @@
|
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@harmony/shared": "workspace:*",
|
|
51
52
|
"@types/node": "^25.5.0",
|
|
52
|
-
"typescript": "^6.0.1"
|
|
53
|
+
"typescript": "^6.0.1",
|
|
54
|
+
"vitest": "^3.2.1"
|
|
53
55
|
}
|
|
54
56
|
}
|