@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.17.3",
3
+ "version": "1.17.5",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -24,6 +24,10 @@
24
24
  "test:coverage": "jest --coverage",
25
25
  "demo": "tsx src/demo/dsl.ts",
26
26
  "benchmark:timing": "scripts/benchmark_timing.sh",
27
+ "preloadtest": "pnpm build",
28
+ "loadtest": "node --import tsx scripts/daemon-load-harness.ts",
29
+ "preloadtest:gate": "pnpm build",
30
+ "loadtest:gate": "node --import tsx scripts/daemon-load-harness.ts --sessions 25 --interval 300 --duration 20 --churn --transcript-lines 5000",
27
31
  "check:protocol": "node scripts/check-protocol.mjs",
28
32
  "gen:schema": "tsx scripts/gen-schema.ts",
29
33
  "check:schema": "tsx scripts/check-schema.ts",
@@ -91,10 +95,10 @@
91
95
  "mobx": "^6.15.0"
92
96
  },
93
97
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.17.3",
95
- "@promptctl/cc-candybar-darwin-x64": "1.17.3",
96
- "@promptctl/cc-candybar-linux-x64": "1.17.3",
97
- "@promptctl/cc-candybar-linux-arm64": "1.17.3"
98
+ "@promptctl/cc-candybar-darwin-arm64": "1.17.5",
99
+ "@promptctl/cc-candybar-darwin-x64": "1.17.5",
100
+ "@promptctl/cc-candybar-linux-x64": "1.17.5",
101
+ "@promptctl/cc-candybar-linux-arm64": "1.17.5"
98
102
  },
99
103
  "pnpm": {
100
104
  "supportedArchitectures": {
@@ -1,23 +1,26 @@
1
- import fs from "node:fs";
2
- import { join } from "node:path";
1
+ import { dirname, join } from "node:path";
3
2
 
4
3
  import {
5
- SessionProvider,
6
4
  type SessionInfo,
7
- type SessionUsage,
8
5
  type UsageInfo,
9
6
  type TokenBreakdown,
10
7
  } from "../../segments/session";
8
+ import { PricingService } from "../../segments/pricing";
11
9
  import {
12
10
  getClaudePaths,
13
11
  findProjectPaths,
12
+ findAgentTranscripts,
13
+ readAppendedEntries,
14
14
  type ClaudeHookData,
15
+ type ParsedEntry,
16
+ type TranscriptCursor,
15
17
  } from "../../utils/claude";
16
18
  // [LAW:single-enforcer] The once-per-day seed's directory walk shares the same
17
19
  // in-flight-I/O budget (gn4.2) as every other transcript scan.
18
20
  import {
19
21
  readdir as gatedReaddir,
20
22
  stat as gatedStat,
23
+ statMtimeMs,
21
24
  } from "../../utils/transcript-fs";
22
25
  import { SingleFlight } from "../../utils/single-flight";
23
26
  import { ABSENT, failed, ok, type Outcome } from "../../utils/outcome";
@@ -105,6 +108,10 @@ interface DayUsage {
105
108
  }
106
109
 
107
110
  interface SessionRecord {
111
+ // [LAW:one-source-of-truth] `files` is canonical (main transcript + agent
112
+ // sidechains, each its own incremental FileFold); `sessionInfo`/`days` are
113
+ // derived from mergeFolds and re-synced on every ingest — the single writer.
114
+ files: Map<string, FileFold>;
108
115
  sessionInfo: SessionInfo;
109
116
  days: Map<string, DayUsage>;
110
117
  transcriptMtime: number;
@@ -152,33 +159,119 @@ function seedCutoffMs(): number {
152
159
  return d.getTime();
153
160
  }
154
161
 
155
- function statMtimeMs(filePath: string | undefined): number {
156
- if (!filePath) return 0;
157
- try {
158
- return fs.statSync(filePath).mtimeMs;
159
- } catch {
160
- return 0;
161
- }
162
+ function emptyBreakdown(): TokenBreakdown {
163
+ return { input: 0, output: 0, cacheCreation: 0, cacheRead: 0 };
164
+ }
165
+
166
+ // [LAW:one-source-of-truth] The incremental fold of ONE append-only transcript
167
+ // file (a session's main transcript or one agent sidechain). `cursor` marks the
168
+ // bytes already folded; the sums run over every usage-bearing entry seen so far.
169
+ // The session aggregate is the MERGE of its files' folds (mergeFolds), so a file
170
+ // rewritten by /compact re-folds in isolation without disturbing the others.
171
+ interface FileFold {
172
+ cursor: TranscriptCursor;
173
+ entries: number; // usage-bearing entries folded (0 ⇒ empty session, all-null)
174
+ cost: number;
175
+ breakdown: TokenBreakdown;
176
+ days: Map<string, DayUsage>; // keys >= seed cutoff only (pruned on fold)
162
177
  }
163
178
 
164
- // dayKey strings sort lexically == chronologically, so "keep recent" is a
165
- // string comparison against yesterday's key.
166
- function bucketByDay(usage: SessionUsage): Map<string, DayUsage> {
179
+ // [LAW:effects-at-boundaries] Pure fold of NEW entries onto a prior file fold.
180
+ // Returns a FRESH FileFold (prior is never mutated) so two concurrent refolds
181
+ // sharing one prior snapshot can't corrupt each other. `reset` (file rewritten)
182
+ // discards the prior sums and folds `entries` — the whole new file — from zero.
183
+ //
184
+ // [LAW:one-source-of-truth] Session cost and day cost use the SAME priced value:
185
+ // an entry lacking costUSD is priced once (PricingService) and that figure feeds
186
+ // BOTH the session total and its day bucket. This matches the old path exactly —
187
+ // it mutated entry.costUSD to the priced cost before bucketByDay read it — so
188
+ // `today` is not silently under-counted for un-costed entries.
189
+ async function foldFile(
190
+ prior: FileFold | undefined,
191
+ reset: boolean,
192
+ entries: readonly ParsedEntry[],
193
+ cursor: TranscriptCursor,
194
+ ): Promise<FileFold> {
195
+ // dayKey strings sort lexically == chronologically, so "keep recent" is a
196
+ // string comparison against yesterday's key.
167
197
  const keep = dayKey(new Date(seedCutoffMs()));
198
+ const base = prior && !reset ? prior : undefined;
199
+ let count = base?.entries ?? 0;
200
+ let cost = base?.cost ?? 0;
201
+ const breakdown = base ? { ...base.breakdown } : emptyBreakdown();
168
202
  const days = new Map<string, DayUsage>();
169
- for (const entry of usage.entries) {
203
+ if (base) {
204
+ for (const [k, v] of base.days) if (k >= keep) days.set(k, { ...v });
205
+ }
206
+ for (const entry of entries) {
207
+ const u = entry.message?.usage;
208
+ if (!u) continue;
209
+ count++;
210
+ const priced =
211
+ entry.costUSD ?? (await PricingService.calculateCostForEntry(entry.raw));
212
+ cost += priced;
213
+ breakdown.input += u.input_tokens || 0;
214
+ breakdown.output += u.output_tokens || 0;
215
+ breakdown.cacheCreation += u.cache_creation_input_tokens || 0;
216
+ breakdown.cacheRead += u.cache_read_input_tokens || 0;
170
217
  const key = dayKey(new Date(entry.timestamp));
171
218
  if (key < keep) continue;
172
219
  const d = days.get(key) ?? { ...EMPTY_DAY };
173
- const u = entry.message.usage;
174
- d.cost += entry.costUSD ?? 0;
220
+ // [LAW:one-source-of-truth] Day cost uses the PRICED value, same as session
221
+ // cost the old path mutated entry.costUSD to the priced cost before
222
+ // bucketByDay read it, so an un-costed entry contributed its priced value to
223
+ // `today`, not 0. Using `priced` here preserves that (session and day agree).
224
+ d.cost += priced;
175
225
  d.input += u.input_tokens || 0;
176
226
  d.output += u.output_tokens || 0;
177
227
  d.cacheCreation += u.cache_creation_input_tokens || 0;
178
228
  d.cacheRead += u.cache_read_input_tokens || 0;
179
229
  days.set(key, d);
180
230
  }
181
- return days;
231
+ return { cursor, entries: count, cost, breakdown, days };
232
+ }
233
+
234
+ // [LAW:effects-at-boundaries] Pure merge of a session's file folds into the
235
+ // derived record shape the read path serves. `entries === 0` across all files is
236
+ // the empty session (all-null SessionInfo, whose fields the payload drops) — the
237
+ // same distinction the old `entries.length === 0` arm carried.
238
+ function mergeFolds(files: ReadonlyMap<string, FileFold>): {
239
+ sessionInfo: SessionInfo;
240
+ days: Map<string, DayUsage>;
241
+ } {
242
+ let count = 0;
243
+ let cost = 0;
244
+ const bd = emptyBreakdown();
245
+ const days = new Map<string, DayUsage>();
246
+ for (const f of files.values()) {
247
+ count += f.entries;
248
+ cost += f.cost;
249
+ bd.input += f.breakdown.input;
250
+ bd.output += f.breakdown.output;
251
+ bd.cacheCreation += f.breakdown.cacheCreation;
252
+ bd.cacheRead += f.breakdown.cacheRead;
253
+ for (const [k, v] of f.days) {
254
+ const d = days.get(k) ?? { ...EMPTY_DAY };
255
+ d.cost += v.cost;
256
+ d.input += v.input;
257
+ d.output += v.output;
258
+ d.cacheCreation += v.cacheCreation;
259
+ d.cacheRead += v.cacheRead;
260
+ days.set(k, d);
261
+ }
262
+ }
263
+ if (count === 0) return { sessionInfo: EMPTY_SESSION_INFO, days };
264
+ const tokens = bd.input + bd.output + bd.cacheCreation + bd.cacheRead;
265
+ return {
266
+ sessionInfo: {
267
+ cost,
268
+ calculatedCost: cost,
269
+ officialCost: null,
270
+ tokens,
271
+ tokenBreakdown: bd,
272
+ },
273
+ days,
274
+ };
182
275
  }
183
276
 
184
277
  // Bounded-concurrency fan-out for the seed: at most `limit` parses in flight.
@@ -201,7 +294,6 @@ async function mapPool<T>(
201
294
  }
202
295
 
203
296
  export class SessionUsageStore {
204
- private readonly sessions = new SessionProvider();
205
297
  private readonly entries = new Map<string, SessionRecord>();
206
298
  // [LAW:one-source-of-truth] Coalesces concurrent MISSES for the same
207
299
  // (session, observed mtime) onto one parse; cleared on settle (a coalescer,
@@ -424,15 +516,49 @@ export class SessionUsageStore {
424
516
  if (!transcriptPath) return existing ? ok(existing) : ABSENT;
425
517
 
426
518
  this.misses++;
427
- const aggregate = await this.flight.run(`${sessionId}:${mtime}`, () =>
428
- this.aggregate(sessionId, transcriptPath),
429
- );
430
- if (aggregate.kind !== "ok") return aggregate;
431
- // Tag with the mtime observed AFTER the read so the next render that sees
432
- // the same mtime is a safe hit.
519
+ // [LAW:no-ambient-temporal-coupling] The flight thunk snapshots the prior
520
+ // record when it RUNS (first caller); coalesced callers share that one
521
+ // refold. Two concurrent DIFFERENT-mtime refolds each read the same prior and
522
+ // last-writer-wins. This is safe — and specifically NOT a double-count —
523
+ // because `refold` writes the byte cursor and the fold as ONE atomic pair
524
+ // (both from a single refold result), and the fold always covers exactly
525
+ // [0, cursor). A losing writer's fold is discarded WITH its cursor, not left
526
+ // beside a stale one. So even when last-writer-wins rewinds the stored cursor,
527
+ // it rewinds the paired fold with it; the next refold folds [cursor, end) onto
528
+ // a fold that lacks those bytes — each byte folded once. `foldFile` is pure
529
+ // (fresh FileFold, prior never mutated), so the shared prior can't corrupt.
530
+ // Pinned by the concurrency test in test/transcript-incremental.test.ts.
531
+ // The prior is read when the thunk RUNS. If evictIfNeeded dropped this
532
+ // session between thunk creation and run, prior is undefined and refold does
533
+ // one whole-file re-fold — still correct, just non-incremental that once.
534
+ // (Eviction only fires after a successful write, so this can bite only on a
535
+ // first miss under cap pressure.)
536
+ // [LAW:no-silent-failure] ingest MUST stay total — every caller reads
537
+ // `.kind`, so a rejected promise (e.g. PricingService.calculateCostForEntry
538
+ // throwing on a bad rate table or network hiccup inside foldFile) would
539
+ // escape the Outcome contract entirely. The deleted getSessionUsageFromPath
540
+ // wrapped this; keep that guarantee by mapping any throw to `failed`.
541
+ let outcome: Outcome<{
542
+ files: Map<string, FileFold>;
543
+ sessionInfo: SessionInfo;
544
+ days: Map<string, DayUsage>;
545
+ mainMtime: number;
546
+ }>;
547
+ try {
548
+ outcome = await this.flight.run(`${sessionId}:${mtime}`, () =>
549
+ this.refold(sessionId, transcriptPath, this.entries.get(sessionId)),
550
+ );
551
+ } catch (error) {
552
+ return failed(
553
+ `usage refold (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
554
+ );
555
+ }
556
+ if (outcome.kind !== "ok") return outcome;
433
557
  const record: SessionRecord = {
434
- ...aggregate.value,
435
- transcriptMtime: statMtimeMs(transcriptPath),
558
+ files: outcome.value.files,
559
+ sessionInfo: outcome.value.sessionInfo,
560
+ days: outcome.value.days,
561
+ transcriptMtime: outcome.value.mainMtime,
436
562
  transcriptPath,
437
563
  lastSeenAt: Date.now(),
438
564
  };
@@ -442,23 +568,54 @@ export class SessionUsageStore {
442
568
  return ok(record);
443
569
  }
444
570
 
445
- private async aggregate(
571
+ // [LAW:dataflow-not-control-flow] Re-fold ONLY the bytes appended since the
572
+ // prior record — the transcript (and each agent sidechain) is append-only, so
573
+ // per-render work is O(new bytes), not O(file). The whole-file case (cold
574
+ // record, or a first-ever read) is the same path with an empty prior: the
575
+ // reader yields the whole file from offset 0. A `failed` read on any file
576
+ // fails the fold (the boundary logs it); an `absent` file (not yet written /
577
+ // agent removed) keeps its prior fold and contributes nothing new.
578
+ private async refold(
446
579
  sessionId: string,
447
580
  transcriptPath: string,
581
+ prior: SessionRecord | undefined,
448
582
  ): Promise<
449
- Outcome<{ sessionInfo: SessionInfo; days: Map<string, DayUsage> }>
583
+ Outcome<{
584
+ files: Map<string, FileFold>;
585
+ sessionInfo: SessionInfo;
586
+ days: Map<string, DayUsage>;
587
+ mainMtime: number;
588
+ }>
450
589
  > {
451
- const usage = await this.sessions.getSessionUsageFromPath(
590
+ // File set for this fold: the main transcript plus the session's current
591
+ // agent sidechains. Files no longer in the set (a removed sidechain) drop
592
+ // out of `newFiles`, so a rewritten/renamed file cannot linger in the merge.
593
+ // Pass the prior file set so already-verified agent sidechains skip their
594
+ // first-line re-read — per-render agent discovery is O(new sidechains).
595
+ const agentPaths = await findAgentTranscripts(
452
596
  sessionId,
453
- transcriptPath,
597
+ dirname(transcriptPath),
598
+ prior === undefined ? undefined : new Set(prior.files.keys()),
454
599
  );
455
- // getSessionUsageFromPath never produces absent (an empty transcript is a
456
- // real zero-entry usage); failed passes through to the boundary.
457
- if (usage.kind !== "ok") return usage;
458
- return ok({
459
- sessionInfo: this.sessions.toSessionInfo(usage.value),
460
- days: bucketByDay(usage.value),
461
- });
600
+ const newFiles = new Map<string, FileFold>();
601
+ let mainMtime = 0;
602
+ for (const filePath of [transcriptPath, ...agentPaths]) {
603
+ const priorFold = prior?.files.get(filePath);
604
+ const read = await readAppendedEntries(filePath, priorFold?.cursor);
605
+ if (read.kind === "failed") return read;
606
+ if (read.kind === "absent") {
607
+ // The file has no bytes yet (fresh main) or vanished (removed agent
608
+ // sidechain). Carry a prior fold forward; otherwise it contributes
609
+ // nothing. The main transcript's absence leaves mainMtime 0, so the next
610
+ // render's mtime gate re-attempts rather than caching an empty read.
611
+ if (priorFold) newFiles.set(filePath, priorFold);
612
+ continue;
613
+ }
614
+ const { entries, cursor, reset } = read.value;
615
+ newFiles.set(filePath, await foldFile(priorFold, reset, entries, cursor));
616
+ if (filePath === transcriptPath) mainMtime = cursor.mtimeMs;
617
+ }
618
+ return ok({ files: newFiles, ...mergeFolds(newFiles), mainMtime });
462
619
  }
463
620
 
464
621
  private ensureSeeded(day: string): Promise<void> {
@@ -0,0 +1,135 @@
1
+ import { launchSync, type LaunchOpts, type LaunchResult } from "../proc/launch";
2
+
3
+ // ─── Process start-time fingerprint ──────────────────────────────────────────
4
+ //
5
+ // [FRAMING:representation] A bare pid is an under-constrained identity: the
6
+ // kernel recycles pids, so "a process with pid 989 exists" does NOT prove "989
7
+ // is the same process that wrote this lease". A crashed daemon whose pid the OS
8
+ // later hands to an unrelated long-lived process reads `alive` on every
9
+ // subsequent start, so no daemon ever comes up (brandon-daemon-lifecycle-2b3.4
10
+ // RESIDUAL 1 — the inverse of the socket-theft storm). The kernel ALSO stamps
11
+ // each process with a start-time it never rewinds within that pid's life;
12
+ // (pid, start-time) is the pair that actually IS a process identity, so a
13
+ // recycled pid is provably a DIFFERENT process (different start-time).
14
+ //
15
+ // [LAW:one-source-of-truth] The authority is the kernel's process identity, not
16
+ // a bare pid. `ps -o lstart=` exposes the start-time on every Unix we ship to
17
+ // (darwin + linux). The reported token is treated as OPAQUE — compared by string
18
+ // equality, never parsed — so there is no date-format representation to drift
19
+ // [FRAMING:representation]: a producer/consumer parse mismatch is unrepresentable
20
+ // when neither side parses. That opaque-equality invariant holds ONLY if the
21
+ // GENERATOR is deterministic. `ps -o lstart=` is strftime-formatted (locale) via
22
+ // `localtime()` (timezone), so the SAME process renders different tokens across a
23
+ // locale change OR a timezone change (TZ env, DST, tzdata update). We pin BOTH on
24
+ // the `ps` subprocess — `LC_ALL=C` (dominates any ambient LC_TIME/LC_ALL) and
25
+ // `TZ=UTC` — so the token is a locale- and timezone-invariant UTC rendering of
26
+ // the start instant, and equality is sound.
27
+
28
+ // A read of a pid's kernel start-time. Only TWO outcomes, because nothing
29
+ // derivable from a `ps` exit code can SOUNDLY prove a process is dead — a
30
+ // non-zero exit means "no start-time to report", which conflates a genuinely
31
+ // absent pid with an access failure (hardened `/proc`/`hidepid`, a
32
+ // permission-denied read of a live process). A `gone` state produced from that
33
+ // would be an over-claim ([LAW:types-are-the-program]) that could false-dead a
34
+ // live owner and reclaim its socket. So:
35
+ // start — the pid is live and this is its start-time token (the ONLY
36
+ // thing `ps` can assert soundly: it printed a row).
37
+ // unavailable — `ps` reported no start-time (absent OR unreadable OR errored).
38
+ // Callers defer to kill(pid,0), the sound liveness test, which
39
+ // correctly reclaims a truly-dead pid and spares a live one.
40
+ // The fingerprint's unique contribution is the `start`+mismatch case (a recycled
41
+ // pid whose live start-time differs from the lease); everything else falls back
42
+ // to kill, no worse than before the fingerprint existed.
43
+ export type StartTimeRead =
44
+ | { kind: "start"; token: string }
45
+ | { kind: "unavailable"; detail: string };
46
+
47
+ // The subprocess boundary, injected so the parse/branch logic is exercised by
48
+ // input enumeration with a fake launcher while the real path runs the real `ps`.
49
+ export type Launcher = (opts: LaunchOpts) => LaunchResult;
50
+
51
+ // [LAW:effects-at-boundaries][LAW:single-enforcer] The sole effect (spawning
52
+ // `ps`) goes through the one subprocess enforcer, `launchSync`. The ONLY sound
53
+ // signal `ps` gives is a printed start-time row (a live pid we could read);
54
+ // [LAW:no-silent-failure] every other outcome — absent pid, access failure,
55
+ // spawn-error, timeout — is `unavailable`, deferring the alive/dead call to
56
+ // kill(pid,0) rather than risk declaring a live owner dead from a `ps` exit
57
+ // code that cannot distinguish "gone" from "cannot read".
58
+ export function readStartTime(
59
+ pid: number,
60
+ launch: Launcher = launchSync,
61
+ ): StartTimeRead {
62
+ const res = launch({
63
+ bin: "ps",
64
+ args: ["-o", "lstart=", "-p", String(pid)],
65
+ category: "process-fingerprint",
66
+ timeoutMs: 2000,
67
+ // [FRAMING:representation] Pin BOTH locale and timezone so the token is
68
+ // deterministic across daemons — the writer and reader must render the same
69
+ // process identically for opaque string equality to be sound. LC_ALL=C fixes
70
+ // the strftime format; TZ=UTC fixes localtime(), so DST / TZ / tzdata changes
71
+ // can't make one live process render two different start-time strings.
72
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
73
+ });
74
+ if (res.ok) {
75
+ const token = res.stdout.trim();
76
+ if (token.length > 0) return { kind: "start", token };
77
+ // Exit 0 with empty output is anomalous (a live pid always lists) — don't
78
+ // mint an empty-string fingerprint; defer to kill.
79
+ return { kind: "unavailable", detail: "ps produced no start-time" };
80
+ }
81
+ const detail =
82
+ res.reason === "spawn-error"
83
+ ? (res.error ?? "ps spawn failed")
84
+ : `ps ${res.reason} (exit ${res.exitCode})`;
85
+ return { kind: "unavailable", detail };
86
+ }
87
+
88
+ export interface LivenessDeps {
89
+ readStartTime: (pid: number) => StartTimeRead;
90
+ pidAlive: (pid: number) => boolean;
91
+ }
92
+
93
+ // [LAW:dataflow-not-control-flow] The liveness verdict is a pure fold over the
94
+ // start-time read + the lease's recorded token. Full input space:
95
+ // read=start + token matches lease → true (same process still alive)
96
+ // read=start + token differs → false (a DIFFERENT process holds the
97
+ // pid — a recycle, or a restarted daemon)
98
+ // read=unavailable → kill(pid,0) fallback (the sound
99
+ // alive/dead test: a truly-dead pid →
100
+ // false → reclaim; a live pid ps couldn't
101
+ // read → true → spared)
102
+ // lease token = null (unfingerprinted at write, e.g. a host without `ps`) →
103
+ // kill(pid,0) fallback for the same reason
104
+ //
105
+ // [LAW:no-silent-failure] Only the `start`+mismatch case comes from the
106
+ // fingerprint; every ambiguous `ps` outcome defers to kill, so the DANGEROUS
107
+ // direction (declaring a live daemon dead → stealing its socket, the storm this
108
+ // epic fights) never happens merely because `ps` could not answer. The only
109
+ // thing forfeited when `ps` cannot answer is detecting a recycled pid.
110
+ export function sameLiveProcess(
111
+ pid: number,
112
+ leaseToken: string | null,
113
+ deps: LivenessDeps,
114
+ ): boolean {
115
+ if (leaseToken === null) return deps.pidAlive(pid);
116
+ const read = deps.readStartTime(pid);
117
+ switch (read.kind) {
118
+ case "start":
119
+ return read.token === leaseToken;
120
+ case "unavailable":
121
+ return deps.pidAlive(pid);
122
+ }
123
+ }
124
+
125
+ // Read THIS process's own start-time to stamp into its lease. `unavailable`
126
+ // (no `ps`) collapses to `null` — the lease records "unfingerprinted", and every
127
+ // reader of it falls back to kill(pid,0) via sameLiveProcess. A dead result is
128
+ // impossible for our own live pid; if it somehow occurs we also record null.
129
+ export function readOwnStartTime(
130
+ pid: number,
131
+ read: (pid: number) => StartTimeRead = readStartTime,
132
+ ): string | null {
133
+ const r = read(pid);
134
+ return r.kind === "start" ? r.token : null;
135
+ }
@@ -22,6 +22,11 @@ import {
22
22
  readSocketIdentity,
23
23
  type SocketIdentity,
24
24
  } from "./socket-ownership";
25
+ import {
26
+ readStartTime,
27
+ readOwnStartTime,
28
+ sameLiveProcess,
29
+ } from "./process-fingerprint";
25
30
  import { dlog, closeLog } from "./log";
26
31
  import {
27
32
  PROTOCOL_VERSION,
@@ -127,8 +132,16 @@ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
127
132
  // connection. Any uncaught error exits non-zero; the next client obtains a
128
133
  // fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
129
134
  // obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
135
+ // [LAW:one-source-of-truth] Our own kernel start-time, read once at startup and
136
+ // stamped into our lease so a future daemon's arbitration can prove whether our
137
+ // pid still names THIS process or a recycled ghost (process-fingerprint.ts).
138
+ // null when this host cannot fingerprint (no `ps`) — readers then fall back to
139
+ // kill(pid,0), no worse than before the fingerprint existed.
140
+ let myStartTime: string | null = null;
141
+
130
142
  export function runDaemon(): void {
131
143
  fs.mkdirSync(daemonDir(), { recursive: true });
144
+ myStartTime = readOwnStartTime(process.pid);
132
145
  // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
133
146
  // not a symlink before we bind. Without this check, a same-host attacker
134
147
  // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
@@ -236,7 +249,11 @@ function handleAddressInUse(server: net.Server, sockPath: string): void {
236
249
  // [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
237
250
  // through unlink + rebind below, not the re-derived global — one identity
238
251
  // source for the whole arbitration.
239
- const decision = arbitrateSocket(readLease(leasePathFor(sockPath)), pidAlive);
252
+ const decision = arbitrateSocket(
253
+ readLease(leasePathFor(sockPath)),
254
+ (pid, startTime) =>
255
+ sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
256
+ );
240
257
  if (decision.kind === "attach-and-exit") {
241
258
  dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
242
259
  process.exit(0);
@@ -280,20 +297,19 @@ function onListening(sockPath: string): void {
280
297
  // the FD yields no path-comparable identity. stat(path), taken as close to the
281
298
  // bind as possible, is the only path-comparable truth available.
282
299
  //
283
- // [FRAMING:representation] Be honest about the ONE window this capture cannot
284
- // close. It catches an absent/unreadable path (below): we could not form a
285
- // fingerprint, so we have no proof of ownership and exit toward the SAFE
286
- // direction (a false-displaced daemon just lets whoever holds the path serve
287
- // no orphan) WITHOUT writing a lease that would stomp a thief's. It does NOT
288
- // catch a path that is present but already holds a THIEF's socket — if a second
289
- // daemon completed its full EADDRINUSE read-lease unlink rebind cycle in
290
- // the single event-loop tick between bind() and this 'listening' callback, we
291
- // fingerprint the thief's identity and the self-check reads `owned` forever
292
- // (the orphan this module exists to kill). That race is irreducible at this
293
- // layer (no race-free handle on our own socket's path-identity exists) and
294
- // vanishingly narrow; fully closing it needs the lock-based liveness the epic
295
- // deferred (a flock/start-time fingerprint) the same residual sibling .1's
296
- // arbitrateSocket documents for its false-alive direction.
300
+ // [FRAMING:representation] This inode capture still cannot close the capture
301
+ // race on its own: if a second daemon completed its full EADDRINUSE →
302
+ // read-lease unlink rebind cycle in the single event-loop tick between
303
+ // bind() and this 'listening' callback, we stat the path and capture the
304
+ // THIEF's inode as "ours". No race-free handle on our own socket's path
305
+ // identity exists (an AF_UNIX listener's fstat is a different namespace's
306
+ // inode), so the captured inode cannot be made trustworthy. But that is no
307
+ // longer the immortal orphan it was (brandon-daemon-lifecycle-2b3.4 RESIDUAL
308
+ // 2): the ownership self-check now ALSO requires the lease to still name us,
309
+ // and a real thief writes its own pid into the lease so a displaced daemon
310
+ // drains within a bounded number of intervals instead of reading `owned`
311
+ // forever (see checkOwnership). The absent/unreadable case below still exits
312
+ // toward the SAFE direction without writing a lease that would stomp a thief's.
297
313
  const boundRead = readSocketIdentity(sockPath);
298
314
  if (boundRead.kind !== "present") {
299
315
  dlog(
@@ -330,15 +346,20 @@ function onListening(sockPath: string): void {
330
346
  // --- socket-ownership self-check ---
331
347
  //
332
348
  // [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
333
- // path over time" (brandon-daemon-lifecycle-2b3.2). Periodically re-stats the
334
- // path; if its kernel identity no longer matches what we bound, we were
335
- // displaced (its socket unlinked + rebound by a reclaimer) and drain through the
336
- // SAME shutdown funnel as signals, the RSS backstop, and the watchdog — no
337
- // parallel exit path.
349
+ // path over time" (brandon-daemon-lifecycle-2b3.2). Each interval it re-reads
350
+ // BOTH representations a displacer can touch the path's kernel identity (still
351
+ // the inode we bound?) and the lease (still names our pid?) and drains through
352
+ // the SAME shutdown funnel as signals, the RSS backstop, and the watchdog on
353
+ // either mismatch. The lease arm closes the capture race (RESIDUAL 2): a thief
354
+ // that stole our socket inside the bind→listening tick, which the inode arm
355
+ // cannot see because we captured the thief's inode, is caught the moment the
356
+ // thief writes its own pid into the lease. No parallel exit path.
338
357
  function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
339
358
  makeOwnershipWatch({
340
359
  bound,
360
+ myPid: process.pid,
341
361
  readIdentity: () => readSocketIdentity(sockPath),
362
+ readLease: () => readLease(leasePathFor(sockPath)),
342
363
  shutdown: (code) => shutdown(code),
343
364
  log: dlog,
344
365
  }).arm();
@@ -418,7 +439,7 @@ function writeLeaseFile(sockPath: string): void {
418
439
  pid: process.pid,
419
440
  version: PROTOCOL_VERSION,
420
441
  binPath: process.argv[1],
421
- startedAt: new Date().toISOString(),
442
+ startTime: myStartTime,
422
443
  });
423
444
  if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
424
445
  }