@promptctl/cc-candybar 1.3.0 → 1.5.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 +67 -67
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +7 -0
- package/src/config/default-dsl-config.ts +119 -14
- package/src/config/dsl-types.ts +9 -0
- package/src/config/loader/globals.ts +6 -0
- package/src/daemon/cache/session-usage-store.ts +81 -0
- package/src/daemon/render-payload.ts +147 -35
- package/src/daemon/server.ts +15 -1
- package/src/daemon/verbs/state-validators.ts +5 -5
- package/src/demo/dsl.ts +7 -1
- package/src/render/action.ts +2 -2
- package/src/render/strip.ts +21 -7
- package/src/template-engine/funcs.ts +4 -4
- package/src/themes/index.ts +4 -0
- package/src/themes/policy.ts +36 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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.5.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.5.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.5.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.5.0"
|
|
98
98
|
},
|
|
99
99
|
"pnpm": {
|
|
100
100
|
"supportedArchitectures": {
|
|
@@ -386,6 +386,31 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
386
386
|
"burn.eta.warnMinutes": { kind: "literal", value: 60 },
|
|
387
387
|
"burn.eta.errorMinutes": { kind: "literal", value: 30 },
|
|
388
388
|
|
|
389
|
+
// Token throughput for the active turn — daemon-derived tok/s on three lanes
|
|
390
|
+
// (render-payload.ts: successive-render delta over the SessionUsageStore).
|
|
391
|
+
// Same absence idiom as burn: -1 is the structurally-impossible default the
|
|
392
|
+
// `formatSpeed` helper reads as "—" [LAW:no-silent-failure] (0 tok/s is a
|
|
393
|
+
// real reading, so it cannot double as the absence marker). Each lane is
|
|
394
|
+
// independently absent — `input` reads "—" mid-stream while `output` flows.
|
|
395
|
+
"speed.input": {
|
|
396
|
+
kind: "input",
|
|
397
|
+
path: "speed.input",
|
|
398
|
+
type: "number",
|
|
399
|
+
default: -1,
|
|
400
|
+
},
|
|
401
|
+
"speed.output": {
|
|
402
|
+
kind: "input",
|
|
403
|
+
path: "speed.output",
|
|
404
|
+
type: "number",
|
|
405
|
+
default: -1,
|
|
406
|
+
},
|
|
407
|
+
"speed.total": {
|
|
408
|
+
kind: "input",
|
|
409
|
+
path: "speed.total",
|
|
410
|
+
type: "number",
|
|
411
|
+
default: -1,
|
|
412
|
+
},
|
|
413
|
+
|
|
389
414
|
// Context — daemon fetches via ContextProvider; contextLeftPercentage.
|
|
390
415
|
"context.totalTokens": {
|
|
391
416
|
kind: "input",
|
|
@@ -437,6 +462,18 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
437
462
|
type: "number",
|
|
438
463
|
default: 0,
|
|
439
464
|
},
|
|
465
|
+
|
|
466
|
+
// ── Style picker state (the live powerline-shape switcher) ───────────────
|
|
467
|
+
// [LAW:one-source-of-truth] `activeStyle` reads the SAME "style" SessionState
|
|
468
|
+
// key the daemon resolves into the strip joiner per render (see
|
|
469
|
+
// effectiveStripStyle wiring in src/daemon/server.ts) — the picker's write
|
|
470
|
+
// and the render's read are one value. Empty default ⇒ the daemon's
|
|
471
|
+
// "powerline" floor is in effect and styleControl shows "(default)".
|
|
472
|
+
activeStyle: { kind: "state", key: "style", default: "" },
|
|
473
|
+
// The style menu's page cursor: −1 closed / 0..N open, mirroring how a theme
|
|
474
|
+
// picker's page key gates its reveal row. The stylePage action declares the
|
|
475
|
+
// int gate; this var reads it back for the reveal `when`.
|
|
476
|
+
stylePage: { kind: "state", key: "style-page", default: "-1" },
|
|
440
477
|
},
|
|
441
478
|
|
|
442
479
|
// ─── Segments ──────────────────────────────────────────────────────────────
|
|
@@ -569,6 +606,23 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
569
606
|
fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
|
|
570
607
|
when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
|
|
571
608
|
},
|
|
609
|
+
// Token throughput for the active turn — output / input / total tok/s, each a
|
|
610
|
+
// successive-render delta computed daemon-side (render-payload.ts); the
|
|
611
|
+
// template only formats. Declared-but-opt-in (NOT in the default root, like
|
|
612
|
+
// block/weekly/burnrate): a user adds `speed` to their layout. Each lane reads
|
|
613
|
+
// "—" when idle/between turns ([LAW:no-silent-failure] — never a stale or
|
|
614
|
+
// divide-by-zero number). Visible once the session has done any work (stable,
|
|
615
|
+
// no layout flicker); `output` is the live generation rate, `input` spikes at
|
|
616
|
+
// turn start, `total` is their sum.
|
|
617
|
+
speed: {
|
|
618
|
+
template:
|
|
619
|
+
' ⇅ out {{ template "formatSpeed" .speed.output }} · ' +
|
|
620
|
+
'in {{ template "formatSpeed" .speed.input }} · ' +
|
|
621
|
+
'tot {{ template "formatSpeed" .speed.total }} ',
|
|
622
|
+
bg: "panel",
|
|
623
|
+
fg: "foreground",
|
|
624
|
+
when: "{{ gt .session.tokens 0 }}",
|
|
625
|
+
},
|
|
572
626
|
// Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
|
|
573
627
|
// to 0, so an expired cache renders "cold" (and reads red via the ≤8
|
|
574
628
|
// arm) rather than a negative number. [LAW:dataflow-not-control-flow]
|
|
@@ -623,25 +677,60 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
623
677
|
" .metrics.sessionDuration .metrics.messageCount" +
|
|
624
678
|
" .metrics.linesAdded .metrics.linesRemoved }}",
|
|
625
679
|
},
|
|
680
|
+
// Style control — the powerline-shape switcher's trigger. [LAW:locality-or-
|
|
681
|
+
// seam] The ✦ glyph + current-style label is the REPRESENTATION; the
|
|
682
|
+
// `openStyleMenu` action is the BEHAVIOR; the name is the seam. Shows the
|
|
683
|
+
// active shape (or "(default)" when unset) and a ▸ that opens the picker
|
|
684
|
+
// row. [LAW:dataflow-not-control-flow] No display state from the provider —
|
|
685
|
+
// the label is the one "style" value the click writes and the render reads.
|
|
686
|
+
styleControl: {
|
|
687
|
+
template:
|
|
688
|
+
"✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
|
|
689
|
+
'{{ action "openStyleMenu" "▸" }}',
|
|
690
|
+
bg: "surface",
|
|
691
|
+
fg: "foreground",
|
|
692
|
+
},
|
|
693
|
+
// The expanded style picker: one full-width page (paged=false) over the
|
|
694
|
+
// `applyStyle` option domain — the 3 powerline shapes. closeOnPick folds a
|
|
695
|
+
// page-reset into the apply, so a pick reshapes the bar and closes the row
|
|
696
|
+
// in one click. The active shape is marked by the picker helper.
|
|
697
|
+
stylePicker: {
|
|
698
|
+
template: '{{ picker "applyStyle" "stylePage" true false }}',
|
|
699
|
+
bg: "surface",
|
|
700
|
+
fg: "foreground",
|
|
701
|
+
},
|
|
626
702
|
},
|
|
627
703
|
|
|
628
|
-
// Default layout —
|
|
629
|
-
// A
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
704
|
+
// Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
|
|
705
|
+
// requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
|
|
706
|
+
// the loader's authoring surface for user JSON, not this typed literal).
|
|
707
|
+
// [LAW:dataflow-not-control-flow] Two rows: an always-on control row, and a
|
|
708
|
+
// picker reveal row that EXISTS only while the style menu cursor is ≥ 0 — the
|
|
709
|
+
// row's presence is a value test on stylePage, not a branch in render code.
|
|
710
|
+
// The picker itself draws the ✕/←/→ affordances from the page + term width.
|
|
634
711
|
root: {
|
|
635
712
|
kind: "container",
|
|
636
|
-
direction: "
|
|
713
|
+
direction: "vertical",
|
|
637
714
|
children: [
|
|
638
|
-
{
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
715
|
+
{
|
|
716
|
+
kind: "container",
|
|
717
|
+
direction: "horizontal",
|
|
718
|
+
children: [
|
|
719
|
+
{ kind: "segment", name: "directory" },
|
|
720
|
+
{ kind: "segment", name: "git" },
|
|
721
|
+
{ kind: "segment", name: "model" },
|
|
722
|
+
{ kind: "segment", name: "session" },
|
|
723
|
+
{ kind: "segment", name: "today" },
|
|
724
|
+
{ kind: "segment", name: "context" },
|
|
725
|
+
{ kind: "segment", name: "toolbar" },
|
|
726
|
+
{ kind: "segment", name: "styleControl" },
|
|
727
|
+
],
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
kind: "segment",
|
|
731
|
+
name: "stylePicker",
|
|
732
|
+
when: "{{ ge (int .stylePage) 0 }}",
|
|
733
|
+
},
|
|
645
734
|
],
|
|
646
735
|
},
|
|
647
736
|
|
|
@@ -664,6 +753,17 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
664
753
|
copyDir: { copy: "{{ .current_dir }}" },
|
|
665
754
|
openProject: { open: "{{ .project_dir }}" },
|
|
666
755
|
openTranscript: { open: "{{ .transcript_path }}" },
|
|
756
|
+
|
|
757
|
+
// [LAW:locality-or-seam] The style picker's behaviors, decoupled by NAME
|
|
758
|
+
// from styleControl/stylePicker above. Three declarations, all gated by
|
|
759
|
+
// derivation (deriveActionValidators): openStyleMenu/stylePage write the
|
|
760
|
+
// page cursor (a literal page-open subsumed by the int gate); applyStyle
|
|
761
|
+
// writes the chosen shape, gated to the STRIP_STYLES allow-list because its
|
|
762
|
+
// value source is `from: "styles"`. The rendered click and the wire gate
|
|
763
|
+
// share that one source — a template cannot smuggle an un-gated style write.
|
|
764
|
+
openStyleMenu: { set: "style-page", to: "0" },
|
|
765
|
+
applyStyle: { set: "style", from: "styles" },
|
|
766
|
+
stylePage: { set: "style-page", int: true },
|
|
667
767
|
},
|
|
668
768
|
|
|
669
769
|
// [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
|
|
@@ -701,6 +801,11 @@ export const DEFAULT_DSL_CONFIG = {
|
|
|
701
801
|
// daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
|
|
702
802
|
formatEta:
|
|
703
803
|
'{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
|
|
804
|
+
// Token throughput: "N/s" (K/M-scaled, rounded) when measured (>= 0), "—"
|
|
805
|
+
// when the daemon had no projectable sample (-1). Branches on the VALUE, like
|
|
806
|
+
// formatRate; reuses formatTokenCount so the K/M scale policy has one home.
|
|
807
|
+
formatSpeed:
|
|
808
|
+
'{{ if lt . 0 }}—{{ else }}{{ template "formatTokenCount" (round .) }}/s{{ end }}',
|
|
704
809
|
// Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
|
|
705
810
|
// part is formatted by the shared formatTokenCount and joined with " + ". A
|
|
706
811
|
// `$first` flag (reassigned across if-frames) inserts the separator before all
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// references it here. The dependency is one-way (this file → action.ts), never
|
|
15
15
|
// the reverse, so that shape can be lifted out without a cycle.
|
|
16
16
|
import type { ActionDecl } from "./action.js";
|
|
17
|
+
import type { StripStyle } from "../themes/policy.js";
|
|
17
18
|
|
|
18
19
|
// [LAW:types-are-the-program] Three stages, three names.
|
|
19
20
|
//
|
|
@@ -180,6 +181,14 @@ export interface Globals {
|
|
|
180
181
|
// `sessionState.theme ?? globals.palette ?? default`, and a per-segment
|
|
181
182
|
// `palette` is an explicit override that ignores the session theme.
|
|
182
183
|
readonly palette?: string;
|
|
184
|
+
|
|
185
|
+
// [LAW:one-type-per-behavior] The config default for the powerline cap/
|
|
186
|
+
// separator SHAPE — the exact twin of `palette` one dimension over: the
|
|
187
|
+
// daemon resolves the live strip style per render as
|
|
188
|
+
// `sessionState.style ?? globals.style ?? "powerline"` (effectiveStripStyle),
|
|
189
|
+
// so a style click reshapes the bar live and a config can set the default
|
|
190
|
+
// shape without an edit-per-session.
|
|
191
|
+
readonly style?: StripStyle;
|
|
183
192
|
}
|
|
184
193
|
|
|
185
194
|
// [LAW:one-type-per-behavior] One discriminated union covers every source
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// add a key to GLOBALS_SCHEMA and Globals; the engine does the rest.
|
|
5
5
|
|
|
6
6
|
import { type Globals } from "../dsl-types.js";
|
|
7
|
+
import { STRIP_STYLES } from "../../themes/policy.js";
|
|
7
8
|
import {
|
|
9
|
+
optionalEnumSpec,
|
|
8
10
|
optionalStringSpec,
|
|
9
11
|
paletteSpec,
|
|
10
12
|
record,
|
|
@@ -23,6 +25,10 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
|
23
25
|
default_separator: optionalStringSpec(),
|
|
24
26
|
default_truncate_marker: optionalStringSpec(),
|
|
25
27
|
palette: paletteSpec(),
|
|
28
|
+
// [LAW:types-are-the-program] The strip style is a CLOSED enum (the powerline
|
|
29
|
+
// shapes the joiner can render), unlike the open-ended palette NAME — so it
|
|
30
|
+
// validates by membership and emits a JSON-Schema `enum`.
|
|
31
|
+
style: optionalEnumSpec(STRIP_STYLES),
|
|
26
32
|
},
|
|
27
33
|
};
|
|
28
34
|
|
|
@@ -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
|
}
|
|
@@ -25,7 +25,10 @@ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
|
|
|
25
25
|
import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
|
|
26
26
|
import { cacheExpiresAt } from "../segments/cache.js";
|
|
27
27
|
import type { DaemonLogger } from "./log.js";
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
SessionUsageStore,
|
|
30
|
+
SpeedObservation,
|
|
31
|
+
} from "./cache/session-usage-store.js";
|
|
29
32
|
import type { ContextProvider } from "../segments/context.js";
|
|
30
33
|
import type { MetricsProvider } from "../segments/metrics.js";
|
|
31
34
|
import type { TmuxService } from "../segments/tmux.js";
|
|
@@ -65,6 +68,7 @@ export interface RenderPayload extends ClaudeHookData {
|
|
|
65
68
|
readonly session?: SessionPayload;
|
|
66
69
|
readonly today?: TodayPayload;
|
|
67
70
|
readonly burn?: BurnPayload;
|
|
71
|
+
readonly speed?: SpeedPayload;
|
|
68
72
|
readonly block?: BlockPayload;
|
|
69
73
|
readonly weekly?: WeeklyPayload;
|
|
70
74
|
readonly cache?: CachePayload;
|
|
@@ -115,6 +119,19 @@ export interface BurnPayload {
|
|
|
115
119
|
readonly costPerHour?: number;
|
|
116
120
|
}
|
|
117
121
|
|
|
122
|
+
// [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
|
|
123
|
+
// second on each of three lanes (prompt-side input, generated output, their
|
|
124
|
+
// total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
|
|
125
|
+
// while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
|
|
126
|
+
// live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
|
|
127
|
+
// idle between turns, or a too-stale prior sample) travels as a missing field to
|
|
128
|
+
// the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
|
|
129
|
+
export interface SpeedPayload {
|
|
130
|
+
readonly input?: number;
|
|
131
|
+
readonly output?: number;
|
|
132
|
+
readonly total?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
118
135
|
export interface BlockPayload {
|
|
119
136
|
readonly nativeUtilization: number;
|
|
120
137
|
readonly resetsAt: number;
|
|
@@ -200,6 +217,15 @@ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
|
|
|
200
217
|
// $/hr is dominated by a single turn rather than a sustained burn.
|
|
201
218
|
const MIN_BURN_SECONDS = 60;
|
|
202
219
|
|
|
220
|
+
// tok/s is a delta between two successive render observations. The wall-time
|
|
221
|
+
// between them must clear a tiny floor (the clock has to have advanced — below
|
|
222
|
+
// it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
|
|
223
|
+
// wider than this means the prior sample predates an idle stretch, so the rate
|
|
224
|
+
// would be diluted by dead time. Both bounds → no reading (re-baseline silently
|
|
225
|
+
// on the next render) rather than a misleading number. [LAW:no-silent-failure]
|
|
226
|
+
const MIN_SPEED_SAMPLE_MS = 50;
|
|
227
|
+
const MAX_SPEED_SAMPLE_MS = 10 * 1000;
|
|
228
|
+
|
|
203
229
|
/**
|
|
204
230
|
* Linearly extrapolate a rate-limit window's utilization to its 100% cap.
|
|
205
231
|
* The window started `windowMs` before `resetsAtSec`; elapsed time and the
|
|
@@ -236,6 +262,64 @@ export function projectCostPerHour(
|
|
|
236
262
|
return (cost * 3600) / durationSeconds;
|
|
237
263
|
}
|
|
238
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Instantaneous tokens-per-second between two successive render observations of
|
|
267
|
+
* one cumulative token count. Returns undefined when the sample window is too
|
|
268
|
+
* small or too large to be honest (see the floor/ceiling constants) or when the
|
|
269
|
+
* count did not advance (idle / between turns — a true 0 over a real window is
|
|
270
|
+
* reported as 0, but a flat count carries no throughput to report). A real
|
|
271
|
+
* positive rate is always >= 0, so callers use -1 as the absence default — 0
|
|
272
|
+
* tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
|
|
273
|
+
*/
|
|
274
|
+
export function projectTokensPerSecond(
|
|
275
|
+
prevTokens: number,
|
|
276
|
+
prevMs: number,
|
|
277
|
+
curTokens: number,
|
|
278
|
+
nowMs: number,
|
|
279
|
+
): number | undefined {
|
|
280
|
+
const deltaMs = nowMs - prevMs;
|
|
281
|
+
if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
|
|
282
|
+
return undefined;
|
|
283
|
+
const deltaTokens = curTokens - prevTokens;
|
|
284
|
+
if (deltaTokens <= 0) return undefined;
|
|
285
|
+
return (deltaTokens * 1000) / deltaMs;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// [LAW:effects-at-boundaries] Pure fold of one speed observation into the
|
|
289
|
+
// payload's three rate lanes. No baseline (first render of a session) or every
|
|
290
|
+
// lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
|
|
291
|
+
// each lane that projects contributes its rate, each that doesn't is a missing
|
|
292
|
+
// field → the -1 default → "—".
|
|
293
|
+
function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
|
|
294
|
+
const { prev, cur } = obs;
|
|
295
|
+
if (prev === undefined) return undefined;
|
|
296
|
+
const input = projectTokensPerSecond(
|
|
297
|
+
prev.input,
|
|
298
|
+
prev.atMs,
|
|
299
|
+
cur.input,
|
|
300
|
+
cur.atMs,
|
|
301
|
+
);
|
|
302
|
+
const output = projectTokensPerSecond(
|
|
303
|
+
prev.output,
|
|
304
|
+
prev.atMs,
|
|
305
|
+
cur.output,
|
|
306
|
+
cur.atMs,
|
|
307
|
+
);
|
|
308
|
+
const total = projectTokensPerSecond(
|
|
309
|
+
prev.total,
|
|
310
|
+
prev.atMs,
|
|
311
|
+
cur.total,
|
|
312
|
+
cur.atMs,
|
|
313
|
+
);
|
|
314
|
+
if (input === undefined && output === undefined && total === undefined)
|
|
315
|
+
return undefined;
|
|
316
|
+
return {
|
|
317
|
+
...(input !== undefined && { input }),
|
|
318
|
+
...(output !== undefined && { output }),
|
|
319
|
+
...(total !== undefined && { total }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
239
323
|
// ─── Builder ─────────────────────────────────────────────────────────────────
|
|
240
324
|
|
|
241
325
|
// ─── Config-driven provider gating ───────────────────────────────────────────
|
|
@@ -428,6 +512,11 @@ export async function buildRenderPayload(
|
|
|
428
512
|
const wants = (prefix: string): boolean =>
|
|
429
513
|
anyPathStartsWith(neededInputPaths, prefix);
|
|
430
514
|
|
|
515
|
+
// [LAW:single-enforcer] One clock read feeds every projection this render —
|
|
516
|
+
// the ETA extrapolations below AND the tok/s sample window in the speed lane,
|
|
517
|
+
// so an ETA, a reset countdown, and a throughput figure all agree on "now".
|
|
518
|
+
const nowMs = (deps.clock ?? (() => new Date()))().getTime();
|
|
519
|
+
|
|
431
520
|
// [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
|
|
432
521
|
// lane is ONE shape: "needed → call provider (whose contract is to never
|
|
433
522
|
// reject — the catch makes the lane total against bugs, mapping a throw
|
|
@@ -443,40 +532,57 @@ export async function buildRenderPayload(
|
|
|
443
532
|
? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
|
|
444
533
|
: Promise.resolve(ABSENT);
|
|
445
534
|
|
|
446
|
-
const [
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
() => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
535
|
+
const [
|
|
536
|
+
gitOutcome,
|
|
537
|
+
usage,
|
|
538
|
+
today,
|
|
539
|
+
context,
|
|
540
|
+
metrics,
|
|
541
|
+
tmuxSession,
|
|
542
|
+
cacheExpiry,
|
|
543
|
+
speed,
|
|
544
|
+
] = await Promise.all([
|
|
545
|
+
lane("git", wants("git"), () =>
|
|
546
|
+
deps.gitProvider.getGitInfo(
|
|
547
|
+
cwd ?? hookData.workspace?.current_dir,
|
|
548
|
+
gitOptionsFromClosure(neededInputPaths),
|
|
549
|
+
hookData.workspace?.project_dir,
|
|
462
550
|
),
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
551
|
+
),
|
|
552
|
+
// [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
|
|
553
|
+
// a derivative of session cost and metrics duration — so wanting `burn`
|
|
554
|
+
// pulls in exactly the two lanes it is folded from.
|
|
555
|
+
lane(
|
|
556
|
+
"session",
|
|
557
|
+
wants("session.cost") || wants("session.tokens") || wants("burn"),
|
|
558
|
+
() => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
|
|
559
|
+
),
|
|
560
|
+
lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
|
|
561
|
+
lane("context", wants("context"), () =>
|
|
562
|
+
deps.contextProvider.getContextInfo(hookData),
|
|
563
|
+
),
|
|
564
|
+
lane("metrics", wants("metrics") || wants("burn"), () =>
|
|
565
|
+
deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
|
|
566
|
+
),
|
|
567
|
+
lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
|
|
568
|
+
// Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
|
|
569
|
+
// seam, so it runs alongside the other providers and stays in the shared
|
|
570
|
+
// in-flight budget rather than blocking the event loop on sync fs.
|
|
571
|
+
lane("cache", wants("cache"), () =>
|
|
572
|
+
cacheExpiresAt(hookData.transcript_path),
|
|
573
|
+
),
|
|
574
|
+
// [LAW:one-source-of-truth] tok/s folds from the SAME store the session
|
|
575
|
+
// lane reads — observeSpeed both reports the prior sample and records this
|
|
576
|
+
// render's, so it must run every render the speed segment is laid out (the
|
|
577
|
+
// first establishes the baseline that the second projects from).
|
|
578
|
+
lane("speed", wants("speed"), () =>
|
|
579
|
+
deps.usageStore.observeSpeed(
|
|
580
|
+
hookData.session_id,
|
|
581
|
+
hookData.transcript_path,
|
|
582
|
+
nowMs,
|
|
478
583
|
),
|
|
479
|
-
|
|
584
|
+
),
|
|
585
|
+
]);
|
|
480
586
|
// [LAW:effects-at-boundaries] The projections are pure folds returning data
|
|
481
587
|
// (payload fragment + failure descriptions); the log effect happens once,
|
|
482
588
|
// here, at the edge. `take` is the total fold for the single-value lanes:
|
|
@@ -505,8 +611,6 @@ export async function buildRenderPayload(
|
|
|
505
611
|
// the formatter func — a duplicate code path was retired.)
|
|
506
612
|
const fiveHour = hookData.rate_limits?.five_hour;
|
|
507
613
|
const sevenDay = hookData.rate_limits?.seven_day;
|
|
508
|
-
// [LAW:single-enforcer] One clock read feeds every projection this render.
|
|
509
|
-
const nowMs = (deps.clock ?? (() => new Date()))().getTime();
|
|
510
614
|
const blockEta = fiveHour
|
|
511
615
|
? projectEtaMinutes(
|
|
512
616
|
fiveHour.used_percentage,
|
|
@@ -532,6 +636,13 @@ export async function buildRenderPayload(
|
|
|
532
636
|
wants("burn") && burnCost != null && burnDuration != null
|
|
533
637
|
? projectCostPerHour(burnCost, burnDuration)
|
|
534
638
|
: undefined;
|
|
639
|
+
// [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
|
|
640
|
+
// effect: it read state and advanced the baseline); the rate is a pure fold of
|
|
641
|
+
// that data here at the edge. Absent observation (lane skipped/failed) or no
|
|
642
|
+
// projectable lane → no `speed` key → every lane reads its -1 default.
|
|
643
|
+
const speedObs = take(speed);
|
|
644
|
+
const speedPayload =
|
|
645
|
+
speedObs !== undefined ? projectSpeed(speedObs) : undefined;
|
|
535
646
|
|
|
536
647
|
// [LAW:one-source-of-truth] The theme variable surfaces the session's
|
|
537
648
|
// resolved theme so the toolbar/tray DSL templates can encode it into
|
|
@@ -597,6 +708,7 @@ export async function buildRenderPayload(
|
|
|
597
708
|
...(sessionPayload !== undefined && { session: sessionPayload }),
|
|
598
709
|
...(todayPayload !== undefined && { today: todayPayload }),
|
|
599
710
|
...(costPerHour !== undefined && { burn: { costPerHour } }),
|
|
711
|
+
...(speedPayload !== undefined && { speed: speedPayload }),
|
|
600
712
|
...(wants("block") &&
|
|
601
713
|
fiveHour !== undefined && {
|
|
602
714
|
block: {
|