@promptctl/cc-candybar 1.17.3 → 1.17.5

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.
@@ -12,18 +12,25 @@ import process from "node:process";
12
12
  // their sockets were stolen, minting immortal orphans (see
13
13
  // brandon-daemon-lifecycle-2b3).
14
14
  //
15
- // [FRAMING:representation] The lease records the owner's pid; liveness is
16
- // kill(pid, 0) the kernel's process table, which is load-independent truth
17
- // about process existence. A connect sample is load-dependent hearsay and must
18
- // never be the authority that destroys another daemon's socket.
15
+ // [FRAMING:representation] The lease records the owner's pid AND its kernel
16
+ // start-time (see process-fingerprint.ts). Liveness is "the SAME process is
17
+ // still alive" kill(pid,0) alone lies when a crashed daemon's pid is recycled
18
+ // to an unrelated live process (reads `alive` forever → no daemon ever comes up,
19
+ // brandon-daemon-lifecycle-2b3.4 RESIDUAL 1). Comparing the start-time makes a
20
+ // recycled pid provably a different process. Both signals are the kernel's
21
+ // load-independent truth about process identity; a connect sample is
22
+ // load-dependent hearsay and must never be the authority that destroys another
23
+ // daemon's socket.
19
24
 
20
25
  // What a lease read yielded. Distinguishing absent / unreadable / owned keeps
21
26
  // the arbitration decision a full enumeration over raw inputs (see
22
- // arbitrateSocket) rather than collapsing the ambiguous cases early.
27
+ // arbitrateSocket) rather than collapsing the ambiguous cases early. `startTime`
28
+ // is the owner's kernel start-time fingerprint, or null when the writing host
29
+ // could not fingerprint (no `ps`) — the reader then falls back to kill(pid,0).
23
30
  export type LeaseRead =
24
31
  | { kind: "absent" }
25
32
  | { kind: "unreadable"; detail: string }
26
- | { kind: "owned"; pid: number };
33
+ | { kind: "owned"; pid: number; startTime: string | null };
27
34
 
28
35
  // The EADDRINUSE arbitration outcome. The path already exists (something bound
29
36
  // it or a stale file remains); this says whether a LIVE owner holds it.
@@ -31,56 +38,61 @@ export type SocketArbitration =
31
38
  | { kind: "attach-and-exit"; reason: string }
32
39
  | { kind: "reclaim"; reason: string };
33
40
 
34
- // Diagnostic-rich lease payload. `pid` is the authority (liveness); the rest is
35
- // operator diagnostics carried in the same file so the lease subsumes the old
36
- // diagnostic pidfile — one file, one identity root.
41
+ // Diagnostic-rich lease payload. `(pid, startTime)` is the authority (process
42
+ // identity liveness); the rest is operator diagnostics carried in the same
43
+ // file so the lease subsumes the old diagnostic pidfile — one file, one identity
44
+ // root. `startTime` is the kernel start-time token (also human-readable, so it
45
+ // doubles as the "daemon started at" diagnostic the old `startedAt` gave), or
46
+ // null when this host could not fingerprint.
37
47
  export interface LeaseRecord {
38
48
  pid: number;
39
49
  version: number;
40
50
  binPath: string | undefined;
41
- startedAt: string;
51
+ startTime: string | null;
42
52
  }
43
53
 
44
54
  // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] The whole
45
55
  // arbitration is a pure fold: (raw lease read, injected liveness predicate) →
46
- // decision. kill(2) is the sole effect and it is injected, so every branch is
47
- // exercised by input enumeration with no real processes. Full input space:
48
- // owned + alive → attach-and-exit (a live owner holds the path; we are a
49
- // duplicate exit so the incumbent keeps serving)
50
- // owned + dead → reclaim (owner crashed; the socket is stale)
51
- // absent → reclaim (no lease to consult a pre-lease or crashed
52
- // daemon left a stale socket; prefer availability)
53
- // unreadable → reclaim (can't prove a live owner — same)
56
+ // decision. Reading process identity is the sole effect and it is injected via
57
+ // `isSameLiveProcess`, so every branch is exercised by input enumeration with no
58
+ // real processes. Full input space:
59
+ // owned + same-live → attach-and-exit (the SAME live owner holds the path; we
60
+ // are a duplicate exit so the incumbent keeps serving)
61
+ // owned + not-same → reclaim (owner crashed, OR its pid was recycled to an
62
+ // unrelated process either way the socket is stale)
63
+ // absent → reclaim (no lease to consult — a pre-lease or crashed
64
+ // daemon left a stale socket; prefer availability)
65
+ // unreadable → reclaim (can't prove a live owner — same)
66
+ //
67
+ // [LAW:one-source-of-truth] `isSameLiveProcess(pid, startTime)` consults the
68
+ // kernel's process identity ((pid, start-time), see process-fingerprint.ts), not
69
+ // a bare pid. This closes RESIDUAL 1: a crashed daemon's pid recycled to a
70
+ // long-lived process now reads NOT-same (different start-time) → reclaim, so a
71
+ // daemon comes up instead of every start attaching to a ghost forever.
54
72
  //
55
73
  // Failure directions:
56
74
  // false-dead (steal a live socket) — made self-healing by the ownership
57
- // self-check (brandon-daemon-lifecycle-2b3.2).
58
- // false-alive (the lease pid is alive but is NOT this daemon — a crashed
59
- // daemon's pid recycled to an unrelated process) we attach-and-exit
60
- // without serving. Short-lived recycle self-corrects next tick; a LONG-LIVED
61
- // recycle reads alive on every start, so no daemon comes up until the stale
62
- // lease is cleared. Fully closing this needs a pid-identity fingerprint
63
- // (process start-time, or an fd/flock held for the daemon's lifetime) — the
64
- // lock-based liveness the epic deferred. connect() can't substitute: it
65
- // can't tell a busy-live daemon with a full accept backlog (ECONNREFUSED —
66
- // the exact storm this replaces) from a recycled non-daemon pid. It is
67
- // strictly rarer than that storm (needs an unclean death skipping lease
68
- // cleanup AND a pid recycle to a long-lived process within the respawn
69
- // window).
75
+ // self-check (brandon-daemon-lifecycle-2b3.2), and now far rarer: the
76
+ // start-time match will not false-negative a live owner unless the host
77
+ // cannot fingerprint at all, in which case sameLiveProcess falls back to
78
+ // kill(pid,0) the prior behavior, no worse.
70
79
  // Reclaiming on missing/unreadable is the availability-preferring direction — a
71
80
  // stale socket with no reclaimer is a hard no-service stall, strictly worse than
72
81
  // a transient double-serve that self-heals.
73
82
  export function arbitrateSocket(
74
83
  read: LeaseRead,
75
- isAlive: (pid: number) => boolean,
84
+ isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
76
85
  ): SocketArbitration {
77
86
  if (read.kind === "owned") {
78
- return isAlive(read.pid)
87
+ return isSameLiveProcess(read.pid, read.startTime)
79
88
  ? {
80
89
  kind: "attach-and-exit",
81
90
  reason: `live owner pid=${read.pid} holds the socket`,
82
91
  }
83
- : { kind: "reclaim", reason: `owner pid=${read.pid} is gone (ESRCH)` };
92
+ : {
93
+ kind: "reclaim",
94
+ reason: `owner pid=${read.pid} is gone or recycled`,
95
+ };
84
96
  }
85
97
  if (read.kind === "absent") {
86
98
  return {
@@ -115,14 +127,30 @@ export function readLease(leasePath: string): LeaseRead {
115
127
  } catch (e) {
116
128
  return { kind: "unreadable", detail: `bad JSON: ${(e as Error).message}` };
117
129
  }
118
- const pid = (parsed as { pid?: unknown } | null)?.pid;
130
+ const record = parsed as { pid?: unknown; startTime?: unknown } | null;
131
+ const pid = record?.pid;
119
132
  if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) {
120
133
  return {
121
134
  kind: "unreadable",
122
135
  detail: `no valid pid (got ${JSON.stringify(pid)})`,
123
136
  };
124
137
  }
125
- return { kind: "owned", pid };
138
+ // startTime is the process fingerprint. A present value must be a string;
139
+ // absent/null means "unfingerprinted" (an older lease, or a host without
140
+ // `ps`) and the reader falls back to kill(pid,0). A present-but-non-string is
141
+ // malformed → unreadable (never silently coerce a lie into a fingerprint).
142
+ const rawStartTime = record?.startTime;
143
+ if (
144
+ rawStartTime !== undefined &&
145
+ rawStartTime !== null &&
146
+ typeof rawStartTime !== "string"
147
+ ) {
148
+ return {
149
+ kind: "unreadable",
150
+ detail: `invalid startTime (got ${JSON.stringify(rawStartTime)})`,
151
+ };
152
+ }
153
+ return { kind: "owned", pid, startTime: rawStartTime ?? null };
126
154
  }
127
155
 
128
156
  // Write our lease after we win the bind. Overwrite-on-write: whoever holds the
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { type DaemonLogger } from "./log";
3
+ import { type LeaseRead } from "./socket-lease";
3
4
 
4
5
  // ─── Socket ownership self-check ─────────────────────────────────────────────
5
6
  //
@@ -21,6 +22,21 @@ import { type DaemonLogger } from "./log";
21
22
  // I bound" is the exact, load-independent test for continued ownership — unlike
22
23
  // a connect probe, which the sibling .1 removed precisely because it lied under
23
24
  // load.
25
+ //
26
+ // [FRAMING:representation] The inode alone leaves ONE hole (RESIDUAL 2, the
27
+ // capture race): if a thief completes unlink + rebind inside the sub-millisecond
28
+ // window between our bind() and our 'listening' callback, we stat the path and
29
+ // capture the THIEF's inode as "ours" — so the inode check reads `owned` forever
30
+ // against the wrong identity. We cannot make that captured inode trustworthy (an
31
+ // AF_UNIX listener's fstat reports a different namespace's inode than stat(path),
32
+ // so there is no race-free "the inode I truly bound"). But a real thief, being a
33
+ // daemon, writes its OWN pid into the lease. So ownership requires a SECOND
34
+ // condition: the lease must still name us. A displacer must touch one of the two
35
+ // representations — steal the socket (inode changes) or overwrite the lease (pid
36
+ // changes) — so requiring BOTH drains any orphan within a bounded number of
37
+ // intervals. No live process can share our live pid, so the lease pid alone
38
+ // distinguishes "the lease still names me" (the start-time fingerprint is only
39
+ // needed by the sibling arbitration, which reasons about a possibly-DEAD pid).
24
40
 
25
41
  // The kernel identity of the bound socket file. (dev, ino) is the unique
26
42
  // identity of a filesystem entry; ino alone can be reused across devices.
@@ -64,43 +80,64 @@ export function readSocketIdentity(sockPath: string): IdentityRead {
64
80
  }
65
81
 
66
82
  // [LAW:effects-at-boundaries][LAW:dataflow-not-control-flow] A pure fold:
67
- // (identity captured at bind, current identity read) decision. No effects, so
68
- // every branch is exercised by input enumeration. Full input space:
69
- // present + same identity owned (we still hold the path we bound)
70
- // present + different → displaced (a reclaimer unlinked + rebound; new inode)
71
- // absent (ENOENT) → displaced (path unlinked, reclaimer not yet rebound)
72
- // unreadable → displaced (cannot PROVE ownershipfail toward exit)
83
+ // (identity captured at bind, current identity read, my pid, current lease read)
84
+ // → decision. No effects, so every branch is exercised by input enumeration.
85
+ // Ownership is the CONJUNCTION of two conditions; either failing displaced:
86
+ // inode: present + same identity as bound (we still hold the path we bound)
87
+ // lease: owned + pid == mine (the lease still names us)
88
+ // Inode failures: different inode / absent / unreadable a reclaimer displaced
89
+ // us (or we cannot prove otherwise). Lease failures: a thief overwrote the
90
+ // lease with its own pid, or the lease vanished/broke — either way we no
91
+ // longer hold the authority.
73
92
  //
74
- // [FRAMING:representation] Only `present + same identity` proves ownership;
75
- // everything else is `displaced`. This is the strongest-true theorem: serving
76
- // must IMPLY owning, so anything short of positive proof drains and exits. The
77
- // failure direction is deliberately safe a false `displaced` (exit while
78
- // actually owning, e.g. a transient stat error) just lets the real owner/thief
79
- // serve and the next client respawns; a false `owned` is the immortal orphan we
80
- // are killing. Always err toward exit.
93
+ // [FRAMING:representation] Only "still holding my bound socket AND still named by
94
+ // the lease" proves ownership; anything short is `displaced`. This is the
95
+ // strongest-true theorem for "serving implies owning", checked against BOTH
96
+ // mutable representations a displacer can touch. The failure direction is
97
+ // deliberately safe a false `displaced` (exit while actually owning) just lets
98
+ // the real owner serve and the next client respawns; a false `owned` is the
99
+ // immortal orphan we are killing. Always err toward exit.
81
100
  export function checkOwnership(
82
101
  bound: SocketIdentity,
83
102
  now: IdentityRead,
103
+ myPid: number,
104
+ lease: LeaseRead,
84
105
  ): OwnershipCheck {
85
- if (now.kind === "present") {
86
- if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
87
- return { kind: "owned" };
88
- }
106
+ const inodeFailure = inodeDisplacement(bound, now);
107
+ if (inodeFailure !== null) {
108
+ return { kind: "displaced", reason: inodeFailure };
109
+ }
110
+ if (lease.kind !== "owned") {
89
111
  return {
90
112
  kind: "displaced",
91
- reason: `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`,
113
+ reason: `lease not held (${lease.kind}) no longer the socket owner`,
92
114
  };
93
115
  }
94
- if (now.kind === "absent") {
116
+ if (lease.pid !== myPid) {
95
117
  return {
96
118
  kind: "displaced",
97
- reason: "socket path gone (ENOENT) — unlinked by a reclaimer",
119
+ reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
98
120
  };
99
121
  }
100
- return {
101
- kind: "displaced",
102
- reason: `socket path unreadable (${now.detail}) — cannot prove ownership`,
103
- };
122
+ return { kind: "owned" };
123
+ }
124
+
125
+ // Returns the displacement reason if the current path identity no longer matches
126
+ // what we bound, or null if the inode still proves ownership.
127
+ function inodeDisplacement(
128
+ bound: SocketIdentity,
129
+ now: IdentityRead,
130
+ ): string | null {
131
+ if (now.kind === "present") {
132
+ if (now.identity.dev === bound.dev && now.identity.ino === bound.ino) {
133
+ return null;
134
+ }
135
+ return `socket replaced (bound dev=${bound.dev} ino=${bound.ino}, now dev=${now.identity.dev} ino=${now.identity.ino})`;
136
+ }
137
+ if (now.kind === "absent") {
138
+ return "socket path gone (ENOENT) — unlinked by a reclaimer";
139
+ }
140
+ return `socket path unreadable (${now.detail}) — cannot prove ownership`;
104
141
  }
105
142
 
106
143
  // [LAW:locality-or-seam] The identity read, the shutdown funnel, and the log
@@ -110,7 +147,9 @@ export function checkOwnership(
110
147
  // `() => readSocketIdentity(sockPath)` and the daemon's single shutdown().
111
148
  export interface OwnershipWatchDeps {
112
149
  bound: SocketIdentity;
150
+ myPid: number;
113
151
  readIdentity: () => IdentityRead;
152
+ readLease: () => LeaseRead;
114
153
  shutdown: (code: number) => void;
115
154
  log: DaemonLogger;
116
155
  intervalMs?: number;
@@ -132,7 +171,12 @@ export function makeOwnershipWatch(
132
171
  let displaced = false;
133
172
 
134
173
  function check(): OwnershipCheck {
135
- const decision = checkOwnership(deps.bound, deps.readIdentity());
174
+ const decision = checkOwnership(
175
+ deps.bound,
176
+ deps.readIdentity(),
177
+ deps.myPid,
178
+ deps.readLease(),
179
+ );
136
180
  if (decision.kind === "displaced" && !displaced) {
137
181
  displaced = true;
138
182
  deps.log(
@@ -45,6 +45,10 @@ export const LAUNCH_CATEGORIES = [
45
45
  "install.pbcopy",
46
46
  "install.open",
47
47
  "daemon-spawn",
48
+ // Process start-time fingerprint (`ps -o lstart=`) for socket-lease liveness
49
+ // (process-fingerprint.ts). Spawned only at daemon start + EADDRINUSE
50
+ // arbitration — never per render — so it needs no rate limit.
51
+ "process-fingerprint",
48
52
  ] as const;
49
53
 
50
54
  export type LaunchCategory = (typeof LAUNCH_CATEGORIES)[number];
@@ -1,10 +1,14 @@
1
1
  import type { ClaudeHookData, ParsedEntry } from "../utils/claude";
2
2
 
3
- // [LAW:one-source-of-truth] Metrics reads the transcript through the shared
4
- // mtime-keyed parse LRU the same parse the session/context segments already
5
- // performed this render instead of a private readFile+parse. One parse path,
6
- // one cache; the multi-MB content arrays are dropped at parse time.
7
- import { parseJsonlFile } from "../utils/claude";
3
+ // [LAW:one-source-of-truth] Metrics reads the transcript through the SAME
4
+ // incremental append reader the usage store uses, folding only the bytes added
5
+ // since last render into a running message count + a bounded recent-entry ring —
6
+ // never a whole-file re-parse. Before this, metrics re-parsed the entire growing
7
+ // transcript every render (free only while it piggybacked the store's cache hit);
8
+ // once the store went incremental, a full re-parse here would have re-inherited
9
+ // the very stall the store fix removed. Now both consumers are O(new bytes).
10
+ import { readAppendedEntries, type TranscriptCursor } from "../utils/claude";
11
+ import { statMtimeMs } from "../utils/transcript-fs";
8
12
  import { debug } from "../utils/logger";
9
13
  import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
10
14
 
@@ -21,6 +25,25 @@ export interface MetricsInfo {
21
25
  linesRemoved: number;
22
26
  }
23
27
 
28
+ // lastResponseTime only inspects the most recent turns for a user→assistant
29
+ // pair; a bounded tail of non-sidechain entries is all it needs. The ring caps
30
+ // per-session retained memory at O(1) regardless of transcript length.
31
+ const RECENT_WINDOW = 20;
32
+ // Bound the per-session fold map like the usage store's records — a long-lived
33
+ // daemon sees many sessions; the ring is tiny, but the map must not grow without
34
+ // limit. LRU by insertion order (delete+set on write), evict oldest past the cap.
35
+ const MAX_SESSIONS = 256;
36
+
37
+ // [LAW:one-source-of-truth] The incremental fold of one session's metrics: the
38
+ // byte cursor already consumed, the running real-user message count, and the
39
+ // recent-entry ring. Canonical; MetricsInfo's transcript-derived fields derive
40
+ // from it, the hookData-derived fields (durations, lines) are always fresh.
41
+ interface MetricsState {
42
+ cursor: TranscriptCursor;
43
+ messageCount: number;
44
+ recent: ParsedEntry[];
45
+ }
46
+
24
47
  // A real user turn vs. a tool_result echoed back as a "user" line. The
25
48
  // discriminator is metrics-local policy over the shared ParsedEntry scalars.
26
49
  function isRealUserMessage(entry: ParsedEntry): boolean {
@@ -31,19 +54,17 @@ function isRealUserMessage(entry: ParsedEntry): boolean {
31
54
  }
32
55
 
33
56
  export class MetricsProvider {
34
- private calculateMessageCount(entries: ParsedEntry[]): number {
35
- return entries.filter(isRealUserMessage).length;
36
- }
57
+ // [LAW:no-shared-mutable-globals] Single owner, hard cap, LRU eviction — the
58
+ // per-session incremental fold state. One instance per daemon.
59
+ private readonly state = new Map<string, MetricsState>();
37
60
 
38
61
  private calculateLastResponseTime(entries: ParsedEntry[]): number | null {
39
62
  if (entries.length === 0) return null;
40
63
 
41
- const recentEntries = entries.slice(-20);
42
-
43
64
  let lastUserTime: Date | null = null;
44
65
  let bestResponseTime: number | null = null;
45
66
 
46
- for (const entry of recentEntries) {
67
+ for (const entry of entries) {
47
68
  const messageType =
48
69
  entry.type || entry.message?.role || entry.message?.type;
49
70
 
@@ -61,6 +82,80 @@ export class MetricsProvider {
61
82
  return bestResponseTime;
62
83
  }
63
84
 
85
+ // [LAW:dataflow-not-control-flow] Fold the appended entries onto the prior
86
+ // state, producing a FRESH state (prior is never mutated) so two concurrent
87
+ // renders of one session both fold from the same prior and last-writer-wins is
88
+ // a correct answer, never a double-count. `reset` (a /compact rewrite) folds
89
+ // from empty. Returns the transcript-derived fields the caller composes with
90
+ // the always-fresh hookData fields.
91
+ private async foldMetrics(
92
+ sessionId: string,
93
+ transcriptPath: string,
94
+ ): Promise<
95
+ // [LAW:types-are-the-program] Never `absent` — a missing transcript is a real
96
+ // zero-count fold (ok), so the caller has exactly two arms to handle.
97
+ | { kind: "ok"; value: { messageCount: number; recent: ParsedEntry[] } }
98
+ | { kind: "failed"; reason: string }
99
+ > {
100
+ const prior = this.state.get(sessionId);
101
+ const mtime = statMtimeMs(transcriptPath);
102
+ // [LAW:no-ambient-temporal-coupling] Fast hit: the transcript is unchanged
103
+ // since we last folded it, so the message count + ring stand.
104
+ if (prior && mtime !== 0 && prior.cursor.mtimeMs === mtime) {
105
+ // [LAW:no-ambient-temporal-coupling] Re-order on the hit so LRU eviction
106
+ // reflects READ recency, not just write recency — an active session whose
107
+ // transcript is momentarily unchanged must not be evicted ahead of idle
108
+ // ones (matches the usage store's hit path).
109
+ this.state.delete(sessionId);
110
+ this.state.set(sessionId, prior);
111
+ // [LAW:no-shared-mutable-globals] Return a COPY of the stored ring, never
112
+ // the reference — a caller mutating the returned array would silently
113
+ // corrupt the retained state (a heisenbug in the next render's
114
+ // lastResponseTime). Every path (hit/miss/absent) copies. 20 entries.
115
+ return {
116
+ kind: "ok",
117
+ value: { messageCount: prior.messageCount, recent: [...prior.recent] },
118
+ };
119
+ }
120
+
121
+ const read = await readAppendedEntries(transcriptPath, prior?.cursor);
122
+ if (read.kind === "failed") return read;
123
+ if (read.kind === "absent") {
124
+ // No transcript yet (fresh session) — keep prior if any, don't cache a
125
+ // cursor so the next render retries when the file appears. Copy the ring
126
+ // (like the hit/miss paths) so no stored reference escapes.
127
+ return {
128
+ kind: "ok",
129
+ value: {
130
+ messageCount: prior?.messageCount ?? 0,
131
+ recent: [...(prior?.recent ?? [])],
132
+ },
133
+ };
134
+ }
135
+
136
+ const { entries, cursor, reset } = read.value;
137
+ const base = prior && !reset ? prior : undefined;
138
+ let messageCount = base?.messageCount ?? 0;
139
+ let recent = base ? [...base.recent] : [];
140
+ for (const entry of entries) {
141
+ if (entry.isSidechain) continue;
142
+ if (isRealUserMessage(entry)) messageCount++;
143
+ recent.push(entry);
144
+ }
145
+ if (recent.length > RECENT_WINDOW) recent = recent.slice(-RECENT_WINDOW);
146
+
147
+ this.state.delete(sessionId);
148
+ this.state.set(sessionId, { cursor, messageCount, recent });
149
+ while (this.state.size > MAX_SESSIONS) {
150
+ const oldest = this.state.keys().next().value;
151
+ if (oldest === undefined) break;
152
+ this.state.delete(oldest);
153
+ }
154
+ // [LAW:no-shared-mutable-globals] `recent` is now the STORED array — copy it
155
+ // out so the returned value shares no reference with retained state.
156
+ return { kind: "ok", value: { messageCount, recent: [...recent] } };
157
+ }
158
+
64
159
  // [LAW:no-silent-failure] A hook payload with no cost block is `absent`
65
160
  // (old clients); a transcript parse error is `failed`, carried to the
66
161
  // payload boundary — the old catch dressed it as the same all-null record
@@ -69,33 +164,24 @@ export class MetricsProvider {
69
164
  sessionId: string,
70
165
  hookData: ClaudeHookData,
71
166
  ): Promise<Outcome<MetricsInfo>> {
72
- try {
73
- debug(`Getting metrics from hook data for session: ${sessionId}`);
167
+ debug(`Getting metrics from hook data for session: ${sessionId}`);
74
168
 
75
- if (!hookData.cost) {
76
- return ABSENT;
77
- }
169
+ if (!hookData.cost) {
170
+ return ABSENT;
171
+ }
78
172
 
79
- // parseJsonlFile keeps sidechain entries (usage needs them); metrics
80
- // counts only main-thread turns, so the sidechain exclusion is local.
81
- const entries = (await parseJsonlFile(hookData.transcript_path)).filter(
82
- (entry) => !entry.isSidechain,
83
- );
84
- const messageCount = this.calculateMessageCount(entries);
85
- const lastResponseTime = this.calculateLastResponseTime(entries);
86
-
87
- return ok({
88
- responseTime: hookData.cost.total_api_duration_ms / 1000,
89
- lastResponseTime,
90
- sessionDuration: hookData.cost.total_duration_ms / 1000,
91
- messageCount,
92
- linesAdded: hookData.cost.total_lines_added,
93
- linesRemoved: hookData.cost.total_lines_removed,
94
- });
95
- } catch (error) {
96
- return failed(
97
- `metrics (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
98
- );
173
+ const folded = await this.foldMetrics(sessionId, hookData.transcript_path);
174
+ if (folded.kind === "failed") {
175
+ return failed(`metrics (${sessionId}): ${folded.reason}`);
99
176
  }
177
+
178
+ return ok({
179
+ responseTime: hookData.cost.total_api_duration_ms / 1000,
180
+ lastResponseTime: this.calculateLastResponseTime(folded.value.recent),
181
+ sessionDuration: hookData.cost.total_duration_ms / 1000,
182
+ messageCount: folded.value.messageCount,
183
+ linesAdded: hookData.cost.total_lines_added,
184
+ linesRemoved: hookData.cost.total_lines_removed,
185
+ });
100
186
  }
101
187
  }