@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.
- package/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- 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
|
-
}
|