@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,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { debug } from "../utils/logger";
4
+ import type { DaemonLogger } from "./log";
5
+ import type { SessionSnapshot, SessionStorage } from "./session-state";
6
+
7
+ // [LAW:locality-or-seam] Logging is injected, not hard-wired to daemon.log.
8
+ // The daemon passes `dlog`; tests and non-daemon callers take this quiet
9
+ // default, which stays silent unless CC_CANDYBAR_DEBUG is set — so unit tests
10
+ // never open the real daemon log stream.
11
+ const quietLogger: DaemonLogger = (_level, message) => debug(message);
12
+
13
+ // [LAW:no-silent-fallbacks] Corrupt/missing file → empty state is the *defined*
14
+ // recovery, not a hidden fallback to different data: an empty store re-rolls
15
+ // random picks exactly as a first-ever boot would. Anything that isn't the
16
+ // expected sessionId→key→value shape is rejected here so the store never
17
+ // hydrates from a half-written or hand-edited file.
18
+ function isSnapshot(value: unknown): value is SessionSnapshot {
19
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
20
+ return false;
21
+ }
22
+ for (const kv of Object.values(value)) {
23
+ if (kv === null || typeof kv !== "object" || Array.isArray(kv))
24
+ return false;
25
+ for (const leaf of Object.values(kv)) {
26
+ if (typeof leaf !== "string") return false;
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+
32
+ // [LAW:single-enforcer] The debounce + atomic write lives here, not in
33
+ // SessionState. The store calls save() on every mutation; this coalesces the
34
+ // bursty 22-session × 1 Hz write load into at most one disk write per window.
35
+ export class FileSessionStorage implements SessionStorage {
36
+ private timer: ReturnType<typeof setTimeout> | null = null;
37
+ private pending: SessionSnapshot | null = null;
38
+
39
+ constructor(
40
+ private readonly filePath: string,
41
+ private readonly debounceMs: number = 500,
42
+ private readonly logger: DaemonLogger = quietLogger,
43
+ ) {}
44
+
45
+ load(): SessionSnapshot {
46
+ let raw: string;
47
+ try {
48
+ raw = fs.readFileSync(this.filePath, "utf8");
49
+ } catch (e) {
50
+ // [LAW:no-silent-fallbacks] A missing file is the expected first-boot
51
+ // recovery (silent → empty). Any other read failure (EACCES, EIO) is an
52
+ // anomaly worth surfacing before recovering to empty.
53
+ const code = (e as NodeJS.ErrnoException).code;
54
+ if (code !== "ENOENT") {
55
+ this.logger(
56
+ "warn",
57
+ `session-state read failed (${code}); starting empty`,
58
+ );
59
+ }
60
+ return {};
61
+ }
62
+ try {
63
+ const parsed: unknown = JSON.parse(raw);
64
+ if (isSnapshot(parsed)) return parsed;
65
+ this.logger(
66
+ "warn",
67
+ `session-state load: unexpected shape, starting empty`,
68
+ );
69
+ return {};
70
+ } catch {
71
+ this.logger("warn", `session-state load: corrupt JSON, starting empty`);
72
+ return {};
73
+ }
74
+ }
75
+
76
+ save(snapshot: SessionSnapshot): void {
77
+ this.pending = snapshot;
78
+ if (this.timer) return;
79
+ this.timer = setTimeout(() => this.flush(), this.debounceMs);
80
+ this.timer.unref();
81
+ }
82
+
83
+ flush(): void {
84
+ if (this.timer) {
85
+ clearTimeout(this.timer);
86
+ this.timer = null;
87
+ }
88
+ if (this.pending === null) return;
89
+ const snapshot = this.pending;
90
+ try {
91
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
92
+ const tmp = `${this.filePath}.tmp`;
93
+ // [LAW:single-enforcer] Daemon runtime files are owner-only (0o600), like
94
+ // pid/spawn.lock. Session state carries conversation identifiers, so it
95
+ // gets the same perms — chmod defeats umask and re-perms a reused tmp.
96
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), { mode: 0o600 });
97
+ fs.chmodSync(tmp, 0o600);
98
+ fs.renameSync(tmp, this.filePath);
99
+ // [LAW:one-source-of-truth] `pending` is "state not yet durably written".
100
+ // Clear it only once the rename lands, so a transient EIO/ENOSPC leaves
101
+ // the snapshot for a later flush (e.g. shutdown) to retry rather than
102
+ // silently dropping the last known state.
103
+ this.pending = null;
104
+ } catch (e) {
105
+ this.logger("warn", `session-state save failed: ${(e as Error).message}`);
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,237 @@
1
+ // [LAW:one-type-per-behavior] One generic store for all per-session state.
2
+ // Adding a new per-session value is just picking a string key — no new class,
3
+ // no DI wiring, no cache invalidation.
4
+ //
5
+ // [LAW:single-enforcer] Reads are MobX-tracked through a single internal atom.
6
+ // Every get() reports observed; every set/clear/prune reports changed. A DSL
7
+ // computed that reads SessionState via this object will re-evaluate whenever
8
+ // any (sessionId, key) pair mutates — coarse-grained on purpose, since
9
+ // session-state mutations are rare (clicks) and computeds are cheap. The
10
+ // alternative — per-key atoms — would be lower-cardinality reactivity at the
11
+ // cost of a much wider API surface; we don't need it.
12
+ //
13
+ // Outside a reactive context (the common case: ad-hoc gets from the segments
14
+ // renderer), atom.reportObserved is a no-op. Tests that construct SessionState
15
+ // without any observer see no change in behavior.
16
+
17
+ import { createAtom, type IAtom, runInAction } from "mobx";
18
+
19
+ export interface SessionStateReader {
20
+ get(sessionId: string, key: string): string | null;
21
+ }
22
+
23
+ // [LAW:locality-or-seam] Renderer needs to *cache* per-session random picks
24
+ // so subsequent renders are stable. Writing them back into the same store
25
+ // click verbs use keeps state in one place — no parallel cache to drift.
26
+ //
27
+ // setBatch commits multiple (key, value) pairs as a single reactive
28
+ // transaction: observers fire ONCE after every pair has landed, never
29
+ // between pairs. The set-state verb's batched-pair URL (a Menu click that
30
+ // writes the chosen value AND collapses the menu) depends on this — if
31
+ // observers saw the first write before the second, an autorun could
32
+ // render half-applied state. The atomicity contract lives in the seam,
33
+ // not in each consumer. [LAW:single-enforcer]
34
+ export interface SessionStateRW extends SessionStateReader {
35
+ set(sessionId: string, key: string, value: string): void;
36
+ setBatch(
37
+ sessionId: string,
38
+ pairs: ReadonlyArray<{ key: string; value: string }>,
39
+ ): void;
40
+ clear(sessionId: string, key: string): void;
41
+ }
42
+
43
+ // Flat, JSON-shaped mirror of the store: sessionId → key → value. This is the
44
+ // on-disk representation and the load/save currency between store and storage.
45
+ export type SessionSnapshot = Record<string, Record<string, string>>;
46
+
47
+ // [LAW:locality-or-seam] The store depends on this seam, not on the filesystem.
48
+ // The daemon injects a disk-backed impl; tests and non-daemon callers get the
49
+ // ephemeral default. Persistence is a property of the *storage*, not the store.
50
+ export interface SessionStorage {
51
+ load(): SessionSnapshot;
52
+ save(snapshot: SessionSnapshot): void;
53
+ flush(): void;
54
+ }
55
+
56
+ // [LAW:dataflow-not-control-flow] Null-object so the store always calls
57
+ // save()/flush() — no "am I persisting?" branch. The ephemeral case is data
58
+ // (a storage that discards), not a special control path.
59
+ const EPHEMERAL_STORAGE: SessionStorage = {
60
+ load: () => ({}),
61
+ save: () => {},
62
+ flush: () => {},
63
+ };
64
+
65
+ function hydrate(snapshot: SessionSnapshot): Map<string, Map<string, string>> {
66
+ const sessions = new Map<string, Map<string, string>>();
67
+ for (const [sessionId, kv] of Object.entries(snapshot)) {
68
+ sessions.set(sessionId, new Map(Object.entries(kv)));
69
+ }
70
+ return sessions;
71
+ }
72
+
73
+ // Generous headroom over realistic concurrent-session counts; matches the
74
+ // render cache's LRU bound. Active sessions stay hot via get-promotion, so only
75
+ // genuinely-idle sessions are ever evicted — eviction *is* "drop dead sessions".
76
+ const DEFAULT_MAX_SESSIONS = 256;
77
+
78
+ export class SessionState implements SessionStateReader, SessionStateRW {
79
+ // [LAW:types-are-the-program] Insertion order is recency order: the store
80
+ // cannot hold more than maxSessions, so "bounded on disk" is structural, not
81
+ // dependent on an external prune caller.
82
+ private sessions: Map<string, Map<string, string>>;
83
+ private storage: SessionStorage;
84
+ // [LAW:single-enforcer] One atom; every read reports observed against it,
85
+ // every mutation reports changed. Coarse-grained reactivity is correct for
86
+ // session-state's load — mutations are rare and computeds are cheap.
87
+ private readonly atom: IAtom = createAtom("SessionState");
88
+
89
+ constructor(
90
+ storage: SessionStorage = EPHEMERAL_STORAGE,
91
+ private readonly maxSessions: number = DEFAULT_MAX_SESSIONS,
92
+ ) {
93
+ this.storage = storage;
94
+ this.sessions = new Map();
95
+ this.hydrateFromStorage();
96
+ }
97
+
98
+ // [LAW:single-enforcer] Bind a persistence backend after construction. Only
99
+ // the daemon process calls this (with the disk-backed storage), so importers
100
+ // that merely load this module — the CLI relay, subcommands — keep the
101
+ // ephemeral default and never read or write the state file. Must run before
102
+ // the daemon serves requests, since it replaces in-memory state with disk.
103
+ useStorage(storage: SessionStorage): void {
104
+ this.storage = storage;
105
+ this.hydrateFromStorage();
106
+ }
107
+
108
+ private hydrateFromStorage(): void {
109
+ this.sessions = hydrate(this.storage.load());
110
+ this.evictOldest();
111
+ // [LAW:dataflow-not-control-flow] The disk mirror always reflects the built
112
+ // in-memory state. An over-cap file trimmed by evictOldest is written back
113
+ // here, so the on-disk bound holds even if no mutation ever follows.
114
+ this.persist();
115
+ }
116
+
117
+ get(sessionId: string, key: string): string | null {
118
+ // [LAW:single-enforcer] reportObserved is the reactive-dep registration —
119
+ // outside a tracking context (the common direct-read case) it is a no-op.
120
+ this.atom.reportObserved();
121
+ const session = this.sessions.get(sessionId);
122
+ if (!session) return null;
123
+ // [LAW:dataflow-not-control-flow] A read promotes recency but never
124
+ // triggers a disk write itself; the reordered insertion order only reaches
125
+ // disk if a later mutation persists.
126
+ this.touch(sessionId, session);
127
+ return session.get(key) ?? null;
128
+ }
129
+
130
+ // [LAW:one-source-of-truth] set is the degenerate single-pair form of
131
+ // setBatch — one write path through the store. The previous shape (a
132
+ // standalone set body) split the write semantics across two routes
133
+ // once setBatch was introduced; collapsing keeps mutation, persistence,
134
+ // and notification in exactly one place.
135
+ set(sessionId: string, key: string, value: string): void {
136
+ this.setBatch(sessionId, [{ key, value }]);
137
+ }
138
+
139
+ // [LAW:no-silent-fallbacks] Atomic commit of N pairs: every write
140
+ // lands BEFORE the single reportChanged() that scheduler-visibly
141
+ // marks the transaction complete. Observers cannot see an
142
+ // intermediate "half-applied" snapshot — `runInAction` defers
143
+ // reaction scheduling until the outermost call exits, and we hold
144
+ // ALL writes inside this one block. Previously, the verb's "loop and
145
+ // call set N times" pattern fired reportChanged() N times, which
146
+ // scheduled autoruns between pairs (visible to consumers as the menu
147
+ // value changing while toolbar-expanded was still old). The batch
148
+ // method is the structural fix: there is no way to get half-applied
149
+ // state because there is no intermediate scheduler tick.
150
+ //
151
+ // [LAW:dataflow-not-control-flow] An empty pairs array is no-work-
152
+ // to-do, returned without firing reportChanged or persisting. The
153
+ // verb body validates that pairs is non-empty before calling, so
154
+ // this is the public-API safety net rather than the hot path.
155
+ setBatch(
156
+ sessionId: string,
157
+ pairs: ReadonlyArray<{ key: string; value: string }>,
158
+ ): void {
159
+ if (pairs.length === 0) return;
160
+ runInAction(() => {
161
+ const session = this.sessions.get(sessionId) ?? new Map<string, string>();
162
+ for (const { key, value } of pairs) session.set(key, value);
163
+ this.touch(sessionId, session);
164
+ this.evictOldest();
165
+ this.persist();
166
+ this.atom.reportChanged();
167
+ });
168
+ }
169
+
170
+ clear(sessionId: string, key: string): void {
171
+ runInAction(() => {
172
+ const session = this.sessions.get(sessionId);
173
+ if (session) {
174
+ session.delete(key);
175
+ // An emptied session is a non-state — drop it so it neither occupies a
176
+ // cap slot nor persists as a `{ "sid": {} }` husk. [LAW:one-source-of-truth]
177
+ // A surviving session is promoted: every interaction is a recency signal,
178
+ // uniform with get()/set(). [LAW:one-type-per-behavior]
179
+ if (session.size === 0) this.sessions.delete(sessionId);
180
+ else this.touch(sessionId, session);
181
+ }
182
+ this.persist();
183
+ this.atom.reportChanged();
184
+ });
185
+ }
186
+
187
+ // [LAW:one-source-of-truth] Drop state for sessions that no longer exist.
188
+ prune(activeSessionIds: Set<string>): void {
189
+ runInAction(() => {
190
+ for (const id of this.sessions.keys()) {
191
+ if (!activeSessionIds.has(id)) this.sessions.delete(id);
192
+ }
193
+ this.persist();
194
+ this.atom.reportChanged();
195
+ });
196
+ }
197
+
198
+ // Move-to-end: re-inserting at the tail makes this the most-recently-used.
199
+ private touch(sessionId: string, session: Map<string, string>): void {
200
+ this.sessions.delete(sessionId);
201
+ this.sessions.set(sessionId, session);
202
+ }
203
+
204
+ private evictOldest(): void {
205
+ let overflow = this.sessions.size - this.maxSessions;
206
+ if (overflow <= 0) return;
207
+ // Delete the oldest keys in insertion order. Cost is proportional to the
208
+ // number of evictions, not the total loaded set — deleting an already-
209
+ // yielded key mid-iteration is well-defined for a Map.
210
+ for (const id of this.sessions.keys()) {
211
+ this.sessions.delete(id);
212
+ if (--overflow === 0) break;
213
+ }
214
+ }
215
+
216
+ // Synchronous write of any debounced-pending snapshot. Called on daemon
217
+ // shutdown so a pending pick isn't lost when the process exits.
218
+ flush(): void {
219
+ this.storage.flush();
220
+ }
221
+
222
+ private persist(): void {
223
+ this.storage.save(this.serialize());
224
+ }
225
+
226
+ private serialize(): SessionSnapshot {
227
+ // [LAW:types-are-the-program] sessionIds are external (hook JSON / click
228
+ // URLs). A null-prototype root makes "__proto__"/"constructor" ordinary
229
+ // own keys instead of prototype-mutation vectors — pollution is
230
+ // unrepresentable rather than guarded against.
231
+ const snapshot = Object.create(null) as SessionSnapshot;
232
+ for (const [sessionId, kv] of this.sessions) {
233
+ snapshot[sessionId] = Object.fromEntries(kv);
234
+ }
235
+ return snapshot;
236
+ }
237
+ }
@@ -0,0 +1,229 @@
1
+ // [LAW:single-enforcer] One mutator owns runtime counters. Server, caches, and
2
+ // watchers each receive a tiny handle they're allowed to bump, but the
3
+ // canonical object lives here. Stats are read-only after serialization.
4
+
5
+ import type { LaunchCategory } from "../proc/launch";
6
+ import type { LaunchStatsHandle } from "../proc/stats-handle";
7
+ import { PROTOCOL_VERSION } from "./protocol";
8
+
9
+ // Rolling window for "last minute" counts. Keep timestamps for each launch in
10
+ // a ring buffer; eviction happens lazily on read.
11
+ //
12
+ // [LAW:dataflow-not-control-flow] The cap is a hard bound on how many
13
+ // launches can be counted in any 60-second window. Bursts above
14
+ // ROLLING_BUFFER_CAP / (ROLLING_WINDOW_MS/1000) launches/sec overwrite
15
+ // timestamps that are still inside the window, causing `lastMinute` to
16
+ // undercount. At 16384 entries / 60s = ~273 sustained launches/sec the
17
+ // undercount only kicks in under pathological load (well past anything kz8
18
+ // is trying to detect). Bump this cap rather than the comment if a future
19
+ // workload ever sustains higher rates.
20
+ const ROLLING_WINDOW_MS = 60_000;
21
+ const ROLLING_BUFFER_CAP = 16384;
22
+
23
+ // Reservoir-sample histogram (16 entries) per category — sufficient for
24
+ // rough p50/p99 without unbounded memory. Replacement uses a simple
25
+ // counter-mod-N strategy: deterministic and adequate for human-eyeball
26
+ // dashboards (no statistical claim of unbiased sampling).
27
+ const HISTOGRAM_CAP = 16;
28
+
29
+ export interface StatsSnapshot {
30
+ pid: number;
31
+ version: number;
32
+ startedAt: string;
33
+ uptimeSec: number;
34
+ rssBytes: number;
35
+ heapUsedBytes: number;
36
+ heapTotalBytes: number;
37
+ externalBytes: number;
38
+ arrayBuffersBytes: number;
39
+ requests: {
40
+ total: number;
41
+ errored: number;
42
+ timedOut: number;
43
+ inFlight: number;
44
+ };
45
+ gitCache: {
46
+ size: number;
47
+ hits: number;
48
+ misses: number;
49
+ invalidations: number;
50
+ watchers: number;
51
+ };
52
+ usageCache: {
53
+ size: number;
54
+ hits: number;
55
+ misses: number;
56
+ sweeps: number;
57
+ };
58
+ renderCache: {
59
+ size: number;
60
+ };
61
+ watchers: {
62
+ active: number;
63
+ opened: number;
64
+ closed: number;
65
+ evicted: number;
66
+ };
67
+ subprocesses: {
68
+ total: number;
69
+ inFlight: number;
70
+ lastMinute: number;
71
+ byCategory: Record<string, number>;
72
+ p50DurationMs: Record<string, number>;
73
+ p99DurationMs: Record<string, number>;
74
+ };
75
+ nextRestartReason: string | null;
76
+ }
77
+
78
+ export class RuntimeStats {
79
+ readonly startedAt = new Date();
80
+ requestsTotal = 0;
81
+ requestsErrored = 0;
82
+ requestsTimedOut = 0;
83
+ inFlight = 0;
84
+
85
+ watchersOpened = 0;
86
+ watchersClosed = 0;
87
+ watchersEvicted = 0;
88
+
89
+ // [LAW:dataflow-not-control-flow] Subprocess metering. Every launch carries
90
+ // its category through one boundary; the per-category state below is a
91
+ // function of that data, not of which call site fired.
92
+ subprocessTotal = 0;
93
+ subprocessInFlight = 0;
94
+ private readonly subprocessCount = new Map<LaunchCategory, number>();
95
+ private readonly subprocessHistogram = new Map<LaunchCategory, number[]>();
96
+ private readonly subprocessHistogramRotator = new Map<
97
+ LaunchCategory,
98
+ number
99
+ >();
100
+ private readonly rollingTimestamps: number[] = [];
101
+ private rollingHead = 0;
102
+
103
+ // [LAW:one-source-of-truth] The same object that owns the counters also
104
+ // exposes the metering handle. The launch primitive calls these two
105
+ // methods; nothing else mutates subprocess state.
106
+ readonly launchStats: LaunchStatsHandle = {
107
+ onStart: (category) => {
108
+ this.subprocessTotal++;
109
+ this.subprocessInFlight++;
110
+ this.subprocessCount.set(
111
+ category,
112
+ (this.subprocessCount.get(category) ?? 0) + 1,
113
+ );
114
+ this.recordRollingNow();
115
+ },
116
+ onEnd: (category, durationMs) => {
117
+ this.subprocessInFlight = Math.max(0, this.subprocessInFlight - 1);
118
+ this.recordDuration(category, durationMs);
119
+ },
120
+ };
121
+
122
+ private recordRollingNow(): void {
123
+ const now = Date.now();
124
+ if (this.rollingTimestamps.length < ROLLING_BUFFER_CAP) {
125
+ this.rollingTimestamps.push(now);
126
+ return;
127
+ }
128
+ // Ring buffer once cap hit. The overwritten slot may still be inside the
129
+ // 60s window — see the cap-rationale comment at ROLLING_BUFFER_CAP.
130
+ this.rollingTimestamps[this.rollingHead] = now;
131
+ this.rollingHead = (this.rollingHead + 1) % ROLLING_BUFFER_CAP;
132
+ }
133
+
134
+ private recordDuration(category: LaunchCategory, durationMs: number): void {
135
+ let hist = this.subprocessHistogram.get(category);
136
+ if (!hist) {
137
+ hist = [];
138
+ this.subprocessHistogram.set(category, hist);
139
+ }
140
+ if (hist.length < HISTOGRAM_CAP) {
141
+ hist.push(durationMs);
142
+ return;
143
+ }
144
+ const idx =
145
+ (this.subprocessHistogramRotator.get(category) ?? 0) % HISTOGRAM_CAP;
146
+ hist[idx] = durationMs;
147
+ this.subprocessHistogramRotator.set(category, idx + 1);
148
+ }
149
+
150
+ private snapshotSubprocesses(): StatsSnapshot["subprocesses"] {
151
+ const cutoff = Date.now() - ROLLING_WINDOW_MS;
152
+ let lastMinute = 0;
153
+ for (const ts of this.rollingTimestamps) {
154
+ if (ts >= cutoff) lastMinute++;
155
+ }
156
+
157
+ // [LAW:one-source-of-truth] Snapshot includes only categories that have
158
+ // actually executed — same shape as p50/p99 below. Consumers wanting the
159
+ // full closed list can read LAUNCH_CATEGORIES (exported from src/proc/launch)
160
+ // and treat missing keys as zero.
161
+ const byCategory: Record<string, number> = {};
162
+ for (const [cat, n] of this.subprocessCount) {
163
+ if (n > 0) byCategory[cat] = n;
164
+ }
165
+
166
+ const p50: Record<string, number> = {};
167
+ const p99: Record<string, number> = {};
168
+ for (const [cat, hist] of this.subprocessHistogram) {
169
+ if (hist.length === 0) continue;
170
+ const sorted = [...hist].sort((a, b) => a - b);
171
+ const p50idx = Math.floor(sorted.length * 0.5);
172
+ const p99idx = Math.min(
173
+ sorted.length - 1,
174
+ Math.floor(sorted.length * 0.99),
175
+ );
176
+ const p50val = sorted[p50idx];
177
+ const p99val = sorted[p99idx];
178
+ if (p50val !== undefined) p50[cat] = p50val;
179
+ if (p99val !== undefined) p99[cat] = p99val;
180
+ }
181
+
182
+ return {
183
+ total: this.subprocessTotal,
184
+ inFlight: this.subprocessInFlight,
185
+ lastMinute,
186
+ byCategory,
187
+ p50DurationMs: p50,
188
+ p99DurationMs: p99,
189
+ };
190
+ }
191
+
192
+ snapshot(extras: {
193
+ gitCache: StatsSnapshot["gitCache"];
194
+ usageCache: StatsSnapshot["usageCache"];
195
+ renderCacheSize: number;
196
+ watchersActive: number;
197
+ nextRestartReason?: string | null;
198
+ }): StatsSnapshot {
199
+ const mem = process.memoryUsage();
200
+ return {
201
+ pid: process.pid,
202
+ version: PROTOCOL_VERSION,
203
+ startedAt: this.startedAt.toISOString(),
204
+ uptimeSec: Math.floor((Date.now() - this.startedAt.getTime()) / 1000),
205
+ rssBytes: mem.rss,
206
+ heapUsedBytes: mem.heapUsed,
207
+ heapTotalBytes: mem.heapTotal,
208
+ externalBytes: mem.external,
209
+ arrayBuffersBytes: mem.arrayBuffers,
210
+ requests: {
211
+ total: this.requestsTotal,
212
+ errored: this.requestsErrored,
213
+ timedOut: this.requestsTimedOut,
214
+ inFlight: this.inFlight,
215
+ },
216
+ gitCache: extras.gitCache,
217
+ usageCache: extras.usageCache,
218
+ renderCache: { size: extras.renderCacheSize },
219
+ watchers: {
220
+ active: extras.watchersActive,
221
+ opened: this.watchersOpened,
222
+ closed: this.watchersClosed,
223
+ evicted: this.watchersEvicted,
224
+ },
225
+ subprocesses: this.snapshotSubprocesses(),
226
+ nextRestartReason: extras.nextRestartReason ?? null,
227
+ };
228
+ }
229
+ }