@promptctl/cc-candybar 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.mjs +76 -76
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +80 -0
- package/src/daemon/cache/git.ts +98 -2
- package/src/daemon/cache/session-usage-store.ts +81 -0
- package/src/daemon/render-payload.ts +186 -35
- package/src/proc/launch.ts +4 -0
- package/src/segments/git.ts +228 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
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",
|
|
@@ -91,10 +91,10 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.6.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.6.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.6.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.6.0"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -280,6 +280,21 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
280
280
|
default: 0,
|
|
281
281
|
},
|
|
282
282
|
|
|
283
|
+
// Forge PR/MR — the daemon's git provider resolves the branch's open PR via
|
|
284
|
+
// gh/glab and projects it here. Declaring any of these turns on the network
|
|
285
|
+
// lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
|
|
286
|
+
// was asked but couldn't answer (auth/network) — distinct from "no PR"
|
|
287
|
+
// (every field empty). prNumber 0 (default) ⇒ no open PR.
|
|
288
|
+
"git.prNumber": {
|
|
289
|
+
kind: "input",
|
|
290
|
+
path: "git.prNumber",
|
|
291
|
+
type: "number",
|
|
292
|
+
default: 0,
|
|
293
|
+
},
|
|
294
|
+
"git.prState": { kind: "input", path: "git.prState", default: "" },
|
|
295
|
+
"git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
|
|
296
|
+
"git.prError": { kind: "input", path: "git.prError", default: "" },
|
|
297
|
+
|
|
283
298
|
// Prompt-cache expiry — epoch seconds, projected by the cache provider.
|
|
284
299
|
// Same unit/shape as block/weekly resetsAt so the cacheTimer segment
|
|
285
300
|
// composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
|
|
@@ -386,6 +401,31 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
386
401
|
"burn.eta.warnMinutes": { kind: "literal", value: 60 },
|
|
387
402
|
"burn.eta.errorMinutes": { kind: "literal", value: 30 },
|
|
388
403
|
|
|
404
|
+
// Token throughput for the active turn — daemon-derived tok/s on three lanes
|
|
405
|
+
// (render-payload.ts: successive-render delta over the SessionUsageStore).
|
|
406
|
+
// Same absence idiom as burn: -1 is the structurally-impossible default the
|
|
407
|
+
// `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
|
|
408
|
+
// real reading, so it cannot double as the absence marker). Each lane is
|
|
409
|
+
// independently absent — `input` reads "—" mid-stream while `output` flows.
|
|
410
|
+
"speed.input": {
|
|
411
|
+
kind: "input",
|
|
412
|
+
path: "speed.input",
|
|
413
|
+
type: "number",
|
|
414
|
+
default: -1,
|
|
415
|
+
},
|
|
416
|
+
"speed.output": {
|
|
417
|
+
kind: "input",
|
|
418
|
+
path: "speed.output",
|
|
419
|
+
type: "number",
|
|
420
|
+
default: -1,
|
|
421
|
+
},
|
|
422
|
+
"speed.total": {
|
|
423
|
+
kind: "input",
|
|
424
|
+
path: "speed.total",
|
|
425
|
+
type: "number",
|
|
426
|
+
default: -1,
|
|
427
|
+
},
|
|
428
|
+
|
|
389
429
|
// Context — daemon fetches via ContextProvider; contextLeftPercentage.
|
|
390
430
|
"context.totalTokens": {
|
|
391
431
|
kind: "input",
|
|
@@ -517,6 +557,24 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
517
557
|
fg: "foreground",
|
|
518
558
|
when: '{{ ne .git.branch "" }}',
|
|
519
559
|
},
|
|
560
|
+
// Git PR/MR — the branch's open pull/merge request as a clickable link.
|
|
561
|
+
// OPT-IN: declared but NOT in the default root (it adds a network gh/glab
|
|
562
|
+
// call). Add "gitPr" to a container's children to enable it. The `{{ link
|
|
563
|
+
// url text }}` emits ONE OSC-8 region carrying the https PR url, so the
|
|
564
|
+
// CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
|
|
565
|
+
// [LAW:no-silent-failure] Three render states from the data: an open PR
|
|
566
|
+
// (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
|
|
567
|
+
// renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
|
|
568
|
+
// no PR (both empty) leaves the `when` gate false and the segment absent.
|
|
569
|
+
gitPr: {
|
|
570
|
+
template:
|
|
571
|
+
'{{ if ne .git.prUrl "" }}' +
|
|
572
|
+
'{{ link .git.prUrl (printf " ⇆ #%v " .git.prNumber) }}' +
|
|
573
|
+
"{{ else }} ⚠ PR {{ end }}",
|
|
574
|
+
bg: "surface-active",
|
|
575
|
+
fg: "foreground",
|
|
576
|
+
when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
|
|
577
|
+
},
|
|
520
578
|
// Quick-action tray — copy the session id / cwd, open the project dir /
|
|
521
579
|
// transcript in the editor. [LAW:locality-or-seam] The glyph is the
|
|
522
580
|
// REPRESENTATION; the named action (below) is the BEHAVIOR; the action
|
|
@@ -581,6 +639,23 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
581
639
|
fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
|
|
582
640
|
when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
|
|
583
641
|
},
|
|
642
|
+
// Token throughput for the active turn — output / input / total tok/s, each a
|
|
643
|
+
// successive-render delta computed daemon-side (render-payload.ts); the
|
|
644
|
+
// template only formats. Declared-but-opt-in (NOT in the default root, like
|
|
645
|
+
// block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
|
|
646
|
+
// "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
|
|
647
|
+
// divide-by-zero number). Visible once the session has done any work (stable,
|
|
648
|
+
// no layout flicker); `output` is the live generation rate, `input` spikes at
|
|
649
|
+
// turn start, `total` is their sum.
|
|
650
|
+
speed: {
|
|
651
|
+
template:
|
|
652
|
+
' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
|
|
653
|
+
'in {{ template "formatSpeed" .speed.input }} · ' +
|
|
654
|
+
'tot {{ template "formatSpeed" .speed.total }} ',
|
|
655
|
+
bg: "panel",
|
|
656
|
+
fg: "foreground",
|
|
657
|
+
when: "{{ gt .session.tokens 0 }}",
|
|
658
|
+
},
|
|
584
659
|
// Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
|
|
585
660
|
// to 0, so an expired cache renders "cold" (and reads red via the ≤8
|
|
586
661
|
// arm) rather than a negative number. [LAW:dataflow-not-control-flow]
|
|
@@ -759,6 +834,11 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
759
834
|
// daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
|
|
760
835
|
formatEta:
|
|
761
836
|
'{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
|
|
837
|
+
// Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
|
|
838
|
+
// when the daemon had no projectable sample (-1). Branches on the VALUE, like
|
|
839
|
+
// formatRate; reuses formatTokenCount so the K/M scale policy has one home.
|
|
840
|
+
formatSpeed:
|
|
841
|
+
'{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
|
|
762
842
|
// Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
|
|
763
843
|
// part is formatted by the shared formatTokenCount and joined with " + ". A
|
|
764
844
|
// `$first` flag (reassigned across if-frames) inserts the separator before all
|
package/src/daemon/cache/git.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { GitService, type GitInfo } from "../../segments/git";
|
|
4
|
-
import { ok, type Outcome } from "../../utils/outcome";
|
|
3
|
+
import { GitService, type GitInfo, type PullRequest } from "../../segments/git";
|
|
4
|
+
import { ABSENT, ok, type Outcome } from "../../utils/outcome";
|
|
5
5
|
import { debug } from "../../utils/logger";
|
|
6
6
|
import { WatcherRegistry, type WatcherHandle } from "./watchers";
|
|
7
7
|
|
|
@@ -44,6 +44,17 @@ const DEFAULT_TTL_MS = 30_000;
|
|
|
44
44
|
const DEFAULT_MAX_ENTRIES = 64;
|
|
45
45
|
const SANITY_INTERVAL_MS = 5 * 60_000;
|
|
46
46
|
|
|
47
|
+
// [LAW:decomposition] The forge PR lookup is a network resource with a
|
|
48
|
+
// different lifecycle than local git state: it changes ~twice in a PR's life
|
|
49
|
+
// and the fs watchers on .git/HEAD|index say nothing about remote PR state. So
|
|
50
|
+
// it gets its OWN cache + TTL — independent of the 30s GitInfo TTL — so the
|
|
51
|
+
// per-render git fetch stays all-local and the gh/glab spawn happens at most
|
|
52
|
+
// once per window (or on branch switch, via the branch in the key). A `failed`
|
|
53
|
+
// lookup caches for a SHORTER window so a transient forge outage is retried
|
|
54
|
+
// soon, not pinned to the bar for the full ok-TTL.
|
|
55
|
+
const PR_TTL_OK_MS = 5 * 60_000;
|
|
56
|
+
const PR_TTL_FAIL_MS = 45_000;
|
|
57
|
+
|
|
47
58
|
// Options applied to subscribe()'s internal getInfo() call. var-system's six
|
|
48
59
|
// GitField values (branch, sha, dirty, ahead, behind, stash) all project from
|
|
49
60
|
// these flags — keep them in lockstep with the projection in
|
|
@@ -60,6 +71,11 @@ interface MtimeSnapshot {
|
|
|
60
71
|
index: number;
|
|
61
72
|
}
|
|
62
73
|
|
|
74
|
+
interface PrCacheEntry {
|
|
75
|
+
pr: Outcome<PullRequest>;
|
|
76
|
+
computedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
interface GitCacheEntry {
|
|
64
80
|
info: GitInfo;
|
|
65
81
|
computedAt: number;
|
|
@@ -147,6 +163,16 @@ export class GitDataProvider extends GitService {
|
|
|
147
163
|
// here; subsequent concurrent callers await the same promise and resolve in
|
|
148
164
|
// lockstep.
|
|
149
165
|
private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
|
|
166
|
+
// [LAW:one-source-of-truth] The forge PR cache, keyed by `repoRoot|branch`
|
|
167
|
+
// (a branch switch is a new key, so its PR is fetched fresh; the old branch's
|
|
168
|
+
// entry ages out). Separate from `entries` because it carries its own TTL.
|
|
169
|
+
private readonly prCache = new Map<string, PrCacheEntry>();
|
|
170
|
+
// [LAW:single-enforcer] Coalesce concurrent PR misses on the same key — same
|
|
171
|
+
// role as fetchInFlight for the GitInfo path, but for the network forge call.
|
|
172
|
+
private readonly prFetchInFlight = new Map<
|
|
173
|
+
string,
|
|
174
|
+
Promise<Outcome<PullRequest>>
|
|
175
|
+
>();
|
|
150
176
|
// [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
|
|
151
177
|
// `refreshing` holds the repoRoots whose refresh loop is currently
|
|
152
178
|
// executing; `refreshAgain` is the trailing-edge flag: if a new
|
|
@@ -292,6 +318,15 @@ export class GitDataProvider extends GitService {
|
|
|
292
318
|
if (outcome.kind !== "ok") return outcome;
|
|
293
319
|
const info = outcome.value;
|
|
294
320
|
|
|
321
|
+
// [LAW:decomposition] The inner GitService never resolves the PR — the
|
|
322
|
+
// cache layer owns that lookup so it can give it an independent TTL. On the
|
|
323
|
+
// 30s GitInfo refetch the prCache is almost always warm, so attaching the
|
|
324
|
+
// PR here is a memory read; the gh/glab spawn only fires when the prCache
|
|
325
|
+
// entry has aged past its (outcome-dependent) TTL or the branch changed.
|
|
326
|
+
if (options.showPullRequest) {
|
|
327
|
+
info.pullRequest = await this.getPullRequestCached(repoRoot, info.branch);
|
|
328
|
+
}
|
|
329
|
+
|
|
295
330
|
// Drop any prior entry for this exact key before re-inserting (so we
|
|
296
331
|
// release its watcher refcount cleanly).
|
|
297
332
|
this.dropEntry(key);
|
|
@@ -315,6 +350,63 @@ export class GitDataProvider extends GitService {
|
|
|
315
350
|
return outcome;
|
|
316
351
|
}
|
|
317
352
|
|
|
353
|
+
// [LAW:single-enforcer] The one read path for a branch's PR. Reads the remote
|
|
354
|
+
// (cheap local git) so the cache key reflects every input the PR value
|
|
355
|
+
// depends on — `repoRoot|branch|remote` — and a re-pointed origin is a fresh
|
|
356
|
+
// key, not a stale hit ([LAW:one-source-of-truth]). TTL is outcome-dependent
|
|
357
|
+
// (a `failed` retries sooner). Concurrent misses coalesce through
|
|
358
|
+
// prFetchInFlight. The forge dispatch delegates to inner.resolvePullRequest —
|
|
359
|
+
// this layer is cache + lifecycle only, never forge knowledge.
|
|
360
|
+
private async getPullRequestCached(
|
|
361
|
+
repoRoot: string,
|
|
362
|
+
branch: string,
|
|
363
|
+
): Promise<Outcome<PullRequest>> {
|
|
364
|
+
// [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
|
|
365
|
+
// surface; only `absent` (no remote configured) means "no forge PR".
|
|
366
|
+
const remote = await this.inner.getRemoteOriginUrl(repoRoot);
|
|
367
|
+
if (remote.kind === "failed") return remote;
|
|
368
|
+
if (remote.kind === "absent") return ABSENT;
|
|
369
|
+
const remoteUrl = remote.value;
|
|
370
|
+
|
|
371
|
+
const key = `${repoRoot}|${branch}|${remoteUrl}`;
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
|
|
374
|
+
const existing = this.prCache.get(key);
|
|
375
|
+
if (existing) {
|
|
376
|
+
const ttl = existing.pr.kind === "failed" ? PR_TTL_FAIL_MS : PR_TTL_OK_MS;
|
|
377
|
+
if (now - existing.computedAt < ttl) {
|
|
378
|
+
// LRU touch so the active branch's PR survives eviction.
|
|
379
|
+
this.prCache.delete(key);
|
|
380
|
+
this.prCache.set(key, existing);
|
|
381
|
+
return existing.pr;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const pending = this.prFetchInFlight.get(key);
|
|
386
|
+
if (pending) return pending;
|
|
387
|
+
|
|
388
|
+
const promise = this.inner
|
|
389
|
+
.resolvePullRequest(repoRoot, remoteUrl)
|
|
390
|
+
.then((pr) => {
|
|
391
|
+
this.prCache.set(key, { pr, computedAt: Date.now() });
|
|
392
|
+
this.evictPrIfNeeded();
|
|
393
|
+
return pr;
|
|
394
|
+
})
|
|
395
|
+
.finally(() => {
|
|
396
|
+
this.prFetchInFlight.delete(key);
|
|
397
|
+
});
|
|
398
|
+
this.prFetchInFlight.set(key, promise);
|
|
399
|
+
return promise;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private evictPrIfNeeded(): void {
|
|
403
|
+
while (this.prCache.size > this.maxEntries) {
|
|
404
|
+
const oldest = this.prCache.keys().next().value;
|
|
405
|
+
if (oldest === undefined) break;
|
|
406
|
+
this.prCache.delete(oldest);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
318
410
|
// [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
|
|
319
411
|
// outcome into the GitInfo|null the var-system callback contract expects.
|
|
320
412
|
// `failed` is logged HERE — the one log site for this consumption path
|
|
@@ -548,6 +640,10 @@ export class GitDataProvider extends GitService {
|
|
|
548
640
|
this.refreshing.clear();
|
|
549
641
|
this.refreshAgain.clear();
|
|
550
642
|
this.fetchInFlight.clear();
|
|
643
|
+
// In-flight PR fetches resolve naturally; clearing the maps means the next
|
|
644
|
+
// caller starts fresh (the prCache is rebuilt cold like every other cache).
|
|
645
|
+
this.prCache.clear();
|
|
646
|
+
this.prFetchInFlight.clear();
|
|
551
647
|
if (this.ownsWatchers) this.watchers.closeAll();
|
|
552
648
|
}
|
|
553
649
|
}
|
|
@@ -47,6 +47,42 @@ export interface TodayInfo {
|
|
|
47
47
|
date: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
// [LAW:one-source-of-truth] One observation of the active session's cumulative
|
|
51
|
+
// token counts at a single instant. tok/s is the delta between two of these —
|
|
52
|
+
// the prior sample lives in this store (the single owner of per-session token
|
|
53
|
+
// totals), never in a parallel counter. `input` folds the cache lanes into the
|
|
54
|
+
// prompt-side total so `total === input + output`. `atMs` is the render's clock
|
|
55
|
+
// instant (the daemon's single-enforcer clock), so a frozen test clock makes
|
|
56
|
+
// the rate deterministic.
|
|
57
|
+
export interface SpeedSample {
|
|
58
|
+
readonly input: number;
|
|
59
|
+
readonly output: number;
|
|
60
|
+
readonly total: number;
|
|
61
|
+
readonly atMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The prior observation (absent on the very first render of a session) and the
|
|
65
|
+
// one just taken. The pure rate projection lives at the render-payload boundary;
|
|
66
|
+
// this store only remembers and reports the pair.
|
|
67
|
+
export interface SpeedObservation {
|
|
68
|
+
readonly prev?: SpeedSample;
|
|
69
|
+
readonly cur: SpeedSample;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function speedSampleOf(
|
|
73
|
+
breakdown: TokenBreakdown | null,
|
|
74
|
+
atMs: number,
|
|
75
|
+
): SpeedSample {
|
|
76
|
+
// Prompt-side = raw input plus both cache lanes (all tokens fed to the model);
|
|
77
|
+
// output = generated. total = the same sum the store's `tokens` projection
|
|
78
|
+
// uses, so `total === input + output`. [LAW:one-source-of-truth]
|
|
79
|
+
const input = breakdown
|
|
80
|
+
? breakdown.input + breakdown.cacheCreation + breakdown.cacheRead
|
|
81
|
+
: 0;
|
|
82
|
+
const output = breakdown ? breakdown.output : 0;
|
|
83
|
+
return { input, output, total: input + output, atMs };
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
// Per-(session, day) scalar contribution — the only granularity the today fold
|
|
51
87
|
// needs. Raw entries are discarded after bucketing, so per-session retained
|
|
52
88
|
// memory is O(retained-days), not O(entries).
|
|
@@ -166,6 +202,17 @@ export class SessionUsageStore {
|
|
|
166
202
|
// first seed completes every later read awaits an already-settled promise —
|
|
167
203
|
// zero rescan. A rejected seed is dropped so the next read retries.
|
|
168
204
|
private readonly seeded = new Map<string, Promise<void>>();
|
|
205
|
+
// [LAW:one-source-of-truth] The prior tok/s observation per session. tok/s is
|
|
206
|
+
// a derivative of the SAME token totals the records map already owns; the
|
|
207
|
+
// baseline (prev counts + time) lives HERE, not in a parallel counter. One
|
|
208
|
+
// call to observeSpeed advances it; the pure delta math is render-payload's.
|
|
209
|
+
private readonly speedSamples = new Map<string, SpeedSample>();
|
|
210
|
+
// [LAW:no-ambient-temporal-coupling] Explicit owner of observe/commit ordering
|
|
211
|
+
// for the speed sample. Concurrent renders observing the SAME transcript state
|
|
212
|
+
// (key = `${sessionId}:${mtime}`) share ONE observation and commit the baseline
|
|
213
|
+
// exactly once — so they return the same prev+cur and render identical,
|
|
214
|
+
// deterministic throughput instead of the second clobbering the first.
|
|
215
|
+
private readonly speedFlight = new SingleFlight();
|
|
169
216
|
private readonly maxEntries: number;
|
|
170
217
|
private readonly staleAgeMs: number;
|
|
171
218
|
private hits = 0;
|
|
@@ -284,6 +331,37 @@ export class SessionUsageStore {
|
|
|
284
331
|
});
|
|
285
332
|
}
|
|
286
333
|
|
|
334
|
+
// Take one tok/s observation of the active session: ingest its current
|
|
335
|
+
// cumulative counts, return the prior sample alongside, and record this one as
|
|
336
|
+
// the new baseline. [LAW:no-silent-failure] A failed transcript parse flows out
|
|
337
|
+
// as `failed` (the boundary logs it and the segment reads "—"); an unknown
|
|
338
|
+
// session yields a zero-count sample, so a first-ever render establishes a
|
|
339
|
+
// baseline without fabricating a rate. `nowMs` is the caller's single-enforcer
|
|
340
|
+
// clock instant — the store never reads the clock for tok/s timing itself.
|
|
341
|
+
async observeSpeed(
|
|
342
|
+
sessionId: string,
|
|
343
|
+
transcriptPath: string | undefined,
|
|
344
|
+
nowMs: number,
|
|
345
|
+
): Promise<Outcome<SpeedObservation>> {
|
|
346
|
+
// [LAW:no-ambient-temporal-coupling] Key the observation by the same
|
|
347
|
+
// (session, mtime) tuple ingest uses, so concurrent renders at one transcript
|
|
348
|
+
// state coalesce onto a single observe-and-commit — the read of `prev` and
|
|
349
|
+
// the write of `cur` happen exactly once for that state, with the flight as
|
|
350
|
+
// the sole owner of ordering. A distinct mtime is a genuinely new sample and
|
|
351
|
+
// gets its own key.
|
|
352
|
+
const mtime = statMtimeMs(transcriptPath);
|
|
353
|
+
return this.speedFlight.run(`${sessionId}:${mtime}`, async () => {
|
|
354
|
+
const record = await this.ingest(sessionId, transcriptPath, mtime);
|
|
355
|
+
if (record.kind === "failed") return record;
|
|
356
|
+
const breakdown =
|
|
357
|
+
record.kind === "ok" ? record.value.sessionInfo.tokenBreakdown : null;
|
|
358
|
+
const cur = speedSampleOf(breakdown, nowMs);
|
|
359
|
+
const prev = this.speedSamples.get(sessionId);
|
|
360
|
+
this.speedSamples.set(sessionId, cur);
|
|
361
|
+
return ok({ ...(prev !== undefined && { prev }), cur });
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
287
365
|
// mtime-gated, coalesced re-parse of ONE session. `ok` is its record,
|
|
288
366
|
// `absent` is an unknown empty session (or no sessionId), `failed` is a
|
|
289
367
|
// transcript that exists but couldn't be parsed — NOT cached (only ok
|
|
@@ -416,6 +494,7 @@ export class SessionUsageStore {
|
|
|
416
494
|
for (const [sid, record] of this.entries) {
|
|
417
495
|
if (now - record.lastSeenAt > this.staleAgeMs) {
|
|
418
496
|
this.entries.delete(sid);
|
|
497
|
+
this.speedSamples.delete(sid);
|
|
419
498
|
dropped++;
|
|
420
499
|
}
|
|
421
500
|
}
|
|
@@ -431,6 +510,7 @@ export class SessionUsageStore {
|
|
|
431
510
|
const oldest = this.entries.keys().next().value;
|
|
432
511
|
if (oldest === undefined) break;
|
|
433
512
|
this.entries.delete(oldest);
|
|
513
|
+
this.speedSamples.delete(oldest);
|
|
434
514
|
dlog("info", `usageStore evict ${oldest}`);
|
|
435
515
|
}
|
|
436
516
|
}
|
|
@@ -442,5 +522,6 @@ export class SessionUsageStore {
|
|
|
442
522
|
}
|
|
443
523
|
this.entries.clear();
|
|
444
524
|
this.seeded.clear();
|
|
525
|
+
this.speedSamples.clear();
|
|
445
526
|
}
|
|
446
527
|
}
|