@promptctl/cc-candybar 1.42.1 → 1.43.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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,1256 +0,0 @@
1
- // [LAW:single-enforcer] The single place where the daemon assembles every
2
- // data field the DSL templates can read. The output of this function — the
3
- // `RenderPayload` — is fed verbatim to `registry.applyInput(...)` (inside
4
- // renderDsl), and every `kind: "input"` variable in the default DSL
5
- // config resolves its `path` against exactly this shape.
6
- //
7
- // [LAW:dataflow-not-control-flow] Variability lives in the values flowing
8
- // through here, not in hand-coded branches. The set of providers actually
9
- // invoked is selected by inspecting the DslConfig's declared input paths —
10
- // the *config* (data) chooses what runs. Templates use `when` predicates
11
- // and inline guards on the resulting values to decide what renders.
12
- //
13
- // [LAW:one-source-of-truth] `RenderPayload` is the contract between the
14
- // daemon's data-provider fleet and the DSL config's input declarations. The
15
- // default config in `src/config/default-dsl-config.ts` declares input paths
16
- // that mirror this shape; user configs MUST agree (a path that doesn't
17
- // resolve falls back to the variable's declared default).
18
-
19
- import path from "node:path";
20
- import os from "node:os";
21
- import type { ClaudeHookData } from "../utils/claude.js";
22
- import type { ClientHints } from "./protocol.js";
23
- import type { DslConfig, Globals, VariableDecl } from "../config/dsl-types.js";
24
- import { effectivePresetName, presetGlobals } from "../config/presets.js";
25
- import { EDIT_MODE_KEY, EDIT_MODE_OPEN } from "../config/loader/edit-mode.js";
26
- import {
27
- DEFAULT_CHARSET,
28
- DEFAULT_COLOR_COMPATIBILITY,
29
- } from "../render/strip.js";
30
- import {
31
- effectiveAutoWrap,
32
- effectiveLookName,
33
- effectivePadding,
34
- effectiveStripStyle,
35
- effectiveThemeName,
36
- } from "../themes/policy.js";
37
- import { walkNodes } from "../config/dsl-types.js";
38
- import { extractTemplateRefs } from "../config/dsl-loader.js";
39
- import type { GitInfo, GitInfoOptions } from "../segments/git.js";
40
- import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
41
- import { cacheExpiresAt } from "../segments/cache.js";
42
- import type { DaemonLogger } from "./log.js";
43
- import type {
44
- SessionUsageStore,
45
- SpeedObservation,
46
- } from "./cache/session-usage-store.js";
47
- import type { ContextProvider } from "../segments/context.js";
48
- import type { MetricsProvider } from "../segments/metrics.js";
49
- import type { TmuxService } from "../segments/tmux.js";
50
- import type { GitDataProvider } from "./cache/git.js";
51
- import type {
52
- Charset,
53
- ColorCompatibility,
54
- StripStyle,
55
- } from "../themes/policy.js";
56
-
57
- // ─── Effective globals ─────────────────────────────────────────────────────
58
-
59
- // [LAW:one-source-of-truth] The daemon resolves each of these exactly ONCE
60
- // per render (server.ts, before both the payload build and renderDsl's
61
- // BuildLineOptions), so the value a trigger label displays and the value
62
- // that actually shaped the render can never disagree — the same reasoning
63
- // theme/look already followed, generalized to every globals field a menu or
64
- // stepper can persist. `theme`/`look`/`style`/`autoWrap`/`padding` compose
65
- // SessionState over the config default (a session pick can diverge from the
66
- // persisted default for its own session); `charset` and `colorCompatibility`
67
- // have no SessionState half — they describe the terminal (glyph coverage,
68
- // colour depth) rather than a per-session taste, so the resolved config global
69
- // over its floor constant is their whole resolution. See CHARSETS in
70
- // themes/policy.ts for why that is a decision rather than a gap.
71
- export interface EffectiveGlobals {
72
- readonly theme: string;
73
- readonly look: string;
74
- // The active PRESET name — effectivePresetName(sessionState.preset,
75
- // globals.preset, presets), collapsed to the floor if stale. Unlike every
76
- // other field here it is not itself a display value: it is the name of the
77
- // fragment whose `globals` were merged to PRODUCE the rest of this struct,
78
- // carried alongside so a menu label states the arrangement that actually
79
- // rendered [LAW:one-source-of-truth].
80
- readonly preset: string;
81
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — the SAME "does the
82
- // active preset have accumulated rootOps right now" fact
83
- // presetIsCustomized derives, resolved alongside `preset` (not a second
84
- // lookup later) because both come from the SAME entry.state read: the
85
- // resolved name and the resolved op-log presence must trace to one
86
- // rebuild, or a diagnostic could name the wrong preset after a reload
87
- // lands mid-render. Not itself a display value either — see `preset`'s
88
- // own comment.
89
- readonly presetCustomized: boolean;
90
- readonly style: StripStyle;
91
- // [LAW:one-source-of-truth] The cell separator `plain` renders between
92
- // segments (globals.default_separator). `string | undefined`, not a resolved
93
- // string, precisely because its floor is NOT ours: PlainJoiner owns " | " and
94
- // pickJoiner already reads undefined as "use the class default", so naming a
95
- // floor here would be a second copy of a constant that lives in rich-js.
96
- // Like charset it has no SessionState half — the config global (as staged by
97
- // whatever fragment is on top) is its whole resolution.
98
- readonly separator: string | undefined;
99
- readonly charset: Charset;
100
- readonly colorCompatibility: ColorCompatibility;
101
- readonly autoWrap: boolean;
102
- readonly padding: number;
103
- }
104
-
105
- // [LAW:one-source-of-truth] THE resolution — one function, so the precedence
106
- // chain has one implementation rather than one per caller. It previously stood
107
- // as two structurally identical struct literals (the daemon's, in server.ts,
108
- // and `cc-candybar check`'s), which is two clocks: the check command's job is
109
- // to render what the daemon would render, and a rung added to one copy is a
110
- // rung silently missing from the other. The callers differ only in WHERE a
111
- // session value comes from and whether an overrides log exists to be customized
112
- // by, so both arrive as parameters and nothing else forks
113
- // [LAW:dataflow-not-control-flow].
114
- //
115
- // `sessionPick` is the reader for one SessionState key. `check` passes a
116
- // function returning null for every key — a fresh session that has never
117
- // clicked — rather than a null store, so "no session" travels as a VALUE
118
- // through the same chain a real session travels [LAW:no-mode-explosion].
119
- export function resolveEffectiveGlobals(
120
- config: DslConfig,
121
- sessionPick: (key: string) => string | null,
122
- presetCustomized: (preset: string) => boolean,
123
- ): EffectiveGlobals {
124
- // The preset resolves FIRST: every field below reads globals, and which
125
- // globals is exactly what the preset decides.
126
- const preset = effectivePresetName(
127
- sessionPick("preset"),
128
- config.globals.preset,
129
- config.presets,
130
- );
131
- const globals = presetGlobals(config, preset);
132
- // [LAW:dataflow-not-control-flow] The staged fragment is a VALUE, and "edit
133
- // mode is off" is the EMPTY value — the identity fragment, exactly as
134
- // PRESET_FLOOR's is. Every field below is resolved by the same expression
135
- // whether or not edit mode is on; only the contents of `staged` differ. This
136
- // is the whole of "no render-walk branch on edit mode": there is no branch
137
- // here either, so there is none to leak downstream.
138
- const staged: Partial<Globals> =
139
- sessionPick(EDIT_MODE_KEY) === EDIT_MODE_OPEN ? config.editGlobals : {};
140
- return {
141
- preset,
142
- presetCustomized: presetCustomized(preset),
143
- theme: effectiveThemeName(
144
- staged.palette,
145
- sessionPick("theme"),
146
- globals.palette,
147
- ),
148
- look: effectiveLookName(
149
- staged.look,
150
- sessionPick("look"),
151
- globals.look,
152
- config.looks,
153
- ),
154
- style: effectiveStripStyle(
155
- staged.style,
156
- sessionPick("style"),
157
- globals.style,
158
- ),
159
- // [LAW:one-source-of-truth] The fields with no SessionState half resolve as
160
- // `staged ?? config ?? floor` — the same chain minus the rung they do not
161
- // have, spelled with the same `??` rather than a second mechanism.
162
- separator: staged.default_separator ?? globals.default_separator,
163
- autoWrap: effectiveAutoWrap(
164
- staged.autoWrap,
165
- sessionPick("autoWrap"),
166
- globals.autoWrap,
167
- ),
168
- padding: effectivePadding(
169
- staged.padding,
170
- sessionPick("padding"),
171
- globals.padding,
172
- ),
173
- charset: staged.charset ?? globals.charset ?? DEFAULT_CHARSET,
174
- colorCompatibility:
175
- staged.colorCompatibility ??
176
- globals.colorCompatibility ??
177
- DEFAULT_COLOR_COMPATIBILITY,
178
- };
179
- }
180
-
181
- // ─── Augmented payload shape ─────────────────────────────────────────────────
182
-
183
- // [LAW:types-are-the-program] The RenderPayload extends ClaudeHookData with
184
- // daemon-computed fields. The new keys are all OPTIONAL on the type because
185
- // individual provider failures (no transcript, no git repo, no tmux) leave
186
- // their slots null/missing — and the DSL templates handle absence via
187
- // `when`/inline guards, never by branching in this code.
188
- export interface RenderPayload extends ClaudeHookData {
189
- // env-style values surfaced as paths so the DSL can read them via `input`
190
- // alongside the rest of the payload. (`kind: "env"` is also available for
191
- // arbitrary env-var lookups in user configs.)
192
- readonly home?: string;
193
-
194
- // ─── Daemon-computed augmentations ───────────────────────────────────────
195
- // The provider's null/absent return becomes a missing field; the DSL's
196
- // input-var fallback chain fills in the default.
197
-
198
- readonly git?: GitPayload;
199
- readonly tmux?: { readonly session: string };
200
- // [LAW:types-are-the-program] REQUIRED for the same reason theme/look are:
201
- // the daemon assembles it every render from sources that cannot be "not
202
- // requested" (two syscalls and one already-parsed wire hint). The fields
203
- // INSIDE it carry the real optionality — see HostPayload.
204
- readonly host: HostPayload;
205
- // [LAW:one-source-of-truth] The daemon-resolved effective theme name —
206
- // effectiveThemeName(sessionState.theme, globals.palette). The SAME value the
207
- // rendered basePalette is built from, surfaced so a trigger label can display
208
- // the active theme WITHOUT the config restating it (the label and the colors
209
- // trace to one resolution and cannot drift).
210
- // [LAW:types-are-the-program] REQUIRED, not optional: the daemon resolves it
211
- // every render and buildRenderPayload includes it unconditionally, so the
212
- // domain truth is "always present". A `?` here would let a callsite believe it
213
- // could be undefined and guard defensively against an impossibility.
214
- readonly theme: { readonly effective: string };
215
- // [LAW:one-type-per-behavior] The daemon-resolved effective LOOK name —
216
- // effectiveLookName(sessionState.look, globals.look, looks) — the exact twin
217
- // of `theme` one dimension over: the SAME name whose ThemeKey adapts the
218
- // rendered palette, surfaced so a trigger label can display the active look.
219
- // Required for the same reason as theme: resolved unconditionally per render.
220
- readonly look: { readonly effective: string };
221
- // [LAW:one-type-per-behavior] The daemon-resolved effective PRESET name —
222
- // effectivePresetName(sessionState.preset, globals.preset, presets) — theme
223
- // and look's twin one level up: the SAME name that selected the layout this
224
- // render walked and the globals it rendered with, surfaced so a preset
225
- // trigger's label can never claim an arrangement the bar is not in.
226
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — `customized` rides
227
- // alongside `effective` rather than as a sibling top-level field, mirroring
228
- // the shape a `when`-gated status segment reads (`.preset.customized`)
229
- // beside the trigger's own `.preset.effective` label. Required for the
230
- // same reason as `effective`: resolved unconditionally per render from
231
- // presetIsCustomized, never absent.
232
- readonly preset: { readonly effective: string; readonly customized: boolean };
233
- // [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
234
- // padding are theme/look's twins over the remaining persistable globals
235
- // (candybar-config-engine-71o.3) — each REQUIRED and unconditionally
236
- // present for the same reason: the daemon already resolves the value for
237
- // BuildLineOptions every render, and this is that exact value, so a
238
- // trigger's "current selection" highlight and the render it describes
239
- // trace to one resolution. See EffectiveGlobals for how each is derived.
240
- // [LAW:types-are-the-program] Unlike theme/look (open, registry-extensible
241
- // names with no closed union to narrow to), style/charset/colorCompatibility
242
- // DO have one (StripStyle/Charset/ColorCompatibility) — narrowed to it
243
- // rather than widened to `string`, so a downstream `switch` over these
244
- // fields gets real exhaustiveness checking.
245
- readonly style: { readonly effective: StripStyle };
246
- readonly charset: { readonly effective: Charset };
247
- readonly colorCompatibility: { readonly effective: ColorCompatibility };
248
- readonly autoWrap: { readonly effective: boolean };
249
- readonly padding: { readonly effective: number };
250
-
251
- // Usage-family. Each provider returns null when it has no data (no
252
- // transcript yet, no rate-limit window active, etc.); we drop the field
253
- // rather than emit zeros, so an unconfigured user sees their declared
254
- // `default`. Individual fields inside each sub-object are ALSO optional —
255
- // "we have a metrics object but messageCount couldn't be computed" is
256
- // representable distinct from "metrics object zeroed because we had to
257
- // satisfy a non-optional type." Absence flows through `applyInput`'s
258
- // fallback chain (which writes both the default value AND a last_error)
259
- // exactly like a missing top-level field.
260
- readonly session?: SessionPayload;
261
- readonly today?: TodayPayload;
262
- readonly burn?: BurnPayload;
263
- readonly speed?: SpeedPayload;
264
- readonly block?: BlockPayload;
265
- readonly weekly?: WeeklyPayload;
266
- readonly cache?: CachePayload;
267
- readonly context?: ContextPayload;
268
- readonly metrics?: MetricsPayload;
269
- }
270
-
271
- // Flattened projection of GitInfo: every field shape the parity bindings
272
- // reference. [LAW:one-type-per-behavior] Same absence policy as
273
- // MetricsPayload: every field can independently be absent (not requested,
274
- // genuinely none, or its fetch failed), and absence is PRESERVED — the DSL
275
- // input fallback chain emits the declared `default` and records a
276
- // `last_error` per field. The old coerce-to-''/0 shim erased the distinction
277
- // between "no stashes" and "stash count unknown because git failed".
278
- export interface GitPayload {
279
- readonly repoName?: string;
280
- // The repo's browsable web page (an https/http URL, credentials stripped),
281
- // derived from the same remotes read `repoName` is. Missing = the repo has no
282
- // remote a browser can open, so a template gates its link on `ne … ""`.
283
- readonly repoUrl?: string;
284
- readonly branch?: string;
285
- readonly sha?: string;
286
- readonly ahead?: number;
287
- readonly behind?: number;
288
- readonly staged?: number;
289
- readonly unstaged?: number;
290
- readonly untracked?: number;
291
- readonly conflicts?: number;
292
- readonly upstream?: string;
293
- readonly stash?: number;
294
- readonly status?: string;
295
- readonly operation?: string;
296
- readonly timeSinceCommit?: number;
297
- // Forge PR/MR. [LAW:no-silent-failure] Unlike every other git field (where
298
- // `failed` collapses to a missing key), the PR's failure is surfaced as
299
- // `prError` so the segment can render a VISIBLY DISTINCT marker — a forge
300
- // outage must not look like "no PR". The three render-distinguishable states:
301
- // open PR (prNumber/prState/prUrl present), lookup failed (prError present),
302
- // no PR (all absent → segment when-gated off).
303
- readonly prNumber?: number;
304
- readonly prState?: string;
305
- readonly prUrl?: string;
306
- readonly prError?: string;
307
- }
308
-
309
- // Which machine this session is on, and whether the user got here over the
310
- // network. The two halves have DIFFERENT provenance and that is the whole
311
- // design ([LAW:one-source-of-truth]):
312
- //
313
- // • `name`/`user` are MACHINE facts. Client and daemon are the same machine
314
- // by construction — the socket path is UID-derived and the pid mutex is
315
- // per-user — so the daemon reading them directly cannot drift from what
316
- // the client would have reported. Sending them over the wire would buy
317
- // nothing and add a second source.
318
- // • `ssh` is a SESSION fact and is the exact opposite: one daemon serves a
319
- // local session and an SSH session simultaneously, so the daemon's own env
320
- // answers for whichever shell spawned it. It can ONLY arrive as a client
321
- // hint. This is the same reasoning that makes `globals.colorCompatibility:
322
- // "auto"` deliberately unrepresentable.
323
- //
324
- // [LAW:no-silent-failure] Every field is optional because each can genuinely
325
- // be unknown, and absence is preserved rather than defaulted here: `user` when
326
- // the uid has no passwd entry, `ssh` when the client predates the hint. Both
327
- // travel as missing keys to the DSL input-fallback chain, which emits the
328
- // declared default AND records a `last_error` that `cc-candybar debug vars`
329
- // surfaces — so "we don't know" stays distinguishable from "we know it's
330
- // local", which a `?? false` here would have destroyed.
331
- export interface HostPayload {
332
- // The SHORT hostname — `os.hostname()` up to the first dot, the same
333
- // projection zsh's `%m` makes. A statusbar cell identifies a machine to a
334
- // human; the FQDN is a network address, a different fact, and a separate
335
- // field the day something needs it.
336
- readonly name?: string;
337
- // The EFFECTIVE username from the passwd database, not `$USER`. The env var
338
- // is a map that drifts (su/sudo leave it stale); the passwd entry for the
339
- // running uid is the territory. Matches zsh's `%n`.
340
- readonly user?: string;
341
- readonly ssh?: boolean;
342
- }
343
-
344
- export interface SessionPayload {
345
- readonly cost?: number;
346
- readonly tokens?: number;
347
- }
348
-
349
- export interface TodayPayload {
350
- readonly cost?: number;
351
- readonly tokens?: number;
352
- }
353
-
354
- // [LAW:one-type-per-behavior] The burn rate is the session's spend velocity —
355
- // dollars per wall-clock hour — a derivative of the same cost the `session`
356
- // segment totals, so it is its own concept, not a field bolted onto the
357
- // totals. Optional because a too-young session yields no honest rate
358
- // ([LAW:no-silent-failure] — absence over a single-turn artifact).
359
- export interface BurnPayload {
360
- readonly costPerHour?: number;
361
- }
362
-
363
- // [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
364
- // second on each of three lanes (prompt-side input, generated output, their
365
- // total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
366
- // while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
367
- // live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
368
- // idle between turns, or a too-stale prior sample) travels as a missing field to
369
- // the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
370
- export interface SpeedPayload {
371
- readonly input?: number;
372
- readonly output?: number;
373
- readonly total?: number;
374
- // [LAW:one-type-per-behavior] The recent burn-rate trend: a delimited series
375
- // of total-lane tok/s over the store's sample ring, for the `sparkline` helper.
376
- // It rides the speed lane because it folds from the SAME observation the three
377
- // instantaneous rates do, but it is INDEPENDENTLY optional — a session that
378
- // burst then went idle has no current rate yet still has a history to draw. It
379
- // travels as a string because a series cannot cross the scalar var-system seam;
380
- // the helper decodes it. Absent (no measurable in-window pair) → missing → "".
381
- readonly history?: string;
382
- }
383
-
384
- export interface BlockPayload {
385
- readonly nativeUtilization: number;
386
- readonly resetsAt: number;
387
- // [LAW:types-are-the-program] Linear projection of nativeUtilization → 100%
388
- // at the current rate, in whole minutes. Absent (not 0, not a sentinel
389
- // in the type) when the window is too young or shows no usage to project
390
- // from — the ETA's "we cannot say" state is unrepresentable as a number,
391
- // so it travels as a missing field to the DSL default. [LAW:no-silent-failure]
392
- readonly etaMinutes?: number;
393
- }
394
-
395
- export interface WeeklyPayload {
396
- readonly percentage: number;
397
- readonly resetsAt: number;
398
- // Same projection as BlockPayload.etaMinutes over the seven-day window.
399
- readonly etaMinutes?: number;
400
- }
401
-
402
- // Prompt-cache warmth. One field — the epoch-seconds expiry instant —
403
- // mirroring block/weekly `resetsAt` so the DSL composes the countdown via
404
- // `minutesUntilReset`. Absent when no cache-bearing transcript entry exists.
405
- export interface CachePayload {
406
- readonly expiresAt: number;
407
- }
408
-
409
- export interface ContextPayload {
410
- readonly totalTokens: number;
411
- readonly contextLeft: number;
412
- }
413
-
414
- // [LAW:types-are-the-program] Each metrics field can independently be absent
415
- // (transcript missing, cost block absent, response-time math undefined). The
416
- // optional fields make "no data for this dimension" distinguishable from
417
- // "real zero" — the DSL input fallback chain emits the declared `default`
418
- // for absent fields and records a `last_error` (`debug vars` surfaces it).
419
- // A coerce-to-zero shim here would erase that distinction.
420
- export interface MetricsPayload {
421
- readonly lastResponseTime?: number;
422
- readonly responseTime?: number;
423
- readonly sessionDuration?: number;
424
- readonly messageCount?: number;
425
- readonly linesAdded?: number;
426
- readonly linesRemoved?: number;
427
- }
428
-
429
- // ─── Provider dependencies ────────────────────────────────────────────────────
430
-
431
- export interface RenderPayloadDeps {
432
- readonly gitProvider: GitDataProvider;
433
- // [LAW:one-source-of-truth] One store backs BOTH the `session` and `today`
434
- // projections — they are folds over the same per-session records, not two
435
- // independent providers that could disagree.
436
- readonly usageStore: SessionUsageStore;
437
- readonly contextProvider: ContextProvider;
438
- readonly metricsProvider: MetricsProvider;
439
- readonly tmuxService: TmuxService;
440
- // [LAW:single-enforcer] The log capability for every provider lane:
441
- // buildRenderPayload is the ONE place lane failures are logged, so the
442
- // providers' interiors never log and never double-log.
443
- readonly log: DaemonLogger;
444
- // [LAW:single-enforcer] The one clock the projection math reads "now" from —
445
- // the same seam threaded to the template engine's `minutesUntilReset`, so
446
- // an ETA and the reset countdown beside it agree on the instant. Omitted ⇒
447
- // wall clock; tests inject a frozen clock for determinism.
448
- readonly clock?: () => Date;
449
- }
450
-
451
- // ─── Rate-limit projection (pure) ──────────────────────────────────────────────
452
- //
453
- // [LAW:effects-at-boundaries] The math is pure — utilization, reset instant,
454
- // window length and `now` in; minutes-to-cap out. The only effect (reading the
455
- // clock) stays in buildRenderPayload; these stay testable in isolation.
456
-
457
- // Window lengths are facts of Claude's rate-limit cadence, not config: the
458
- // five-hour block and the seven-day window.
459
- const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
460
- const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
461
- // Below this much elapsed in a window, a linear projection from one early data
462
- // point is noise — surface no ETA rather than a confidently-wrong number.
463
- const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
464
- // The same floor for the spend rate: under a minute of session wall-clock,
465
- // $/hr is dominated by a single turn rather than a sustained burn.
466
- const MIN_BURN_SECONDS = 60;
467
-
468
- // tok/s is a delta between two successive render observations. The wall-time
469
- // between them must clear a tiny floor (the clock has to have advanced — below
470
- // it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
471
- // wider than this means the prior sample predates an idle stretch, so the rate
472
- // would be diluted by dead time. Both bounds → no reading (re-baseline silently
473
- // on the next render) rather than a misleading number. [LAW:no-silent-failure]
474
- const MIN_SPEED_SAMPLE_MS = 50;
475
- const MAX_SPEED_SAMPLE_MS = 10 * 1000;
476
-
477
- /**
478
- * Linearly extrapolate a rate-limit window's utilization to its 100% cap.
479
- * The window started `windowMs` before `resetsAtSec`; elapsed time and the
480
- * used-% give a rate, and the remaining headroom divided by that rate is the
481
- * minutes-to-cap. Returns undefined when the window is too young to project
482
- * or shows no usage yet — the caller drops the field and the segment renders
483
- * "—" rather than a fabricated ETA. [LAW:no-silent-failure]
484
- */
485
- export function projectEtaMinutes(
486
- usedPercentage: number,
487
- resetsAtSec: number,
488
- windowMs: number,
489
- nowMs: number,
490
- ): number | undefined {
491
- const elapsedMs = windowMs - (resetsAtSec * 1000 - nowMs);
492
- if (elapsedMs < MIN_PROJECTABLE_ELAPSED_MS || usedPercentage <= 0)
493
- return undefined;
494
- const pctPerMs = usedPercentage / elapsedMs;
495
- const etaMs = (100 - usedPercentage) / pctPerMs;
496
- return Math.max(0, Math.round(etaMs / 60000));
497
- }
498
-
499
- /**
500
- * Session spend rate in dollars per hour: cost over wall-clock duration.
501
- * Returns undefined under a wall-clock floor where the rate is a single-turn
502
- * artifact, not a sustained burn. A real $0 over enough time is a true 0/hr,
503
- * not absence. [LAW:no-silent-failure]
504
- */
505
- export function projectCostPerHour(
506
- cost: number,
507
- durationSeconds: number,
508
- ): number | undefined {
509
- if (durationSeconds < MIN_BURN_SECONDS) return undefined;
510
- return (cost * 3600) / durationSeconds;
511
- }
512
-
513
- /**
514
- * Instantaneous tokens-per-second between two successive render observations of
515
- * one cumulative token count. Returns undefined when the sample window is too
516
- * small or too large to be honest (see the floor/ceiling constants) or when the
517
- * count did not advance (idle / between turns — a true 0 over a real window is
518
- * reported as 0, but a flat count carries no throughput to report). A real
519
- * positive rate is always >= 0, so callers use -1 as the absence default — 0
520
- * tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
521
- */
522
- export function projectTokensPerSecond(
523
- prevTokens: number,
524
- prevMs: number,
525
- curTokens: number,
526
- nowMs: number,
527
- ): number | undefined {
528
- const deltaMs = nowMs - prevMs;
529
- if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
530
- return undefined;
531
- const deltaTokens = curTokens - prevTokens;
532
- if (deltaTokens <= 0) return undefined;
533
- return (deltaTokens * 1000) / deltaMs;
534
- }
535
-
536
- // [LAW:effects-at-boundaries] Pure fold of one speed observation into the
537
- // payload's three rate lanes. No baseline (first render of a session) or every
538
- // lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
539
- // each lane that projects contributes its rate, each that doesn't is a missing
540
- // field → the -1 default → "—".
541
- function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
542
- const { prev, cur } = obs;
543
- if (prev === undefined) return undefined;
544
- const input = projectTokensPerSecond(
545
- prev.input,
546
- prev.atMs,
547
- cur.input,
548
- cur.atMs,
549
- );
550
- const output = projectTokensPerSecond(
551
- prev.output,
552
- prev.atMs,
553
- cur.output,
554
- cur.atMs,
555
- );
556
- const total = projectTokensPerSecond(
557
- prev.total,
558
- prev.atMs,
559
- cur.total,
560
- cur.atMs,
561
- );
562
- if (input === undefined && output === undefined && total === undefined)
563
- return undefined;
564
- return {
565
- ...(input !== undefined && { input }),
566
- ...(output !== undefined && { output }),
567
- ...(total !== undefined && { total }),
568
- };
569
- }
570
-
571
- // [LAW:effects-at-boundaries] Pure fold of the observation's sample ring into the
572
- // burn-rate history string. Each adjacent pair becomes one total-lane tok/s; an
573
- // un-projectable pair (no new tokens, or a gap outside the sample window) is a
574
- // real ZERO-throughput interval, not absence — the series is a string of numbers,
575
- // and 0 is the honest value for "burned nothing here" ([LAW:no-silent-failure] —
576
- // the gap is reported, not dropped to misalign the graph). Fewer than two samples
577
- // ⇒ no pair ⇒ undefined (the whole field drops to the "" default).
578
- function projectSpeedHistory(obs: SpeedObservation): string | undefined {
579
- const { samples } = obs;
580
- const rates: number[] = [];
581
- for (let i = 1; i < samples.length; i++) {
582
- const prev = samples[i - 1]!;
583
- const cur = samples[i]!;
584
- // [LAW:no-silent-failure] Only an in-window interval is a measurable reading.
585
- // An out-of-window pair — samples too close to time a rate reliably, or a
586
- // stale gap spanning idle time — is UNMEASURABLE, not zero burn, so it is
587
- // SKIPPED rather than fabricated as a 0 bar that would read as real activity.
588
- // projectTokensPerSecond stays the single formula+window authority (same
589
- // module constants); this classifier disambiguates its undefined: out-of-
590
- // window ⇒ skip, in-window ⇒ undefined means a non-positive token delta, a
591
- // genuine zero-burn reading that stays 0.
592
- const deltaMs = cur.atMs - prev.atMs;
593
- if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
594
- continue;
595
- const rate = projectTokensPerSecond(
596
- prev.total,
597
- prev.atMs,
598
- cur.total,
599
- cur.atMs,
600
- );
601
- rates.push(rate ?? 0);
602
- }
603
- if (rates.length === 0) return undefined;
604
- return rates.join(",");
605
- }
606
-
607
- // ─── Host identity ───────────────────────────────────────────────────────────
608
-
609
- /**
610
- * The short hostname: everything before the first dot, or the whole string when
611
- * there is no dot. `os.hostname()` yields an FQDN on some hosts (macOS's
612
- * `mymachine.local`, a DNS-configured server's `web1.prod.example.com`) and a
613
- * bare name on others; this makes the rendered cell identify the machine the
614
- * same way on both, which is zsh's `%m` and the projection git-taculous shows.
615
- *
616
- * [LAW:effects-at-boundaries] Pure and total — the syscall stays in readHost.
617
- */
618
- export function shortHostname(hostname: string): string {
619
- const dot = hostname.indexOf(".");
620
- return dot < 0 ? hostname : hostname.slice(0, dot);
621
- }
622
-
623
- /**
624
- * Assemble the host identity for one render.
625
- *
626
- * [LAW:effects-at-boundaries] Named `read*`, not `project*`, because it is not
627
- * pure: two syscalls happen here. That is in bounds — this module IS the
628
- * daemon's data-assembly edge, the same edge that reads `process.env.HOME`
629
- * below — and the point of the seam is that nothing downstream reads them
630
- * again.
631
- *
632
- * [LAW:no-silent-failure] A throwing syscall (a uid with no passwd entry is the
633
- * realistic case, in a stripped container) yields an ABSENT field plus a
634
- * description for the caller to log, exactly like a failed git lane — never a
635
- * fabricated name, and never an exception: the whole bar must not blank over a
636
- * cosmetic cell.
637
- */
638
- function readHost(hints: ClientHints): {
639
- readonly host: HostPayload;
640
- readonly failures: readonly string[];
641
- } {
642
- const failures: string[] = [];
643
- const attempt = (field: string, read: () => string): string | undefined => {
644
- try {
645
- const value = read();
646
- // "" is not a usable identity; treat it as absence so the DSL default
647
- // applies rather than rendering an empty `@host` fragment.
648
- return value === "" ? undefined : value;
649
- } catch (e) {
650
- failures.push(`host.${field}: ${String(e)}`);
651
- return undefined;
652
- }
653
- };
654
-
655
- const name = attempt("name", () => shortHostname(os.hostname()));
656
- const user = attempt("user", () => os.userInfo().username);
657
- return {
658
- host: {
659
- ...(name !== undefined && { name }),
660
- ...(user !== undefined && { user }),
661
- // [LAW:one-source-of-truth] Passed straight through from the parsed
662
- // hint. The daemon deliberately does NOT consult its own SSH_* env as a
663
- // fallback: that env belongs to whichever shell spawned it, so a
664
- // "helpful" fallback would confidently mislabel every session that
665
- // daemon serves. Absent hint → absent field → declared default + a
666
- // recorded last_error, which is the honest report of "not answered".
667
- ...(hints.ssh !== undefined && { ssh: hints.ssh }),
668
- },
669
- failures,
670
- };
671
- }
672
-
673
- // ─── Builder ─────────────────────────────────────────────────────────────────
674
-
675
- // ─── Config-driven provider gating ───────────────────────────────────────────
676
- //
677
- // [LAW:dataflow-not-control-flow] Whether a provider fires is selected by
678
- // the active layout. Walk from `config.root` → cells nodes → their segments →
679
- // their template strings → referenced variable names → recursive expansion through
680
- // `template`-kind vars. The transitive closure tells us which input paths
681
- // are actually reachable from a rendered segment; providers feeding paths
682
- // outside that closure do not run.
683
- //
684
- // [LAW:single-enforcer] One reachability walk owns "is this provider
685
- // needed." A declared-but-unreachable input variable (the default config
686
- // declares every built-in variable for reference completeness) contributes
687
- // no work to the hot path. Exported so the cache can compute the closure
688
- // once at registration time (config is stable per cache entry) and reuse
689
- // it across renders.
690
- export function buildNeededPrefixes(config: DslConfig): ReadonlySet<string> {
691
- // 1. Variable name → declaration index for fast lookup. Global vars first;
692
- // per-segment vars are namespaced `segName.varName` (same as runtime).
693
- const allDecls = new Map<string, VariableDecl>();
694
- for (const [name, decl] of Object.entries(config.variables)) {
695
- allDecls.set(name, decl);
696
- }
697
- for (const [segName, seg] of Object.entries(config.segments)) {
698
- if (!seg.vars) continue;
699
- for (const [varName, decl] of Object.entries(seg.vars)) {
700
- allDecls.set(`${segName}.${varName}`, decl);
701
- }
702
- }
703
-
704
- // 2. BFS from layout segments. Frontier seeds with refs from each
705
- // rendered segment's template/when/bg/fg ONLY — segment-local vars in
706
- // `seg.vars` are reached transitively via those refs (their declared
707
- // names appear in the templates that need them). Seeding from
708
- // `seg.vars` directly would mark unused per-segment template vars as
709
- // needed and pull in their providers without justification.
710
- // `visited` tracks vars whose own `template`-kind body we've already
711
- // followed.
712
- const frontier: string[] = [];
713
- const visited = new Set<string>();
714
-
715
- for (const node of walkNodes(config.root)) {
716
- // A node's `when` references variables too — seed them so a provider feeding
717
- // only a predicate (e.g. a state var gating a row/container) isn't gated out.
718
- if (node.when)
719
- for (const ref of extractTemplateRefs(node.when)) frontier.push(ref);
720
- if (node.kind !== "segment") continue;
721
- const seg = config.segments[node.name];
722
- if (!seg) continue;
723
- for (const src of [seg.template, seg.when, seg.bg, seg.fg]) {
724
- if (src) for (const ref of extractTemplateRefs(src)) frontier.push(ref);
725
- }
726
- }
727
-
728
- // 3. Walk the closure. A ref is the dotted form `a.b.c`. Two cases:
729
- // - LEAF: `ref` exactly matches a declared variable. The scope proxy
730
- // treats this as the variable read.
731
- // - NAMESPACE: `ref` is a strict prefix of declared variable names
732
- // (e.g. `git` when only `git.branch`, `git.sha`, … are declared).
733
- // The scope proxy returns a nested proxy here, which the template
734
- // can iterate / stringify / pass to functions like `toJson`. A
735
- // namespace read implicitly reaches every leaf under it, so every
736
- // `<ref>.*` declaration becomes reachable.
737
- // Both cases collapse to "expand the ref to every matching declared
738
- // name." For template-kind matches, the body refs become new frontier
739
- // items; for input-kind matches, the path is added to the closure.
740
- const inputPaths = new Set<string>();
741
- while (frontier.length > 0) {
742
- const ref = frontier.pop()!;
743
- for (const declName of expandRef(allDecls, ref)) {
744
- if (visited.has(declName)) continue;
745
- visited.add(declName);
746
- const decl = allDecls.get(declName);
747
- if (!decl) continue;
748
- if (decl.kind === "input") {
749
- inputPaths.add(decl.path);
750
- } else if (decl.kind === "template") {
751
- for (const r of extractTemplateRefs(decl.template)) {
752
- frontier.push(r);
753
- }
754
- }
755
- // Other kinds (literal/env/file/shell/time/git/state) declare their
756
- // own box without reading a payload path — they need no provider
757
- // gating because the daemon's payload builder is the only thing
758
- // this gates.
759
- }
760
- }
761
-
762
- return inputPaths;
763
- }
764
-
765
- // [LAW:single-enforcer] Mirror of the scope proxy's read semantics: a ref
766
- // resolves either to an exact variable (leaf) or to every variable under a
767
- // namespace prefix (`.git` matches `git.branch`, `git.sha`, …). Yields the
768
- // set of declared names the ref reaches.
769
- function expandRef(
770
- decls: ReadonlyMap<string, VariableDecl>,
771
- ref: string,
772
- ): readonly string[] {
773
- // Leaf — most specific declared name wins, mirroring lookupDecl's loop.
774
- let candidate = ref;
775
- while (candidate.length > 0) {
776
- if (decls.has(candidate)) return [candidate];
777
- const dot = candidate.lastIndexOf(".");
778
- if (dot < 0) break;
779
- candidate = candidate.slice(0, dot);
780
- }
781
- // Namespace — every name starting with `${ref}.` is reachable.
782
- const ns = `${ref}.`;
783
- const matches: string[] = [];
784
- for (const name of decls.keys()) {
785
- if (name.startsWith(ns)) matches.push(name);
786
- }
787
- return matches;
788
- }
789
-
790
- function anyPathStartsWith(
791
- paths: ReadonlySet<string>,
792
- prefix: string,
793
- ): boolean {
794
- for (const p of paths) {
795
- if (p === prefix || p.startsWith(prefix + ".")) return true;
796
- }
797
- return false;
798
- }
799
-
800
- // [LAW:dataflow-not-control-flow] Each `show*` flag is derived from a
801
- // specific declared input path; the closure tells us exactly which fields
802
- // the user's templates will read. Without this, GitService.getGitInfo
803
- // silently returns "" / 0 for fields whose `show*` flag isn't set (because
804
- // computing them requires extra git invocations), and a user who declares
805
- // `git.sha` or `git.staged` would see their template evaluate against
806
- // empty strings or zeros.
807
- function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
808
- const has = (path: string): boolean => needed.has(path);
809
- // `git.staged` / `git.unstaged` / `git.untracked` / `git.conflicts` all
810
- // come from one `git status --porcelain` call — any one of them turning
811
- // the flag on enables all four.
812
- const wantsWorkingTree =
813
- has("git.staged") ||
814
- has("git.unstaged") ||
815
- has("git.untracked") ||
816
- has("git.conflicts");
817
- return {
818
- ...(has("git.sha") && { showSha: true }),
819
- ...(wantsWorkingTree && { showWorkingTree: true }),
820
- ...(has("git.stash") && { showStashCount: true }),
821
- ...(has("git.upstream") && { showUpstream: true }),
822
- ...(has("git.repoName") && { showRepoName: true }),
823
- ...(has("git.repoUrl") && { showRepoUrl: true }),
824
- ...(has("git.operation") && { showOperation: true }),
825
- ...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
826
- // Any PR field laid out turns on the (network) forge lookup. Keep these in
827
- // lockstep with the projected `git.pr*` fields below.
828
- ...((has("git.prNumber") ||
829
- has("git.prState") ||
830
- has("git.prUrl") ||
831
- has("git.prError")) && { showPullRequest: true }),
832
- };
833
- }
834
-
835
- /**
836
- * Compose every render-time data source into the augmented payload that the
837
- * DSL applies to its input variables.
838
- *
839
- * Each provider runs only if its payload prefix sits in the closure
840
- * computed by `buildNeededPrefixes(config)` — the set of `kind: "input"`
841
- * paths transitively reachable from a segment in `config.root`. Merely
842
- * declaring an input variable does NOT trigger provider work; the variable
843
- * must actually be referenced by a layout-rendered segment (directly, or
844
- * via a chain of `template`-kind vars). The default config declares many
845
- * unused inputs for reference completeness — switching one on is a layout
846
- * edit, not a re-declaration.
847
- *
848
- * All needed providers run concurrently via `Promise.all`; each one's
849
- * failure becomes a missing field (handled by the DSL input fallback
850
- * chain). No provider error propagates to the caller — a single broken
851
- * source must not blank the bar.
852
- *
853
- * [LAW:no-silent-failure][LAW:single-enforcer] Every lane carries a typed
854
- * outcome (ok | absent | failed) and THIS function is the one log site:
855
- * `failed` is logged through `deps.log` and projected as a missing field,
856
- * `absent` is a missing field with nothing to log — the default lives in
857
- * the DSL declaration's `default` field, owned by the config, not buried
858
- * here.
859
- */
860
- export async function buildRenderPayload(
861
- hookData: ClaudeHookData,
862
- deps: RenderPayloadDeps,
863
- cwd: string | undefined,
864
- // [LAW:single-enforcer] The cache pre-computes the closure once at
865
- // registration; passing it in (rather than recomputing per render) keeps
866
- // the hot path free of the BFS + extractTemplateRefs cost.
867
- neededInputPaths: ReadonlySet<string>,
868
- // [LAW:one-source-of-truth] Every globals field a menu/stepper can persist,
869
- // resolved ONCE by the daemon (server.ts, before both this call and the
870
- // BuildLineOptions it renders with) and used for BOTH the actual render AND
871
- // these payload fields — so a trigger label can never disagree with what
872
- // was actually rendered. Passed in (not re-resolved here) because the
873
- // daemon already computes every one of these for renderDsl's options; this
874
- // is that same struct, threaded to the sole payload assembler.
875
- effective: EffectiveGlobals,
876
- // [LAW:locality-or-seam] The parsed client hints, NOT the raw request — this
877
- // function is downstream of the wire checkpoint and never re-sanitizes.
878
- // Separate from `effective` on purpose: that struct is resolved globals (what
879
- // the config and the session chose), this is observed session context (what
880
- // the client saw). Fusing them would put a config-precedence chain and a
881
- // trust boundary behind one name.
882
- hints: ClientHints,
883
- ): Promise<RenderPayload> {
884
- const wants = (prefix: string): boolean =>
885
- anyPathStartsWith(neededInputPaths, prefix);
886
-
887
- // [LAW:single-enforcer] One clock read feeds every projection this render —
888
- // the ETA extrapolations below AND the tok/s sample window in the speed lane,
889
- // so an ETA, a reset countdown, and a throughput figure all agree on "now".
890
- const nowMs = (deps.clock ?? (() => new Date()))().getTime();
891
-
892
- // [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
893
- // lane is ONE shape: "needed → call provider (whose contract is to never
894
- // reject — the catch makes the lane total against bugs, mapping a throw
895
- // into the same logged failure path)" or "not needed → ABSENT". The skipped
896
- // lanes resolve immediately; no variant means lanes are present/absent at
897
- // the array level (which would change the destructure shape).
898
- const lane = <T>(
899
- name: string,
900
- needed: boolean,
901
- run: () => Promise<Outcome<T>>,
902
- ): Promise<Outcome<T>> =>
903
- needed
904
- ? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
905
- : Promise.resolve(ABSENT);
906
-
907
- const [
908
- gitOutcome,
909
- usage,
910
- today,
911
- context,
912
- metrics,
913
- tmuxSession,
914
- cacheExpiry,
915
- speed,
916
- ] = await Promise.all([
917
- lane("git", wants("git"), () =>
918
- deps.gitProvider.getGitInfo(
919
- cwd ?? hookData.workspace?.current_dir,
920
- gitOptionsFromClosure(neededInputPaths),
921
- hookData.workspace?.project_dir,
922
- ),
923
- ),
924
- // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
925
- // a derivative of session cost and metrics duration — so wanting `burn`
926
- // pulls in exactly the two lanes it is folded from.
927
- lane(
928
- "session",
929
- wants("session.cost") || wants("session.tokens") || wants("burn"),
930
- () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
931
- ),
932
- lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
933
- lane("context", wants("context"), () =>
934
- deps.contextProvider.getContextInfo(hookData),
935
- ),
936
- lane("metrics", wants("metrics") || wants("burn"), () =>
937
- deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
938
- ),
939
- lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
940
- // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
941
- // seam, so it runs alongside the other providers and stays in the shared
942
- // in-flight budget rather than blocking the event loop on sync fs.
943
- lane("cache", wants("cache"), () =>
944
- cacheExpiresAt(hookData.transcript_path),
945
- ),
946
- // [LAW:one-source-of-truth] tok/s folds from the SAME store the session
947
- // lane reads — observeSpeed both reports the prior sample and records this
948
- // render's, so it must run every render the speed segment is laid out (the
949
- // first establishes the baseline that the second projects from).
950
- lane("speed", wants("speed"), () =>
951
- deps.usageStore.observeSpeed(
952
- hookData.session_id,
953
- hookData.transcript_path,
954
- nowMs,
955
- ),
956
- ),
957
- ]);
958
- // [LAW:effects-at-boundaries] The projections are pure folds returning data
959
- // (payload fragment + failure descriptions); the log effect happens once,
960
- // here, at the edge. `take` is the total fold for the single-value lanes:
961
- // ok → value, absent → undefined, failed → undefined + a failure to log.
962
- const failures: string[] = [];
963
- const take = <T>(oc: Outcome<T>): T | undefined => {
964
- if (oc.kind === "failed") {
965
- failures.push(oc.reason);
966
- return undefined;
967
- }
968
- return oc.kind === "ok" ? oc.value : undefined;
969
- };
970
-
971
- const gitProjection = projectGitInfo(gitOutcome);
972
- failures.push(...gitProjection.failures);
973
- // Ungated, like `home` below: two syscalls and a hint already in hand, so a
974
- // `wants` gate would add a branch and save nothing.
975
- const hostProjection = readHost(hints);
976
- failures.push(...hostProjection.failures);
977
- const usageValue = take(usage);
978
- const todayValue = take(today);
979
- const contextValue = take(context);
980
- const metricsValue = take(metrics);
981
- const tmuxValue = take(tmuxSession);
982
- const cacheValue = take(cacheExpiry);
983
- for (const f of failures) deps.log("warn", `provider fetch failed: ${f}`);
984
- // [LAW:dataflow-not-control-flow] block.* reads straight from hookData
985
- // alongside weekly. (The prior dedicated provider only re-derived
986
- // `minutesUntilReset(resets_at)`, which the DSL template composes via
987
- // the formatter func — a duplicate code path was retired.)
988
- const fiveHour = hookData.rate_limits?.five_hour;
989
- const sevenDay = hookData.rate_limits?.seven_day;
990
- const blockEta = fiveHour
991
- ? projectEtaMinutes(
992
- fiveHour.used_percentage,
993
- fiveHour.resets_at,
994
- FIVE_HOUR_MS,
995
- nowMs,
996
- )
997
- : undefined;
998
- const weeklyEta = sevenDay
999
- ? projectEtaMinutes(
1000
- sevenDay.used_percentage,
1001
- sevenDay.resets_at,
1002
- SEVEN_DAY_MS,
1003
- nowMs,
1004
- )
1005
- : undefined;
1006
- // [LAW:dataflow-not-control-flow] Gated by `wants("burn")` so the rate is
1007
- // computed only when a layout segment reads it; absent cost/duration (lane
1008
- // skipped or provider empty) yields no rate, never a fabricated one.
1009
- const burnCost = usageValue?.session.cost;
1010
- const burnDuration = metricsValue?.sessionDuration;
1011
- const costPerHour =
1012
- wants("burn") && burnCost != null && burnDuration != null
1013
- ? projectCostPerHour(burnCost, burnDuration)
1014
- : undefined;
1015
- // [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
1016
- // effect: it read state and advanced the baseline); the rate is a pure fold of
1017
- // that data here at the edge. Absent observation (lane skipped/failed) or no
1018
- // projectable lane → no `speed` key → every lane reads its -1 default.
1019
- const speedObs = take(speed);
1020
- // [LAW:dataflow-not-control-flow] The instantaneous rates and the burn-rate
1021
- // history fold independently from one observation — either can be present
1022
- // without the other (a fresh burst has rates but a one-sample history; an
1023
- // idle-after-burst session has a history but no current rate). Merge whatever
1024
- // each yields; the whole `speed` key drops only when both are absent.
1025
- const speedRates =
1026
- speedObs !== undefined ? projectSpeed(speedObs) : undefined;
1027
- const speedHistory =
1028
- speedObs !== undefined ? projectSpeedHistory(speedObs) : undefined;
1029
- const speedPayload =
1030
- speedRates !== undefined || speedHistory !== undefined
1031
- ? {
1032
- ...speedRates,
1033
- ...(speedHistory !== undefined && { history: speedHistory }),
1034
- }
1035
- : undefined;
1036
-
1037
- // home is always available — it's a single env-var read, no I/O cost.
1038
- // Letting the gate skip it would add a branch with no win.
1039
- // [LAW:single-enforcer] All path-shaped payload fields are normalized
1040
- // to forward-slash separators at this boundary. The DSL's directory
1041
- // template (and any user template that does prefix/relative-path math)
1042
- // assumes POSIX separators; without normalization, Windows hookData
1043
- // (current_dir = "C:\Users\Alice") would never match a forward-slash
1044
- // home prefix. Normalize *here*, not in the template — keeps DSL
1045
- // templates platform-agnostic by construction.
1046
- const home = posixify(process.env.HOME ?? process.env.USERPROFILE);
1047
- const workspace = hookData.workspace
1048
- ? {
1049
- ...hookData.workspace,
1050
- current_dir: posixify(hookData.workspace.current_dir) ?? "",
1051
- project_dir: posixify(hookData.workspace.project_dir) ?? "",
1052
- }
1053
- : hookData.workspace;
1054
-
1055
- // [LAW:types-are-the-program] Partial projections so absent provider data
1056
- // travels as missing fields all the way to applyInput. Each provider's
1057
- // null sub-fields become absent keys here; applyInput's fallback chain
1058
- // fills in the declared DSL default and records a last_error per field.
1059
- const sessionPayload: SessionPayload | undefined =
1060
- usageValue === undefined
1061
- ? undefined
1062
- : pickNonNull({
1063
- cost: usageValue.session.cost,
1064
- tokens: usageValue.session.tokens,
1065
- });
1066
- const todayPayload: TodayPayload | undefined =
1067
- todayValue === undefined
1068
- ? undefined
1069
- : { cost: todayValue.cost, tokens: todayValue.tokens };
1070
- const metricsPayload: MetricsPayload | undefined =
1071
- metricsValue === undefined
1072
- ? undefined
1073
- : pickNonNull({
1074
- lastResponseTime: metricsValue.lastResponseTime,
1075
- responseTime: metricsValue.responseTime,
1076
- sessionDuration: metricsValue.sessionDuration,
1077
- messageCount: metricsValue.messageCount,
1078
- linesAdded: metricsValue.linesAdded,
1079
- linesRemoved: metricsValue.linesRemoved,
1080
- });
1081
-
1082
- return {
1083
- ...hookData,
1084
- ...(workspace !== undefined && { workspace }),
1085
- ...(home !== undefined && { home }),
1086
- ...(gitProjection.git !== undefined && { git: gitProjection.git }),
1087
- ...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
1088
- host: hostProjection.host,
1089
- // [LAW:one-source-of-truth] Always present — the daemon resolves every
1090
- // one of these each render (for BuildLineOptions/basePalette), and these
1091
- // are those exact values. No `wants` gate: each costs nothing (already in
1092
- // hand) and a config reading e.g. `.padding.effective` must always find it.
1093
- theme: { effective: effective.theme },
1094
- look: { effective: effective.look },
1095
- preset: {
1096
- effective: effective.preset,
1097
- customized: effective.presetCustomized,
1098
- },
1099
- style: { effective: effective.style },
1100
- charset: { effective: effective.charset },
1101
- colorCompatibility: { effective: effective.colorCompatibility },
1102
- autoWrap: { effective: effective.autoWrap },
1103
- padding: { effective: effective.padding },
1104
- ...(sessionPayload !== undefined && { session: sessionPayload }),
1105
- ...(todayPayload !== undefined && { today: todayPayload }),
1106
- ...(costPerHour !== undefined && { burn: { costPerHour } }),
1107
- ...(speedPayload !== undefined && { speed: speedPayload }),
1108
- ...(wants("block") &&
1109
- fiveHour !== undefined && {
1110
- block: {
1111
- // [LAW:one-source-of-truth] Both fields read straight from the
1112
- // hookData rate-limit window; the DSL composes minutesUntilReset
1113
- // against .block.resetsAt the same way weekly does. One
1114
- // projection rule, two segments.
1115
- nativeUtilization: fiveHour.used_percentage,
1116
- resetsAt: fiveHour.resets_at,
1117
- ...(blockEta !== undefined && { etaMinutes: blockEta }),
1118
- },
1119
- }),
1120
- ...(sevenDay !== undefined && {
1121
- weekly: {
1122
- percentage: sevenDay.used_percentage,
1123
- resetsAt: sevenDay.resets_at,
1124
- ...(weeklyEta !== undefined && { etaMinutes: weeklyEta }),
1125
- },
1126
- }),
1127
- ...(cacheValue !== undefined && {
1128
- cache: { expiresAt: cacheValue },
1129
- }),
1130
- ...(contextValue !== undefined && {
1131
- context: {
1132
- totalTokens: contextValue.totalTokens,
1133
- contextLeft: contextValue.contextLeftPercentage,
1134
- },
1135
- }),
1136
- ...(metricsPayload !== undefined && { metrics: metricsPayload }),
1137
- };
1138
- }
1139
-
1140
- // [LAW:types-are-the-program] Project an object with possibly-null fields
1141
- // down to a partial whose nulls have been dropped. If every field is null
1142
- // the result is undefined — caller treats that as "provider returned but
1143
- // had nothing usable" and omits the sub-object entirely.
1144
- function pickNonNull<T extends Readonly<Record<string, number | null>>>(
1145
- src: T,
1146
- ): { [K in keyof T]?: number } | undefined {
1147
- const out: { [K in keyof T]?: number } = {};
1148
- let any = false;
1149
- for (const k of Object.keys(src) as Array<keyof T>) {
1150
- const v = src[k];
1151
- if (v !== null && v !== undefined) {
1152
- out[k] = v;
1153
- any = true;
1154
- }
1155
- }
1156
- return any ? out : undefined;
1157
- }
1158
-
1159
- // [LAW:single-enforcer] Convert backslash-separator path strings to
1160
- // forward-slash separators so DSL templates can rely on POSIX path math
1161
- // (prefix checks, trimPrefix) on every platform. Undefined and empty
1162
- // inputs pass through unchanged. The function is platform-conditional
1163
- // only on whether `\` *could* be a separator (it never can on POSIX, so
1164
- // no harm in always converting — but the explicit guard avoids touching
1165
- // strings on non-Windows callers where backslash is meaningful
1166
- // inside path components).
1167
- function posixify(s: string | undefined): string | undefined {
1168
- if (s === undefined || s.length === 0) return s;
1169
- if (path.sep !== "\\") return s;
1170
- return s.replace(/\\/g, "/");
1171
- }
1172
-
1173
- // [LAW:types-are-the-program] Project the outcome-carrying GitInfo down to
1174
- // the flat shape the DSL input paths read. A pure fold: `ok` fields become
1175
- // values, `absent` and `failed` fields become MISSING keys (the DSL input
1176
- // fallback chain fills the declared default and records a last_error), and
1177
- // every `failed` contributes a description for the boundary to log — this
1178
- // function performs no effect itself ([LAW:effects-at-boundaries]).
1179
- function projectGitInfo(outcome: Outcome<GitInfo>): {
1180
- readonly git?: GitPayload;
1181
- readonly failures: readonly string[];
1182
- } {
1183
- if (outcome.kind === "absent") return { failures: [] };
1184
- if (outcome.kind === "failed") return { failures: [outcome.reason] };
1185
-
1186
- const info = outcome.value;
1187
- const failures: string[] = [];
1188
- const field = <T>(
1189
- name: string,
1190
- oc: Outcome<T> | undefined,
1191
- ): T | undefined => {
1192
- if (oc === undefined || oc.kind === "absent") return undefined;
1193
- if (oc.kind === "failed") {
1194
- failures.push(`git.${name}: ${oc.reason}`);
1195
- return undefined;
1196
- }
1197
- return oc.value;
1198
- };
1199
-
1200
- const aheadBehind = field("aheadBehind", info.aheadBehind);
1201
- const sha = field("sha", info.sha);
1202
- const operation = field("operation", info.operation);
1203
- const timeSinceCommit = field("timeSinceCommit", info.timeSinceCommit);
1204
- const stash = field("stash", info.stashCount);
1205
- const upstream = field("upstream", info.upstream);
1206
- const repoName = field("repoName", info.repoName);
1207
- const repoUrl = field("repoUrl", info.repoUrl);
1208
-
1209
- // [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
1210
- // `failed` lookup is NOT dropped to a missing key (which the template can't
1211
- // tell apart from "no PR"). It is BOTH logged AND surfaced as `prError` so
1212
- // the segment renders a distinct marker. `absent` is still a missing key (no
1213
- // PR / no forge → segment off). The reason is the gate value the template
1214
- // tests; the same reason is logged for the operator.
1215
- const pr = info.pullRequest;
1216
- const prFields: {
1217
- prNumber?: number;
1218
- prState?: string;
1219
- prUrl?: string;
1220
- prError?: string;
1221
- } = {};
1222
- if (pr?.kind === "ok") {
1223
- prFields.prNumber = pr.value.number;
1224
- prFields.prState = pr.value.state;
1225
- prFields.prUrl = pr.value.url;
1226
- } else if (pr?.kind === "failed") {
1227
- failures.push(`git.pr: ${pr.reason}`);
1228
- prFields.prError = pr.reason;
1229
- }
1230
-
1231
- return {
1232
- git: {
1233
- branch: info.branch,
1234
- status: info.status,
1235
- ...(aheadBehind !== undefined && {
1236
- ahead: aheadBehind.ahead,
1237
- behind: aheadBehind.behind,
1238
- }),
1239
- ...(info.workingTree !== undefined && {
1240
- staged: info.workingTree.staged,
1241
- unstaged: info.workingTree.unstaged,
1242
- untracked: info.workingTree.untracked,
1243
- conflicts: info.workingTree.conflicts,
1244
- }),
1245
- ...(sha !== undefined && { sha }),
1246
- ...(operation !== undefined && { operation }),
1247
- ...(timeSinceCommit !== undefined && { timeSinceCommit }),
1248
- ...(stash !== undefined && { stash }),
1249
- ...(upstream !== undefined && { upstream }),
1250
- ...(repoName !== undefined && { repoName }),
1251
- ...(repoUrl !== undefined && { repoUrl }),
1252
- ...prFields,
1253
- },
1254
- failures,
1255
- };
1256
- }