@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,118 @@
1
+ import process from "node:process";
2
+ import tty from "node:tty";
3
+
4
+ export function getColorSupport(): "none" | "ansi" | "ansi256" | "truecolor" {
5
+ const { env } = process;
6
+
7
+ let colorEnabled = true;
8
+
9
+ if (env.NO_COLOR && env.NO_COLOR !== "") {
10
+ colorEnabled = false;
11
+ }
12
+
13
+ const forceColor = env.FORCE_COLOR;
14
+ if (forceColor && forceColor !== "") {
15
+ if (forceColor === "false" || forceColor === "0") {
16
+ return "none";
17
+ }
18
+ if (forceColor === "true" || forceColor === "1") {
19
+ return "ansi";
20
+ }
21
+ if (forceColor === "2") {
22
+ return "ansi256";
23
+ }
24
+ if (forceColor === "3") {
25
+ return "truecolor";
26
+ }
27
+ return "ansi";
28
+ }
29
+
30
+ if (!colorEnabled) {
31
+ return "none";
32
+ }
33
+
34
+ if (env.TERM === "dumb") {
35
+ return "none";
36
+ }
37
+
38
+ if (env.CI) {
39
+ if (
40
+ ["GITHUB_ACTIONS", "GITEA_ACTIONS", "CIRCLECI"].some((key) => key in env)
41
+ ) {
42
+ return "truecolor";
43
+ }
44
+ return "ansi";
45
+ }
46
+
47
+ if (env.COLORTERM === "truecolor") {
48
+ return "truecolor";
49
+ }
50
+
51
+ const truecolorTerminals = [
52
+ "xterm-kitty",
53
+ "xterm-ghostty",
54
+ "wezterm",
55
+ "alacritty",
56
+ "foot",
57
+ "contour",
58
+ ];
59
+
60
+ if (truecolorTerminals.includes(env.TERM || "")) {
61
+ return "truecolor";
62
+ }
63
+
64
+ if (env.TERM_PROGRAM) {
65
+ switch (env.TERM_PROGRAM) {
66
+ case "iTerm.app":
67
+ return "truecolor";
68
+ case "Apple_Terminal":
69
+ return "ansi256";
70
+ case "vscode":
71
+ return "truecolor";
72
+ case "Tabby":
73
+ return "truecolor";
74
+ }
75
+ }
76
+
77
+ if (/-256(color)?$/i.test(env.TERM || "")) {
78
+ return "ansi256";
79
+ }
80
+
81
+ if (/-truecolor$/i.test(env.TERM || "")) {
82
+ return "truecolor";
83
+ }
84
+
85
+ if (
86
+ /^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(
87
+ env.TERM || "",
88
+ )
89
+ ) {
90
+ return "ansi";
91
+ }
92
+
93
+ if (env.COLORTERM) {
94
+ return "ansi";
95
+ }
96
+
97
+ if (tty?.WriteStream?.prototype?.hasColors) {
98
+ try {
99
+ const colors = tty.WriteStream.prototype.hasColors();
100
+ if (!colors) {
101
+ return "none";
102
+ }
103
+
104
+ const has256Colors = tty.WriteStream.prototype.hasColors(256);
105
+ const has16mColors = tty.WriteStream.prototype.hasColors(16777216);
106
+
107
+ if (has16mColors) {
108
+ return "truecolor";
109
+ } else if (has256Colors) {
110
+ return "ansi256";
111
+ } else {
112
+ return "ansi";
113
+ }
114
+ } catch {}
115
+ }
116
+
117
+ return "ansi";
118
+ }
@@ -0,0 +1,77 @@
1
+ const CLAUDE_MODEL_PATTERN =
2
+ /^(?:(?:global|apac|au|eu|us|us-east-\d|us-west-\d|eu-west-\d|eu-central-\d)\.)?(?:anthropic\.|azure_ai\/|bedrock\/|vertex_ai\/)?claude-(?:(?<family>opus|sonnet|haiku)-(?<newMajor>\d+)(?:-(?<newMinor>\d))?|(?<oldMajor>\d+)(?:-(?<oldMinor>\d))?-(?<oldFamily>opus|sonnet|haiku))(?:[-@]\d{8})?(?:-v\d+:\d+)?(?:-latest)?$/i;
3
+
4
+ const FRIENDLY_MODEL_PATTERN =
5
+ /^(?<family>opus|sonnet|haiku)\s+(?<major>\d+)(?:\.(?<minor>\d))?$/i;
6
+
7
+ export function formatModelName(rawName: string): string {
8
+ if (!rawName) {
9
+ return "Claude";
10
+ }
11
+
12
+ // [LAW:one-source-of-truth] strip variant decorations (e.g. " (1M context)",
13
+ // "[1m]") so all callers see canonical "Family X.Y" output regardless of
14
+ // whether the input came from model.id or model.display_name.
15
+ const stripped = rawName
16
+ .trim()
17
+ .replace(/\s*\([^)]*\)\s*$/, "")
18
+ .replace(/\s*\[[^\]]*\]\s*$/, "")
19
+ .trim();
20
+
21
+ const match = stripped.match(CLAUDE_MODEL_PATTERN);
22
+ if (match?.groups) {
23
+ const { family, newMajor, newMinor, oldMajor, oldMinor, oldFamily } =
24
+ match.groups;
25
+
26
+ const modelFamily = family || oldFamily;
27
+ const major = newMajor || oldMajor;
28
+ const minor = newMinor || oldMinor;
29
+
30
+ if (modelFamily && major) {
31
+ const capitalizedFamily =
32
+ modelFamily.charAt(0).toUpperCase() +
33
+ modelFamily.slice(1).toLowerCase();
34
+ const version = minor ? `${major}.${minor}` : major;
35
+ return `${capitalizedFamily} ${version}`;
36
+ }
37
+ }
38
+
39
+ const friendly = stripped.match(FRIENDLY_MODEL_PATTERN);
40
+ if (friendly?.groups) {
41
+ const family = friendly.groups.family!;
42
+ const major = friendly.groups.major!;
43
+ const minor = friendly.groups.minor;
44
+ const capitalizedFamily =
45
+ family.charAt(0).toUpperCase() + family.slice(1).toLowerCase();
46
+ const version = minor ? `${major}.${minor}` : major;
47
+ return `${capitalizedFamily} ${version}`;
48
+ }
49
+
50
+ return stripped || rawName;
51
+ }
52
+
53
+ export function shortenModelName(formatted: string): string {
54
+ // [LAW:one-type-per-behavior] same parser, different rendering — operates on
55
+ // the canonical output of formatModelName so callers don't reparse raw IDs.
56
+ const match = formatted.match(FRIENDLY_MODEL_PATTERN);
57
+ if (!match?.groups) return formatted;
58
+ const family = match.groups.family!;
59
+ const major = match.groups.major!;
60
+ const minor = match.groups.minor;
61
+ const initial = family.charAt(0).toUpperCase();
62
+ const version = minor ? `${major}.${minor}` : major;
63
+ return `${initial}${version}`;
64
+ }
65
+
66
+ // [LAW:one-source-of-truth] Locale-grouped integer rendering. Callers that
67
+ // want "50,000" instead of "50000" go through this rather than calling
68
+ // toLocaleString() ad-hoc — the legacy context segment used the latter
69
+ // pattern inline, and the DSL formatter (template-engine/funcs.ts)
70
+ // delegates here so the two producers agree by construction.
71
+ //
72
+ // No locale argument: the default-locale behaviour is exactly what the
73
+ // legacy renderer did (`n.toLocaleString()`), so byte-parity holds with
74
+ // whatever locale the host process picks at startup.
75
+ export function formatInteger(n: number): string {
76
+ return n.toLocaleString();
77
+ }
@@ -0,0 +1,5 @@
1
+ export function debug(message: string, ...args: unknown[]): void {
2
+ if (process.env.CC_CANDYBAR_DEBUG) {
3
+ console.error(`[DEBUG] ${message}`, ...args);
4
+ }
5
+ }
@@ -0,0 +1,33 @@
1
+ // [LAW:types-are-the-program] The provider-outcome vocabulary: a data fetch
2
+ // either produced a value, found that the domain genuinely has none (no
3
+ // upstream configured, no transcript yet), or failed to get an answer at all
4
+ // (timeout, spawn error, unreadable file). Before this type, the third state
5
+ // was unrepresentable — failure had to wear a real value's clothes (0, "",
6
+ // a basename) and the lie flowed all the way to the rendered bar.
7
+ //
8
+ // [LAW:dataflow-not-control-flow] Failure is a value that flows to a
9
+ // boundary, not a swallowed branch. Producers classify once, where the
10
+ // command semantics are known; consumers fold once, at the edge where the
11
+ // log effect and the payload mapping live.
12
+ export type Outcome<T> =
13
+ | { readonly kind: "ok"; readonly value: T }
14
+ | { readonly kind: "absent" }
15
+ | { readonly kind: "failed"; readonly reason: string };
16
+
17
+ export function ok<T>(value: T): Outcome<T> {
18
+ return { kind: "ok", value };
19
+ }
20
+
21
+ export const ABSENT: Outcome<never> = { kind: "absent" };
22
+
23
+ export function failed(reason: string): Outcome<never> {
24
+ return { kind: "failed", reason };
25
+ }
26
+
27
+ // [LAW:dataflow-not-control-flow] Total fold for consumers that only need a
28
+ // value-or-fallback view (e.g. var-system's typed projection). Both non-ok
29
+ // arms collapse to the fallback; consumers that must distinguish absent from
30
+ // failed (the logging boundaries) match on `kind` instead.
31
+ export function orElse<T>(outcome: Outcome<T> | undefined, fallback: T): T {
32
+ return outcome?.kind === "ok" ? outcome.value : fallback;
33
+ }
@@ -0,0 +1,126 @@
1
+ // [LAW:single-enforcer] All hookData schema validation flows through
2
+ // validateHookData. One trust boundary, one check.
3
+ //
4
+ // [LAW:dataflow-not-control-flow] Every check runs unconditionally. Results
5
+ // accumulate into a ValidationReport — callers decide what to do with them.
6
+ // No early exits, no control-flow branches that skip checks.
7
+
8
+ import type { ClaudeHookData } from "./claude";
9
+
10
+ export interface ValidationReport {
11
+ // Required fields that were absent or had wrong types.
12
+ missingRequired: string[];
13
+ typeMismatches: Array<{ path: string; expected: string; got: string }>;
14
+ // Top-level keys not in the known schema — Anthropic may have added new fields.
15
+ unknownTopLevelFields: string[];
16
+ }
17
+
18
+ // Top-level keys Anthropic sends (plus hook_event_name, which cc-candybar adds).
19
+ // Adding a new Anthropic field here suppresses the "unknown field" log for it.
20
+ const KNOWN_TOP_LEVEL = new Set([
21
+ "hook_event_name", // cc-candybar internal
22
+ "session_id",
23
+ "session_name",
24
+ "transcript_path",
25
+ "cwd",
26
+ "model",
27
+ "workspace",
28
+ "version",
29
+ "output_style",
30
+ "cost",
31
+ "context_window",
32
+ "exceeds_200k_tokens",
33
+ "effort",
34
+ "thinking",
35
+ "rate_limits",
36
+ "vim",
37
+ "agent",
38
+ "worktree",
39
+ ]);
40
+
41
+ // Required fields: [dot-separated path, expected typeof result]
42
+ // "object" means non-null, non-array object. Checked in declaration order.
43
+ const REQUIRED_FIELDS: Array<
44
+ [string, "string" | "number" | "boolean" | "object"]
45
+ > = [
46
+ ["session_id", "string"],
47
+ ["transcript_path", "string"],
48
+ ["cwd", "string"],
49
+ ["model", "object"],
50
+ ["model.id", "string"],
51
+ ["model.display_name", "string"],
52
+ ["workspace", "object"],
53
+ ["workspace.current_dir", "string"],
54
+ ["workspace.project_dir", "string"],
55
+ ];
56
+
57
+ /**
58
+ * Validate raw hookData received over the wire against the known Anthropic schema.
59
+ *
60
+ * Returns the data typed as ClaudeHookData alongside a ValidationReport.
61
+ * Never throws — divergences are reported, not thrown. The daemon decides
62
+ * how to surface them (dlog warn/info).
63
+ *
64
+ * [LAW:no-defensive-null-guards] Validation at the trust boundary is correct.
65
+ * Everywhere else in the codebase, hookData fields are used without guards
66
+ * because this boundary guarantees their presence.
67
+ */
68
+ export function validateHookData(raw: unknown): {
69
+ data: ClaudeHookData;
70
+ report: ValidationReport;
71
+ } {
72
+ const report: ValidationReport = {
73
+ missingRequired: [],
74
+ typeMismatches: [],
75
+ unknownTopLevelFields: [],
76
+ };
77
+
78
+ const obj: Record<string, unknown> =
79
+ raw !== null && typeof raw === "object" && !Array.isArray(raw)
80
+ ? (raw as Record<string, unknown>)
81
+ : {};
82
+
83
+ for (const [path, expectedType] of REQUIRED_FIELDS) {
84
+ const value = resolvePath(obj, path);
85
+ if (value === undefined || value === null) {
86
+ report.missingRequired.push(path);
87
+ } else {
88
+ const actualType = Array.isArray(value) ? "array" : typeof value;
89
+ const mismatch =
90
+ expectedType === "object"
91
+ ? actualType !== "object"
92
+ : actualType !== expectedType;
93
+ if (mismatch) {
94
+ report.typeMismatches.push({
95
+ path,
96
+ expected: expectedType,
97
+ got: actualType,
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ for (const key of Object.keys(obj)) {
104
+ if (!KNOWN_TOP_LEVEL.has(key)) {
105
+ report.unknownTopLevelFields.push(key);
106
+ }
107
+ }
108
+
109
+ return { data: raw as ClaudeHookData, report };
110
+ }
111
+
112
+ function resolvePath(obj: Record<string, unknown>, dotPath: string): unknown {
113
+ let cur: unknown = obj;
114
+ for (const key of dotPath.split(".")) {
115
+ if (
116
+ cur === null ||
117
+ cur === undefined ||
118
+ typeof cur !== "object" ||
119
+ Array.isArray(cur)
120
+ ) {
121
+ return undefined;
122
+ }
123
+ cur = (cur as Record<string, unknown>)[key];
124
+ }
125
+ return cur;
126
+ }
@@ -0,0 +1,57 @@
1
+ // [LAW:one-source-of-truth] A keyed in-flight coalescer: while a computation
2
+ // for a given key is running, every concurrent caller for that same key shares
3
+ // the ONE in-flight promise instead of starting its own. The fs gate
4
+ // (transcript-fs.ts) bounds how many fs ops run at once; it does NOT dedupe the
5
+ // work — K concurrent renders each still launch their own (now-bounded)
6
+ // whole-tree scan. This is the missing piece: K renders trigger ONE scan, not
7
+ // K. The gate makes that one scan's cost bounded; this makes there be one.
8
+ //
9
+ // [LAW:types-are-the-program] Coalescing is expressed by ROUTING through one
10
+ // owner of "is this key already computing", not by a guard scattered at each
11
+ // callsite. The selection is dataflow — `inflight.get(key) ?? start(...)` —
12
+ // both arms yield a `Promise<T>`, so the operation (return the shared promise)
13
+ // always runs; only the value varies. There is no `if (alreadyRunning) return`
14
+ // branch that skips work.
15
+ //
16
+ // Scope of sharing is exactly the in-flight WINDOW: the entry is removed when
17
+ // the promise settles (success OR failure), so this is a coalescer, never a
18
+ // cache. A fresh call after completion starts a new computation — staleness is
19
+ // impossible because nothing is retained past settle. Result caching, when
20
+ // wanted, is a separate concern owned by the caller (the disk/LRU caches).
21
+ //
22
+ // This also dissolves the render-timeout orphaning problem
23
+ // (brandon-daemon-memory-leak-gn4.3): a render that abandons its await (the
24
+ // daemon's 200ms response timeout fires) does not cancel or duplicate the
25
+ // shared computation — there is only ever one scan in flight per key, so the
26
+ // timed-out render leaves behind the single canonical computation that the next
27
+ // render coalesces onto. A timeout therefore adds zero new fs work.
28
+ export class SingleFlight {
29
+ private readonly inflight = new Map<string, Promise<unknown>>();
30
+
31
+ run<T>(key: string, factory: () => Promise<T>): Promise<T> {
32
+ return (
33
+ (this.inflight.get(key) as Promise<T> | undefined) ??
34
+ this.start(key, factory)
35
+ );
36
+ }
37
+
38
+ private start<T>(key: string, factory: () => Promise<T>): Promise<T> {
39
+ const promise = factory();
40
+ this.inflight.set(key, promise);
41
+ // Deregister on settle. The identity check guards the (impossible-by-key
42
+ // but cheap-to-prove) case where a newer promise has replaced this one:
43
+ // only the promise that registered itself clears itself.
44
+ const deregister = (): void => {
45
+ if (this.inflight.get(key) === promise) this.inflight.delete(key);
46
+ };
47
+ promise.then(deregister, deregister);
48
+ return promise;
49
+ }
50
+
51
+ // Number of computations currently in flight — a read-only observability
52
+ // surface for tests asserting the coalescing contract (one in-flight entry
53
+ // per key). Never read as control flow.
54
+ get size(): number {
55
+ return this.inflight.size;
56
+ }
57
+ }
@@ -0,0 +1,43 @@
1
+ // [LAW:single-enforcer] Terminal width has exactly one authoritative source:
2
+ // the live client's shell context (env / ioctl), captured at the wire boundary
3
+ // and threaded through as request data. This module is a pure resolver from
4
+ // (caller-supplied hint, ambient env, stderr TTY) to "width with reserve
5
+ // applied, or null." Subprocess-based fallbacks belong at the wire boundary,
6
+ // not here. stderr (not stdout) is the TTY-side fallback: when this resolver
7
+ // runs in a hook context, stdout is the captured statusline pipe while stderr
8
+ // stays attached to the parent terminal.
9
+ //
10
+ // [LAW:dataflow-not-control-flow] The function always runs the same code path.
11
+ // Variability lives in the inputs (hint set or not, env set or not, stderr a
12
+ // TTY or not), never in whether work runs.
13
+
14
+ // @info Reserves characters for Claude Code's right-side UI messages
15
+ // (e.g., "Current: 2.1.78 · latest: 2.1.78", "Thinking off")
16
+ const RESERVED_CHARS = 45;
17
+
18
+ // [LAW:single-enforcer] The canonical raw-cols → usable-cols transform.
19
+ // Every consumer that needs to honor Claude Code's overlay routes through
20
+ // here; there is no parallel `cols - 45` math anywhere. Exposed so callers
21
+ // that already have a raw width (e.g. the daemon's wire-fallback path,
22
+ // the demo reading process.stdout.columns) can apply the reserve without
23
+ // re-entering the env/stderr resolution chain in getTerminalWidth.
24
+ export function applyClaudeCodeReserve(rawCols: number): number {
25
+ return Math.max(1, rawCols - RESERVED_CHARS);
26
+ }
27
+
28
+ export function getTerminalWidth(termColsHint?: number): number | null {
29
+ if (termColsHint && termColsHint > 0)
30
+ return applyClaudeCodeReserve(termColsHint);
31
+
32
+ const envColumns = process.env.COLUMNS;
33
+ if (envColumns) {
34
+ const parsed = parseInt(envColumns, 10);
35
+ if (!isNaN(parsed) && parsed > 0) return applyClaudeCodeReserve(parsed);
36
+ }
37
+
38
+ if (process.stderr.columns && process.stderr.columns > 0) {
39
+ return applyClaudeCodeReserve(process.stderr.columns);
40
+ }
41
+
42
+ return null;
43
+ }
@@ -0,0 +1,11 @@
1
+ export const ESC = String.fromCharCode(27);
2
+ const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
3
+ export const ANSI_SPLIT = new RegExp(`(${ESC}\\[[0-9;]*m)`);
4
+
5
+ export function stripAnsi(str: string): string {
6
+ return str.replace(ANSI_REGEX, "");
7
+ }
8
+
9
+ export function visibleLength(str: string): number {
10
+ return stripAnsi(str).length;
11
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ open as fsOpen,
3
+ readdir as fsReaddir,
4
+ readFile as fsReadFile,
5
+ stat as fsStat,
6
+ } from "node:fs/promises";
7
+ import type { FileHandle } from "node:fs/promises";
8
+
9
+ import { ABSENT, failed, ok, type Outcome } from "./outcome";
10
+
11
+ // [LAW:single-enforcer] One owner of the transcript-scanning in-flight-I/O
12
+ // budget. Every readdir/stat/readFile over the ~/.claude/projects tree passes
13
+ // through this module's limiter, so the number of concurrent libuv fs requests
14
+ // is bounded by a constant no matter how many renders fan out at once. The OOM
15
+ // heap proved the illegal state this forbids: ~3046 FSReqPromise pending at
16
+ // once, each pinning a parked await-stack.
17
+ //
18
+ // [LAW:types-are-the-program] The bound is enforced by ROUTING, not by a
19
+ // post-hoc guard: the transcript path imports these gated primitives instead of
20
+ // node:fs/promises, so "thousands of stats/reads in flight" is unrepresentable
21
+ // rather than merely checked. There is no `if (tooMany)` anywhere — the same
22
+ // fan-out runs every render; the limiter only decides *when* each op dispatches.
23
+
24
+ // 8 = 2× the libuv default UV_THREADPOOL_SIZE (4). Two dispatched ops per worker
25
+ // keeps every threadpool thread fed without a queue-drain stall between syscalls,
26
+ // while peak in-flight memory stays O(threadpool) rather than O(transcript count).
27
+ const TRANSCRIPT_FS_CONCURRENCY = 8;
28
+
29
+ interface Waiter {
30
+ readonly wake: () => void;
31
+ next: Waiter | null;
32
+ }
33
+
34
+ // A counting semaphore that runs at most `max` thunks concurrently. Slots are
35
+ // handed off directly to the next waiter on release (never incremented while a
36
+ // waiter is parked), so admission can never exceed `max` — the over-admission
37
+ // race of an increment-then-wake design is structurally absent.
38
+ class Limiter {
39
+ private slots: number;
40
+ // FIFO wait queue as a singly-linked list: enqueue at `tail`, dequeue at
41
+ // `head`, both O(1) with no array reindexing (Array.shift would be O(n), so a
42
+ // drain of the thousands-of-queued-ops burst this limiter exists to absorb
43
+ // would be O(n²) on the render hot path). A dequeued node is immediately
44
+ // unreferenced, so a continuously-saturated queue retains only the waiters
45
+ // currently parked — no consumed-prefix accumulates, unlike a head-index
46
+ // array that only reclaims on full drain.
47
+ private head: Waiter | null = null;
48
+ private tail: Waiter | null = null;
49
+
50
+ constructor(max: number) {
51
+ this.slots = max;
52
+ }
53
+
54
+ async run<T>(task: () => Promise<T>): Promise<T> {
55
+ await this.acquire();
56
+ try {
57
+ return await task();
58
+ } finally {
59
+ this.release();
60
+ }
61
+ }
62
+
63
+ private async acquire(): Promise<void> {
64
+ if (this.slots > 0) {
65
+ this.slots--;
66
+ return;
67
+ }
68
+ // No slot free — park until release() hands one to us. The slot is
69
+ // transferred directly, so we must NOT decrement again on resume.
70
+ await new Promise<void>((wake) => {
71
+ const node: Waiter = { wake, next: null };
72
+ if (this.tail) this.tail.next = node;
73
+ else this.head = node;
74
+ this.tail = node;
75
+ });
76
+ }
77
+
78
+ private release(): void {
79
+ const node = this.head;
80
+ if (!node) {
81
+ // No one waiting — return the slot to the pool.
82
+ this.slots++;
83
+ return;
84
+ }
85
+ // Hand the slot directly to the next waiter; the dequeued node is dropped.
86
+ this.head = node.next;
87
+ if (!this.head) this.tail = null;
88
+ node.wake();
89
+ }
90
+ }
91
+
92
+ const gate = new Limiter(TRANSCRIPT_FS_CONCURRENCY);
93
+
94
+ // Wrap an async fs primitive so every call flows through the shared gate. The
95
+ // `(...args: never[])` bound is the maximally-permissive function constraint
96
+ // (parameters are contravariant, so `never[]` accepts any arg list) — it admits
97
+ // every fs/promises overload, not rejects them. Callers see the original
98
+ // overloaded type `F`; the cast is needed because TS can't prove a generic
99
+ // wrapper preserves an overload set, but each fs overload is a valid `fn(...)`
100
+ // call, so the wrap is sound (verified: `tsc --noEmit` passes against the
101
+ // multi-overload call sites in claude.ts/cache.ts).
102
+ function gated<F extends (...args: never[]) => Promise<unknown>>(fn: F): F {
103
+ return ((...args: Parameters<F>) =>
104
+ gate.run(() => fn(...args))) as unknown as F;
105
+ }
106
+
107
+ export const readdir = gated(fsReaddir);
108
+ export const readFile = gated(fsReadFile);
109
+ export const stat = gated(fsStat);
110
+
111
+ // [LAW:single-enforcer] A bounded tail read through the SAME gate: the last
112
+ // `maxBytes` of a file (the whole file when smaller), plus whether that window
113
+ // reaches the file start. The open→stat→read→close runs under one gate slot, so
114
+ // a tail read counts as one in-flight op exactly like a readFile — a transcript
115
+ // scanner that grows its window backward must use THIS, not raw node:fs, or it
116
+ // reintroduces the unbounded-fs state the gate forbids.
117
+ //
118
+ // [LAW:no-silent-failure] The file not existing is the expected, every-render
119
+ // case for a fresh session (no transcript yet) — `absent`. Any other error
120
+ // (permissions, I/O) is a real read failure — `failed`, carrying its reason
121
+ // to whichever boundary owns the log effect. Folding both into one null made
122
+ // a broken transcript indistinguishable from a missing one.
123
+ export async function readTail(
124
+ path: string,
125
+ maxBytes: number,
126
+ ): Promise<Outcome<{ buf: Buffer; fromStart: boolean }>> {
127
+ return gate.run(async () => {
128
+ let fh: FileHandle | null = null;
129
+ try {
130
+ fh = await fsOpen(path, "r");
131
+ const { size } = await fh.stat();
132
+ const start = Math.max(0, size - maxBytes);
133
+ const buf = Buffer.alloc(size - start);
134
+ // [LAW:no-silent-fallbacks] A single read may return short — the scanner
135
+ // would then parse a zero-padded tail and miss cache activity. Loop until
136
+ // the window is filled or EOF; on a short final read (the file shrank
137
+ // under us) return only the bytes actually read, never the zero padding.
138
+ let off = 0;
139
+ while (off < buf.length) {
140
+ const { bytesRead } = await fh.read(
141
+ buf,
142
+ off,
143
+ buf.length - off,
144
+ start + off,
145
+ );
146
+ if (bytesRead === 0) break;
147
+ off += bytesRead;
148
+ }
149
+ return ok({
150
+ buf: off === buf.length ? buf : buf.subarray(0, off),
151
+ fromStart: start === 0,
152
+ });
153
+ } catch (e) {
154
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") return ABSENT;
155
+ return failed(
156
+ `readTail ${path}: ${e instanceof Error ? e.message : String(e)}`,
157
+ );
158
+ } finally {
159
+ await fh?.close();
160
+ }
161
+ });
162
+ }
@@ -0,0 +1,24 @@
1
+ export { VariableStore, type VarNode } from "./store";
2
+ export {
3
+ type VarType,
4
+ type VarValue,
5
+ typeOf,
6
+ toString,
7
+ toNumber,
8
+ toBool,
9
+ } from "./types";
10
+ export {
11
+ SourceRegistry,
12
+ parseDuration,
13
+ formatGoTime,
14
+ MIN_SHELL_TTL_MS,
15
+ type CachePolicy,
16
+ type ShellOptions,
17
+ type FileOptions,
18
+ type TemplateOptions,
19
+ type TimeOptions,
20
+ type GitField,
21
+ type GitOptions,
22
+ type StateOptions,
23
+ type LastError,
24
+ } from "./sources";