@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.
@@ -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
  });
@@ -104,11 +104,14 @@ describe("StreamParser", () => {
104
104
  expect(events.costs).toEqual([
105
105
  {
106
106
  totalCostUsd: 0.5,
107
- totalInputTokens: 115,
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"]);
@@ -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
  }
@@ -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}`);
@@ -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.
@@ -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
- // 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.2",
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",