@gethmy/agent 1.1.2 → 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__/process-group.test.js +27 -7
- package/dist/__tests__/stream-parser.test.js +4 -1
- package/dist/completion.d.ts +6 -0
- package/dist/completion.js +12 -2
- package/dist/progress-tracker.js +3 -0
- package/dist/stream-parser.d.ts +5 -0
- package/dist/stream-parser.js +17 -4
- package/dist/worktree.js +45 -10
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -104,11 +104,14 @@ describe("StreamParser", () => {
|
|
|
104
104
|
expect(events.costs).toEqual([
|
|
105
105
|
{
|
|
106
106
|
totalCostUsd: 0.5,
|
|
107
|
-
totalInputTokens:
|
|
107
|
+
totalInputTokens: 10,
|
|
108
108
|
totalOutputTokens: 20,
|
|
109
|
+
totalCacheCreationInputTokens: 5,
|
|
110
|
+
totalCacheReadInputTokens: 100,
|
|
109
111
|
durationMs: 1000,
|
|
110
112
|
durationApiMs: 900,
|
|
111
113
|
numTurns: 3,
|
|
114
|
+
modelName: undefined,
|
|
112
115
|
},
|
|
113
116
|
]);
|
|
114
117
|
expect(events.results).toEqual(["success"]);
|
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/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}`);
|
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];
|
|
@@ -21,6 +25,7 @@ export declare class StreamParser extends EventEmitter<StreamParserEvents> {
|
|
|
21
25
|
private attached;
|
|
22
26
|
private toolNames;
|
|
23
27
|
private hasEmittedText;
|
|
28
|
+
private observedModel?;
|
|
24
29
|
/**
|
|
25
30
|
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
26
31
|
* Parses NDJSON lines and emits typed events.
|
package/dist/stream-parser.js
CHANGED
|
@@ -6,6 +6,7 @@ export class StreamParser extends EventEmitter {
|
|
|
6
6
|
attached = false;
|
|
7
7
|
toolNames = new Map();
|
|
8
8
|
hasEmittedText = false;
|
|
9
|
+
observedModel;
|
|
9
10
|
/**
|
|
10
11
|
* Attach a readable stream (Claude CLI stdout) to the parser.
|
|
11
12
|
* Parses NDJSON lines and emits typed events.
|
|
@@ -66,6 +67,17 @@ export class StreamParser extends EventEmitter {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
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
|
+
}
|
|
69
81
|
switch (msg.type) {
|
|
70
82
|
case "assistant": {
|
|
71
83
|
const blocks = msg.message?.content;
|
|
@@ -114,16 +126,17 @@ export class StreamParser extends EventEmitter {
|
|
|
114
126
|
}
|
|
115
127
|
if (typeof msg.total_cost_usd === "number") {
|
|
116
128
|
const usage = msg.usage;
|
|
117
|
-
const totalInputTokens = (usage?.input_tokens ?? 0) +
|
|
118
|
-
(usage?.cache_creation_input_tokens ?? 0) +
|
|
119
|
-
(usage?.cache_read_input_tokens ?? 0);
|
|
120
129
|
this.emit("cost_update", {
|
|
121
130
|
totalCostUsd: msg.total_cost_usd,
|
|
122
|
-
totalInputTokens,
|
|
131
|
+
totalInputTokens: usage?.input_tokens ?? 0,
|
|
123
132
|
totalOutputTokens: usage?.output_tokens ?? 0,
|
|
133
|
+
totalCacheCreationInputTokens: usage?.cache_creation_input_tokens ?? 0,
|
|
134
|
+
totalCacheReadInputTokens: usage?.cache_read_input_tokens ?? 0,
|
|
124
135
|
durationMs: msg.duration_ms ?? 0,
|
|
125
136
|
durationApiMs: msg.duration_api_ms ?? 0,
|
|
126
137
|
numTurns: msg.num_turns ?? 0,
|
|
138
|
+
modelName: this.observedModel ??
|
|
139
|
+
(typeof msg.model === "string" ? msg.model : undefined),
|
|
127
140
|
});
|
|
128
141
|
}
|
|
129
142
|
this.emit("result", msg.stop_reason ?? msg.subtype ?? "unknown");
|
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 {
|