@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,231 @@
1
+ // [LAW:locality-or-seam] A `{{ picker "applyAction" "pageAction" closeOnPick
2
+ // paged }}` call renders a width-fit grid of option cells over the action table:
3
+ // each option cell APPLIES the named option action (and, when closeOnPick, also
4
+ // resets the named page action's key in the SAME atomic write); ✕/←/→ navigate
5
+ // the page cursor. The picker is a pure RENDER helper — it owns no state and no
6
+ // new gate. It references two ALREADY-declared, ALREADY-gated actions by name
7
+ // (the apply set-option → its option domain + the theme key's allow-list gate;
8
+ // the page set-int → the page key's int gate), so the rendered clicks and the
9
+ // wire gate share one source: the action table.
10
+ //
11
+ // [LAW:one-type-per-behavior] There is no `menu`/`picker` TYPE — a picker is
12
+ // content a segment template pulls in, exactly like `action`. This is the
13
+ // successor to the deleted menu/buttons widget runtime: the same `paginate` fold
14
+ // and ←/→/✕ projection, re-expressed over named actions instead of a widget union.
15
+ //
16
+ // [LAW:dataflow-not-control-flow] Paged vs wrap is ONE value, not a mode: the
17
+ // `paged` flag selects the available width passed to `paginate` (term.cols vs
18
+ // Infinity). Infinite width ⇒ one page ⇒ the long line wraps via FlexStrip; finite
19
+ // ⇒ a sliced page with ←/→. The same fold, same emit pipeline; the width value
20
+ // (and the matching noWrap) select the shape.
21
+ //
22
+ // [LAW:one-way-deps] Lives in render/ (depends on template-engine/ + ./action.js),
23
+ // injected into the engine by the caller (registerDslConfig hands pickerFuncs in
24
+ // as data). The generic engine never imports this module.
25
+
26
+ import { RichText } from "@promptctl/rich-js";
27
+ import type { FuncMap } from "@promptctl/go-template-js";
28
+ import { toNumber } from "../var-system/types.js";
29
+ import { TERM_COLS_VAR } from "../config/dsl-types.js";
30
+ import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
31
+ import {
32
+ linkFragment,
33
+ readVar,
34
+ type ActionRuntime,
35
+ type CompiledActionDecl,
36
+ } from "./action.js";
37
+
38
+ const PICKER_CLOSE = "✕";
39
+ const PICKER_PREV = "←";
40
+ const PICKER_NEXT = "→";
41
+
42
+ // [LAW:single-enforcer] One display-width measure — rich-js's cellLength, the
43
+ // same algebra FlexStrip wraps by — so pagination fits the line the strip
44
+ // produces. No second width function.
45
+ function cellWidth(text: string): number {
46
+ return new RichText(text).cellLength;
47
+ }
48
+
49
+ // [LAW:dataflow-not-control-flow] A pure function of (item widths, available
50
+ // width, reserved width): greedy fill into pages, each reserving room for the
51
+ // ←/→/✕ affordances. The `page` value selects the slice; an oversized lone item
52
+ // gets its own page (it can't be split). Infinite width = one page (the wrap
53
+ // case — everything on one line, FlexStrip breaks it). Exported for unit testing.
54
+ export function paginate(
55
+ widths: readonly number[],
56
+ available: number,
57
+ reserve: number,
58
+ ): number[][] {
59
+ if (!Number.isFinite(available)) {
60
+ return widths.length > 0 ? [widths.map((_, i) => i)] : [];
61
+ }
62
+ const usable = Math.max(1, available - reserve);
63
+ const pages: number[][] = [];
64
+ let cur: number[] = [];
65
+ let curW = 0;
66
+ for (let i = 0; i < widths.length; i++) {
67
+ const w = widths[i]!;
68
+ if (cur.length === 0) {
69
+ cur = [i];
70
+ curW = w;
71
+ } else if (curW + 1 + w <= usable) {
72
+ cur.push(i);
73
+ curW += 1 + w;
74
+ } else {
75
+ pages.push(cur);
76
+ cur = [i];
77
+ curW = w;
78
+ }
79
+ }
80
+ if (cur.length > 0) pages.push(cur);
81
+ return pages;
82
+ }
83
+
84
+ // [LAW:dataflow-not-control-flow] Join link-bearing spans with single-space
85
+ // separators into ONE RichText (a picker is one `{{ picker }}` expression, so it
86
+ // must emit one value; the option/affordance cells ride as spans on it). `noWrap`
87
+ // is the `paged` value: a paged page is one line that must not wrap; a wrap-mode
88
+ // run is the long line FlexStrip is ALLOWED to break across lines.
89
+ function assemble(frags: readonly RichText[], paged: boolean): RichText {
90
+ const spaced: RichText[] = [];
91
+ for (const frag of frags) {
92
+ if (spaced.length > 0) spaced.push(new RichText(" "));
93
+ spaced.push(frag);
94
+ }
95
+ const assembled = RichText.fromFragments(spaced);
96
+ assembled.noWrap = paged;
97
+ assembled.end = "";
98
+ return assembled;
99
+ }
100
+
101
+ // [LAW:no-defensive-null-guards] The loader proves both picker arg names resolve
102
+ // to declared actions; this asserts the KIND each must be (apply ⇒ set-option,
103
+ // page ⇒ set-int) — a wrong kind is an author error surfaced loudly at render
104
+ // (composeWithDiagnostics shows it), not a silent empty picker.
105
+ function requireKind<K extends CompiledActionDecl["kind"]>(
106
+ runtime: ActionRuntime,
107
+ name: string,
108
+ kind: K,
109
+ shape: string,
110
+ ): Extract<CompiledActionDecl, { kind: K }> {
111
+ const action = runtime.compiled.get(name);
112
+ if (!action || action.kind !== kind) {
113
+ throw new Error(
114
+ `picker references action "${name}" which must be ${shape}, got ${action ? `a ${action.kind} action` : "no such action"}`,
115
+ );
116
+ }
117
+ return action as Extract<CompiledActionDecl, { kind: K }>;
118
+ }
119
+
120
+ // [LAW:dataflow-not-control-flow] The page value (and the live width) select
121
+ // which option cells render and which boundary arrows exist — a boundary arrow is
122
+ // an ABSENT fragment, never a skipped branch. Every affordance click is a `set`
123
+ // on the page key: ←/→ navigate (render-computed p±1), ✕ closes (-1). Each option
124
+ // click APPLIES its option AND (when closeOnPick) resets the page key to -1 in one
125
+ // atomic set-state — the picker owns the page key, so it derives the close-write
126
+ // rather than the author re-stating the key.
127
+ function renderPicker(
128
+ applyName: string,
129
+ pageName: string,
130
+ closeOnPick: boolean,
131
+ paged: boolean,
132
+ runtime: ActionRuntime,
133
+ ): RichText {
134
+ const apply = requireKind(
135
+ runtime,
136
+ applyName,
137
+ "set-option",
138
+ "a set-option action ({ set, from })",
139
+ );
140
+ const page = requireKind(
141
+ runtime,
142
+ pageName,
143
+ "set-int",
144
+ "an int action ({ set, int: true })",
145
+ );
146
+ const store = runtime.store;
147
+ if (!store) {
148
+ throw new Error(
149
+ `picker "${applyName}" rendered without a VariableStore — registerDslConfig was not given one`,
150
+ );
151
+ }
152
+ const sessionId = readVar(store, "session.id");
153
+ const current = readVar(store, apply.stateVar);
154
+ const widths = apply.options.map(cellWidth);
155
+
156
+ // ✕ is always present; ←/→ appear only on a multi-page menu. Reserve arrow
157
+ // space only after a first pass proves it overflows — reserving it
158
+ // unconditionally is self-fulfilling (a run that fits with just ✕ could be
159
+ // forced to split, making arrows appear unnecessarily). In wrap mode
160
+ // (available = Infinity) paginate yields one page, so neither pass splits.
161
+ const available = paged ? toNumber(store.read(TERM_COLS_VAR)) : Infinity;
162
+ const closeReserve = cellWidth(PICKER_CLOSE) + 1;
163
+ const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
164
+ const firstPass = paginate(widths, available, closeReserve);
165
+ const pages =
166
+ firstPass.length > 1
167
+ ? paginate(widths, available, closeReserve + arrowReserve)
168
+ : firstPass;
169
+
170
+ // [LAW:no-defensive-null-guards] The page value genuinely may be absent/empty
171
+ // (the key was never written) — parse it at this trust boundary; an out-of-range
172
+ // or unset value clamps into the existing page set, so the picker never indexes
173
+ // a non-existent page. The segment's `when` gates visibility on page >= 0.
174
+ const rawPage = parseInt(readVar(store, page.stateVar), 10);
175
+ const pageIdx = Number.isInteger(rawPage)
176
+ ? Math.max(0, Math.min(rawPage, pages.length - 1))
177
+ : 0;
178
+ const pageCells = pages[pageIdx] ?? [];
179
+
180
+ const pageUrl = (value: number): string =>
181
+ effectsUrl([
182
+ { verb: VERB_SET_STATE, args: [sessionId, page.key, String(value)] },
183
+ ]);
184
+ const optionUrl = (option: string): string =>
185
+ effectsUrl([
186
+ {
187
+ verb: VERB_SET_STATE,
188
+ args: [
189
+ sessionId,
190
+ apply.key,
191
+ option,
192
+ ...(closeOnPick ? [page.key, "-1"] : []),
193
+ ],
194
+ },
195
+ ]);
196
+
197
+ const frags: RichText[] = [linkFragment(PICKER_CLOSE, pageUrl(-1), false)];
198
+ if (pageIdx > 0) {
199
+ frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
200
+ }
201
+ for (const i of pageCells) {
202
+ const option = apply.options[i]!;
203
+ frags.push(linkFragment(option, optionUrl(option), option === current));
204
+ }
205
+ if (pageIdx < pages.length - 1) {
206
+ frags.push(linkFragment(PICKER_NEXT, pageUrl(pageIdx + 1), false));
207
+ }
208
+ return assemble(frags, paged);
209
+ }
210
+
211
+ // [LAW:dataflow-not-control-flow] One func; the two action NAMES select which
212
+ // declared effects fire, the two bools are the bounded author choices
213
+ // (closeOnPick, paged). Returns T (RichText), the single fragment go-template-js
214
+ // emits for `{{ picker … }}`.
215
+ //
216
+ // [LAW:one-way-deps] The caller injects this FuncMap into createCcCandybarEngine
217
+ // (capabilities-over-context) so the generic engine never imports the picker.
218
+ export function pickerFuncs(runtime: ActionRuntime): FuncMap {
219
+ return {
220
+ picker: {
221
+ fn: (
222
+ applyName: string,
223
+ pageName: string,
224
+ closeOnPick: boolean,
225
+ paged: boolean,
226
+ ) => renderPicker(applyName, pageName, closeOnPick, paged, runtime),
227
+ argTypes: ["string", "string", "bool", "bool"],
228
+ returnType: "T",
229
+ },
230
+ };
231
+ }
@@ -0,0 +1,51 @@
1
+ import type { RichText } from "@promptctl/rich-js";
2
+
3
+ // [LAW:single-enforcer] THE one place a horizontal cell stream is partitioned
4
+ // into vertical lines. Vertical composition lives on the CELL stream — resolved
5
+ // here, before any strip measures a cell — never inside a strip. A "\n" that
6
+ // reaches a horizontal strip is a zero-width lie: the strip measures a cell by
7
+ // its rendered cell-width, for which an embedded "\n" corrupts wrap math,
8
+ // powerline caps, and background fill. Splitting first means each emitted line
9
+ // is a clean newline-free cell run the strip can measure honestly.
10
+ //
11
+ // [LAW:one-source-of-truth] The line boundary is the literal "\n" carried in a
12
+ // cell's text — the SOLE vertical sentinel. Horizontal cell identity is already
13
+ // carried by segment boundaries and link spans, so only the vertical axis lacks
14
+ // a carrier and only it needs a sentinel.
15
+
16
+ /**
17
+ * Partition a cell stream into per-line cell groups on the "\n" sentinel.
18
+ *
19
+ * A newline-free cell passes through BY REFERENCE — the common case is
20
+ * byte-identical to handing the original stream straight to a strip. A cell
21
+ * carrying "\n" is split via `RichText.split` (span- and OSC-8-preserving, and
22
+ * it consumes the separator): the piece before the first "\n" closes the
23
+ * current line, each interior piece is a whole line of its own, and the final
24
+ * piece begins the next line. The "\n" is consumed as the partition point,
25
+ * never emitted into a line and never measured.
26
+ *
27
+ * An empty input yields `[[]]` — one empty line group, not zero — so a visible
28
+ * row whose segments all rendered nothing still produces exactly one (empty)
29
+ * line, matching the pre-substrate behavior.
30
+ */
31
+ export function splitCellsIntoLines(cells: readonly RichText[]): RichText[][] {
32
+ const lines: RichText[][] = [];
33
+ let current: RichText[] = [];
34
+ for (const cell of cells) {
35
+ // [LAW:dataflow-not-control-flow] The discriminator is whether the cell
36
+ // carries the sentinel — a value, read once. The newline-free path keeps
37
+ // the original reference so the dominant case allocates nothing new.
38
+ if (!cell.contains("\n")) {
39
+ current.push(cell);
40
+ continue;
41
+ }
42
+ const pieces = cell.split("\n");
43
+ current.push(pieces[0]!);
44
+ for (let i = 1; i < pieces.length; i++) {
45
+ lines.push(current);
46
+ current = [pieces[i]!];
47
+ }
48
+ }
49
+ lines.push(current);
50
+ return lines;
51
+ }
@@ -0,0 +1,103 @@
1
+ import {
2
+ Strip,
3
+ RichText,
4
+ Style,
5
+ PowerlineJoiner,
6
+ CapsuleJoiner,
7
+ PlainJoiner,
8
+ FlexStrip,
9
+ renderToString,
10
+ type Joiner,
11
+ type ColorSystemSpec,
12
+ } from "@promptctl/rich-js";
13
+
14
+ export interface RenderedSegmentLike {
15
+ type: string;
16
+ text: string;
17
+ bgHex?: string;
18
+ fgHex?: string;
19
+ }
20
+
21
+ export type StripStyle = "powerline" | "capsule" | "plain";
22
+
23
+ // [LAW:one-source-of-truth] Raw terminal cols we assume when the wire
24
+ // didn't give us one (older client, env-stripped spawn). RAW — not
25
+ // post-reserve — so the Claude-Code-UI reserve applies uniformly across
26
+ // wire and fallback paths (callers thread this through
27
+ // applyClaudeCodeReserve from src/utils/terminal-width).
28
+ export const DEFAULT_TERMINAL_WIDTH = 120;
29
+
30
+ export interface BuildLineOptions {
31
+ style: StripStyle;
32
+ colorCompatibility: ColorSystemSpec;
33
+ separator?: string;
34
+ // [LAW:types-are-the-program] Every render carries a width. Finite values
35
+ // wrap via FlexStrip; Number.POSITIVE_INFINITY renders one unbounded line.
36
+ // Required (not optional) so callers cannot silently drop the wire's value.
37
+ width: number;
38
+ }
39
+
40
+ function pickJoiner(style: StripStyle, separator?: string): Joiner {
41
+ // [LAW:dataflow-not-control-flow] joiner choice is data-driven; one branch
42
+ // per shape, no nested conditionals.
43
+ if (style === "capsule") return new CapsuleJoiner();
44
+ if (style === "plain") {
45
+ return new PlainJoiner(separator !== undefined ? { separator } : {});
46
+ }
47
+ return new PowerlineJoiner();
48
+ }
49
+
50
+ function toCell(seg: RenderedSegmentLike): RichText {
51
+ // Padding mirrors the legacy buildLineFromSegments: one space on each side
52
+ // of the segment text. The joiners sit between cells; padding sits inside.
53
+ const padded = ` ${seg.text} `;
54
+ const style = new Style({
55
+ bgcolor: seg.bgHex || undefined,
56
+ color: seg.fgHex || undefined,
57
+ });
58
+ return new RichText(padded, { style, end: "", noWrap: true });
59
+ }
60
+
61
+ /**
62
+ * [LAW:single-enforcer] The one place RichText cells become an ANSI byte
63
+ * string. Every render path (DSL RichText[] via the template-engine
64
+ * pipeline, buildLineStrip's input-shape adapter, debug per-segment
65
+ * serialization) flows through here. The wrap dispatch lives here too:
66
+ * finite width → FlexStrip (rich-js owns the wrap algebra); infinite
67
+ * width → Strip.
68
+ *
69
+ * [LAW:dataflow-not-control-flow] The dispatch is on the value, not on a
70
+ * flag. There is no `wrap: boolean` knob; presence of a finite width IS
71
+ * the decision.
72
+ */
73
+ export function renderStripCells(
74
+ cells: readonly RichText[],
75
+ options: BuildLineOptions,
76
+ ): string {
77
+ if (cells.length === 0) return "";
78
+ const joiner = pickJoiner(options.style, options.separator);
79
+ if (Number.isFinite(options.width)) {
80
+ const flex = new FlexStrip([...cells], { joiner });
81
+ const out = renderToString(flex, {
82
+ width: options.width,
83
+ colorSystem: options.colorCompatibility,
84
+ });
85
+ return out.endsWith("\n") ? out.slice(0, -1) : out;
86
+ }
87
+ const strip = new Strip([...cells], joiner);
88
+ return renderToString(strip, {
89
+ colorSystem: options.colorCompatibility,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * Input-shape adapter for callers that hold RenderedSegmentLike[] rather
95
+ * than pre-constructed RichText cells. Wrap behavior is identical to
96
+ * renderStripCells — the cell construction is the only difference.
97
+ */
98
+ export function buildLineStrip(
99
+ segments: readonly RenderedSegmentLike[],
100
+ options: BuildLineOptions,
101
+ ): string {
102
+ return renderStripCells(segments.map(toCell), options);
103
+ }
@@ -0,0 +1,131 @@
1
+ // Prompt-cache warmth provider.
2
+ //
3
+ // Anthropic's prompt cache has a fixed TTL (1h): each turn that reads or
4
+ // creates cache entries refreshes it, and after the TTL the next turn pays
5
+ // full cache-creation cost again. This provider answers one question — when
6
+ // does the current session's cache go cold? — by tail-reading the transcript
7
+ // for the most recent entry that touched the cache and projecting its
8
+ // timestamp forward by the TTL.
9
+ //
10
+ // [LAW:dataflow-not-control-flow] The datum is a single epoch instant, not a
11
+ // rendered string. Whether the timer shows "12m", "cold", or hides entirely,
12
+ // and what color it takes, are all functions of this one number evaluated in
13
+ // the DSL template — the same shape block/weekly use with `resetsAt`. The
14
+ // provider carries no display policy.
15
+ //
16
+ // [LAW:types-are-the-program] The return is `Outcome<number>`: a known expiry
17
+ // instant (`ok`), "no cache activity found" (`absent` — no transcript yet, or
18
+ // no cache-bearing entry), or a real read failure (`failed` — the transcript
19
+ // exists but couldn't be read). Absent becomes a missing payload field, which
20
+ // the segment's `when` predicate reads as hidden; failed reaches the payload
21
+ // boundary, the one place that logs it — there is no "0 means hidden"
22
+ // ambiguity to defend against downstream, and no failure dressed as absence.
23
+
24
+ import { ABSENT, ok, type Outcome } from "../utils/outcome.js";
25
+ import { readTail } from "../utils/transcript-fs.js";
26
+
27
+ // Anthropic prompt cache TTL. A const, not a knob: it is a property of the
28
+ // upstream cache, not of this renderer. If a future cache tier ships a
29
+ // different TTL, that is a new arm here, not a user config field.
30
+ const CACHE_TTL_MS = 60 * 60 * 1000;
31
+ const TAIL_CHUNK = 64 * 1024;
32
+ const TAIL_MAX = 1 * 1024 * 1024;
33
+
34
+ // [LAW:types-are-the-program] A cheap CANDIDATE filter, not the authority. It
35
+ // matches any line mentioning a non-zero cache-token field, which includes a
36
+ // line whose *message content* merely quotes the string (a pasted JSON snippet,
37
+ // a transcript of a review discussing these very fields). The authoritative
38
+ // check is the parsed `message.usage` value — the regex only avoids JSON.parsing
39
+ // every line; a match is verified before its timestamp is trusted. The `[1-9]`
40
+ // rejects the `":0` common case so most non-cache lines never reach the parser.
41
+ const CACHE_HIT_RE =
42
+ /"(?:cache_read_input_tokens|cache_creation_input_tokens)":[1-9]/;
43
+
44
+ // The transcript-line shape this provider reads. Untrusted JSON — every field is
45
+ // optional and narrowed at use; only positive `message.usage` cache tokens count.
46
+ interface UsageLine {
47
+ readonly timestamp?: string;
48
+ readonly message?: {
49
+ readonly usage?: {
50
+ readonly cache_read_input_tokens?: number;
51
+ readonly cache_creation_input_tokens?: number;
52
+ };
53
+ };
54
+ }
55
+
56
+ // Parse a candidate line and return its millisecond timestamp ONLY if its
57
+ // `message.usage` actually records positive cache activity. A content-only
58
+ // mention (or unparseable line) yields null, so a false-positive regex match
59
+ // can never set the timer warm.
60
+ function cacheActivityTs(line: string): number | null {
61
+ let parsed: UsageLine;
62
+ try {
63
+ parsed = JSON.parse(line) as UsageLine;
64
+ } catch {
65
+ return null;
66
+ }
67
+ const usage = parsed.message?.usage;
68
+ const positive =
69
+ (usage?.cache_read_input_tokens ?? 0) > 0 ||
70
+ (usage?.cache_creation_input_tokens ?? 0) > 0;
71
+ if (!positive) return null;
72
+ const ms = parsed.timestamp != null ? Date.parse(parsed.timestamp) : NaN;
73
+ return Number.isNaN(ms) ? null : ms;
74
+ }
75
+
76
+ /**
77
+ * Epoch *seconds* at which the session's prompt cache expires; `absent` when
78
+ * no cache-bearing transcript entry can be found; `failed` when the
79
+ * transcript exists but couldn't be read. Seconds (not millis) to match the
80
+ * unit of block/weekly `resetsAt`, so the DSL composes
81
+ * `minutesUntilReset .cache.expiresAt` with no unit translation.
82
+ */
83
+ export async function cacheExpiresAt(
84
+ transcriptPath: string,
85
+ ): Promise<Outcome<number>> {
86
+ const lastCacheMs = await findLastCacheActivityTs(transcriptPath);
87
+ if (lastCacheMs.kind !== "ok") return lastCacheMs;
88
+ return ok(Math.floor((lastCacheMs.value + CACHE_TTL_MS) / 1000));
89
+ }
90
+
91
+ // Tail-read the JSONL transcript and return the millisecond timestamp of the
92
+ // last entry with cache activity. The relevant entry is almost always within
93
+ // the final few KB, so the common case reads one TAIL_CHUNK; only a transcript
94
+ // whose last cache hit is deeper grows to TAIL_MAX. [LAW:single-enforcer] both
95
+ // reads go through the gated transcript-fs seam (readTail), so this scanner is
96
+ // bounded with every other transcript read instead of blocking the event loop
97
+ // on synchronous fs.
98
+ async function findLastCacheActivityTs(
99
+ transcriptPath: string,
100
+ ): Promise<Outcome<number>> {
101
+ for (const maxBytes of [TAIL_CHUNK, TAIL_MAX]) {
102
+ const tail = await readTail(transcriptPath, maxBytes);
103
+ // absent (no transcript yet) and failed (unreadable) both end the scan;
104
+ // the outcome carries which one happened to the payload boundary.
105
+ if (tail.kind !== "ok") return tail;
106
+ const ts = scanBufferForLastCacheTs(tail.value.buf, tail.value.fromStart);
107
+ if (ts != null) return ok(ts);
108
+ // The window reached the file start: the whole transcript is scanned, no
109
+ // hit exists — growing further would re-read the same bytes.
110
+ if (tail.value.fromStart) return ABSENT;
111
+ }
112
+ return ABSENT;
113
+ }
114
+
115
+ function scanBufferForLastCacheTs(
116
+ buf: Buffer,
117
+ bufStartsAtFileBeginning: boolean,
118
+ ): number | null {
119
+ const text = buf.toString("utf8");
120
+ const lines = text.split("\n");
121
+ // When the window doesn't start at the file beginning, the first line is
122
+ // likely a partial JSON object — skip it so we never mis-parse a fragment.
123
+ const start = bufStartsAtFileBeginning ? 0 : 1;
124
+ for (let i = lines.length - 1; i >= start; i--) {
125
+ const line = lines[i];
126
+ if (!line || !CACHE_HIT_RE.test(line)) continue; // cheap candidate filter
127
+ const ts = cacheActivityTs(line); // authoritative: parsed usage must be > 0
128
+ if (ts != null) return ts;
129
+ }
130
+ return null;
131
+ }