@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.
- package/dist/index.mjs +79 -79
- package/package.json +9 -5
- package/src/daemon/cache/session-usage-store.ts +195 -38
- package/src/daemon/process-fingerprint.ts +135 -0
- package/src/daemon/server.ts +42 -21
- package/src/daemon/socket-lease.ts +64 -36
- package/src/daemon/socket-ownership.ts +69 -25
- package/src/proc/launch.ts +4 -0
- package/src/segments/metrics.ts +122 -36
- package/src/segments/session.ts +4 -169
- package/src/utils/claude.ts +79 -12
- package/src/utils/transcript-fs.ts +118 -1
|
@@ -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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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 (
|
|
35
|
-
//
|
|
36
|
-
// diagnostic pidfile — one file, one identity
|
|
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
|
-
|
|
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.
|
|
47
|
-
// exercised by input enumeration with no
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
-
|
|
84
|
+
isSameLiveProcess: (pid: number, startTime: string | null) => boolean,
|
|
76
85
|
): SocketArbitration {
|
|
77
86
|
if (read.kind === "owned") {
|
|
78
|
-
return
|
|
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
|
-
: {
|
|
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
|
|
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
|
-
|
|
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
|
|
68
|
-
// every branch is exercised by input enumeration.
|
|
69
|
-
//
|
|
70
|
-
// present +
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// serve and the next client respawns; a false `owned` is the
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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: `
|
|
113
|
+
reason: `lease not held (${lease.kind}) — no longer the socket owner`,
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
|
-
if (
|
|
116
|
+
if (lease.pid !== myPid) {
|
|
95
117
|
return {
|
|
96
118
|
kind: "displaced",
|
|
97
|
-
reason:
|
|
119
|
+
reason: `lease reassigned (names pid=${lease.pid}, we are pid=${myPid}) — a reclaimer overwrote it`,
|
|
98
120
|
};
|
|
99
121
|
}
|
|
100
|
-
return {
|
|
101
|
-
|
|
102
|
-
|
|
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(
|
|
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(
|
package/src/proc/launch.ts
CHANGED
|
@@ -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];
|
package/src/segments/metrics.ts
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
73
|
-
debug(`Getting metrics from hook data for session: ${sessionId}`);
|
|
167
|
+
debug(`Getting metrics from hook data for session: ${sessionId}`);
|
|
74
168
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
169
|
+
if (!hookData.cost) {
|
|
170
|
+
return ABSENT;
|
|
171
|
+
}
|
|
78
172
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
}
|