@promptctl/cc-candybar 1.42.1 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
@@ -1,1330 +0,0 @@
1
- import fs from "node:fs";
2
- import net from "node:net";
3
- import v8 from "node:v8";
4
- import process from "node:process";
5
- import { fileURLToPath } from "node:url";
6
- import { parseArgs } from "node:util";
7
- import {
8
- daemonDir,
9
- ensureSocketParentSafe,
10
- leasePath,
11
- leasePathFor,
12
- socketPath,
13
- sessionStatePath,
14
- } from "./paths";
15
- import {
16
- arbitrateSocket,
17
- readLease,
18
- removeLeaseIfOwned,
19
- writeLease,
20
- } from "./socket-lease";
21
- import {
22
- makeOwnershipWatch,
23
- readSocketIdentity,
24
- type SocketIdentity,
25
- } from "./socket-ownership";
26
- import {
27
- readStartTime,
28
- readOwnStartTime,
29
- sameLiveProcess,
30
- } from "./process-fingerprint";
31
- import {
32
- admitDaemon,
33
- realBreakerDeps,
34
- releaseRegistration,
35
- readRegistryEntry,
36
- } from "./fork-bomb-breaker";
37
- import { dlog } from "./log";
38
- import {
39
- PROTOCOL_VERSION,
40
- encodeFrame,
41
- makeFrameReader,
42
- parseClientHints,
43
- } from "./protocol";
44
- import type { Request, Response } from "./protocol";
45
- import { GitDataProvider } from "./cache/git";
46
- import { SessionUsageStore } from "./cache/session-usage-store";
47
- import { RenderCache } from "./cache/render";
48
- import { WatcherRegistry } from "./cache/watchers";
49
- import { RuntimeStats } from "./stats";
50
- import {
51
- makeLimits,
52
- realLimitsDeps,
53
- rssLimitBytes,
54
- type LimitsHandle,
55
- } from "./limits";
56
- import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
57
- import { resetSpawnBackoff } from "./acquire";
58
- import { SessionState } from "./session-state";
59
- import { FileSessionStorage } from "./session-state-file";
60
- import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
61
- import {
62
- effectsUrl,
63
- VERB_SHOW_CONFIG_ERROR,
64
- VERB_SHOW_CONFIG_WARNING,
65
- } from "../click/wire.js";
66
- import { validateHookData } from "../utils/schema-validator.js";
67
- import { setLaunchStats } from "../proc/launch";
68
- import { buildDebugSnapshot } from "./debug";
69
- import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
70
- import { expandHome } from "../config/dsl-loader.js";
71
- import { renderDsl } from "../dsl/render.js";
72
- import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
73
- import { presetIsCustomized } from "../config/presets.js";
74
- import {
75
- renderStripCells,
76
- DEFAULT_CHARSET,
77
- DEFAULT_COLOR_COMPATIBILITY,
78
- DEFAULT_PADDING,
79
- DEFAULT_TERMINAL_WIDTH,
80
- DEFAULT_WRAP,
81
- type BuildLineOptions,
82
- type Charset,
83
- type ColorCompatibility,
84
- } from "../render/strip.js";
85
- import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
86
- import type { RichText } from "@promptctl/rich-js";
87
- import {
88
- buildRenderPayload,
89
- resolveEffectiveGlobals,
90
- type EffectiveGlobals,
91
- } from "./render-payload.js";
92
- import { ContextProvider } from "../segments/context.js";
93
- import { MetricsProvider } from "../segments/metrics.js";
94
- import { TmuxService } from "../segments/tmux.js";
95
- import { sanitizeAndTruncate } from "../render/diagnostic-text.js";
96
- import {
97
- ANSI_RESET,
98
- DIAGNOSTIC_ERROR_BG,
99
- DIAGNOSTIC_ERROR_FG,
100
- DIAGNOSTIC_WARNING_BG,
101
- DIAGNOSTIC_WARNING_FG,
102
- } from "../render/diagnostic-style.js";
103
-
104
- // [LAW:one-source-of-truth] one cache instance per daemon process — multiple
105
- // instances would defeat the share-across-sessions invariant.
106
- const stats = new RuntimeStats();
107
- // [LAW:single-enforcer] Route all child_process spawns through src/proc/launch.
108
- // Installing the metering handle here makes subprocess counts visible in
109
- // daemon-stats.
110
- setLaunchStats(stats.launchStats);
111
- // [LAW:single-enforcer] The daemon injects `dlog` into both registries so
112
- // cache + watcher lifecycle events land in daemon.log at the right level.
113
- // Non-daemon consumers (var-system tests, future library use) take the
114
- // default debug-routed loggers and never write to daemon log files.
115
- const watcherRegistry = new WatcherRegistry({
116
- counters: stats,
117
- logger: dlog,
118
- });
119
- const gitService = new GitDataProvider({
120
- watchers: watcherRegistry,
121
- logger: dlog,
122
- });
123
- const usageStore = new SessionUsageStore();
124
- // [LAW:locality-or-seam] Constructed ephemeral so importing this module (CLI
125
- // relay, subcommands) does no disk I/O. The daemon binds the file-backed
126
- // storage in runDaemon(), making it the sole reader/writer of the state file.
127
- const sessionState = new SessionState();
128
- // [LAW:one-source-of-truth] One provider per data shape, shared across every
129
- // render in this daemon. The render cache owns DSL-state-per-config; these
130
- // providers serve the augmented payload that flows through every render.
131
- const contextProvider = new ContextProvider();
132
- const metricsProvider = new MetricsProvider();
133
- const tmuxService = new TmuxService();
134
- const renderCache = new RenderCache(
135
- {
136
- gitService,
137
- sessionState,
138
- watchers: watcherRegistry,
139
- },
140
- {
141
- observers: {
142
- // [LAW:no-silent-failure] Every config (re)load's outcome lands in
143
- // daemon.log beside the "config change detected" line that preceded it
144
- // — the operator's only record of whether a save was picked up cleanly,
145
- // kept rendering last-known-good behind an error, or resolved to a
146
- // different file. Same info level as the detection line.
147
- onReload: (entry) =>
148
- dlog(
149
- "info",
150
- `config loaded projectDir=${entry.projectDir} cwd=${entry.cwd} file=${entry.configFilePath ?? "<bundled default>"} error=${entry.lastError === null ? "none" : JSON.stringify(entry.lastError)} warning=${entry.lastWarning === null ? "none" : JSON.stringify(entry.lastWarning)}`,
151
- ),
152
- },
153
- },
154
- );
155
-
156
- const REQUEST_TIMEOUT_MS = 200;
157
- const BIN_CHECK_INTERVAL_MS = 60 * 1000;
158
-
159
- // Daemon entry point. Tries to bind the Unix socket — atomic bind() is the
160
- // single-instance enforcer (two daemons cannot both bind the same path; the
161
- // kernel makes duplicate-daemon unrepresentable). Listens for one request per
162
- // connection. Any uncaught error exits non-zero; the next client obtains a
163
- // fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
164
- // obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
165
- // [LAW:one-source-of-truth] Our own kernel start-time, read once at startup and
166
- // stamped into our lease so a future daemon's arbitration can prove whether our
167
- // pid still names THIS process or a recycled ghost (process-fingerprint.ts).
168
- // null when this host cannot fingerprint (no `ps`) — readers then fall back to
169
- // kill(pid,0), no worse than before the fingerprint existed.
170
- let myStartTime: string | null = null;
171
-
172
- // The registry path this daemon claimed in the fork-bomb breaker's population
173
- // registry (fork-bomb-breaker.ts), or null when exempt (the canonical
174
- // production socket) or never reached (refused before claiming one). Released
175
- // on shutdown so a graceful exit frees its slot immediately rather than
176
- // waiting for the next boot's stale-sweep.
177
- let breakerRegistryPath: string | null = null;
178
-
179
- // The parsed memory budget (bytes); set first thing in runDaemon.
180
- let budgetBytes = 0;
181
-
182
- export function runDaemon(): void {
183
- // Catch-alls log + exit so the supervisor (the next client) can restart us.
184
- // [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
185
- // catching all of unknown space, not skipping known optional values.
186
- // [LAW:single-enforcer] Registered FIRST, before any of the startup calls
187
- // below that can throw synchronously (admitDaemon's ensureDirSafe/writeEntry,
188
- // ensureSocketParentSafe) — otherwise an early throw is a raw unhandled
189
- // exception (stack trace to stderr, bypassing the clean shutdown(1) log +
190
- // SIGKILL backstop) rather than funneling through the same death path as
191
- // every other failure mode.
192
- process.on("uncaughtException", (err) => {
193
- dlog("error", `uncaughtException: ${err.stack || err.message}`);
194
- shutdown(1);
195
- });
196
- process.on("unhandledRejection", (reason) => {
197
- dlog("error", `unhandledRejection: ${String(reason)}`);
198
- shutdown(1);
199
- });
200
- for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
201
- process.on(sig, () => {
202
- dlog("info", `received ${sig}, shutting down`);
203
- shutdown(0);
204
- });
205
- }
206
-
207
- // [LAW:effects-at-boundaries] The memory budget is parsed here, before any
208
- // resource is committed — a malformed override is refused before the breaker
209
- // registers us, before the bind, before the lease, and before a `daemon up`
210
- // line could claim a boot that is about to die. Parsed once, threaded into
211
- // armLimits and the boot line.
212
- // [LAW:single-enforcer] Refused through the same death funnel as every other
213
- // boot failure. A synchronous throw here is NOT uncaught — it lands in
214
- // index.ts's catch, whose stderr the detached spawn discards — so the one
215
- // line that says why the daemon never came up would go nowhere.
216
- try {
217
- budgetBytes = rssLimitBytes(process.env);
218
- } catch (err) {
219
- dlog("error", `refusing to boot: ${(err as Error).message}`);
220
- shutdown(1);
221
- return;
222
- }
223
-
224
- // [LAW:single-enforcer] The fork-bomb circuit breaker runs FIRST among the
225
- // resource-committing steps (no dir created, no socket touched, no session
226
- // state loaded) — the whole point of a load-independent backstop is that it
227
- // holds even when everything downstream of it is thrashing. Own start-time
228
- // must be read first: it is both this check's identity and the lease's
229
- // fingerprint later, so it is read exactly once and threaded through both
230
- // (see realBreakerDeps' doc comment).
231
- myStartTime = readOwnStartTime(process.pid);
232
- const admission = admitDaemon(realBreakerDeps(myStartTime));
233
- if (!admission.decision.allow) {
234
- dlog(
235
- "warn",
236
- `fork-bomb breaker: ${admission.decision.reason}; refusing to boot`,
237
- );
238
- shutdown(1);
239
- return;
240
- }
241
- breakerRegistryPath = admission.registryPath;
242
-
243
- fs.mkdirSync(daemonDir(), { recursive: true });
244
- // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
245
- // not a symlink before we bind. Without this check, a same-host attacker
246
- // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
247
- // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
248
- // location — every bind path goes through the same trust precondition.
249
- // No symmetric client-side check: the daemon is the sole creator, so a
250
- // successful bind already proves the parent is trusted. Failure here surfaces
251
- // as a daemon exit; the client falls back to the last cached render.
252
- ensureSocketParentSafe(socketPath());
253
-
254
- // Bind disk persistence now that we know we are the daemon process — load
255
- // prior session state and become the sole writer of the state file.
256
- sessionState.useStorage(
257
- new FileSessionStorage(sessionStatePath(), 500, dlog),
258
- );
259
-
260
- // [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
261
- // the watchdog calls shutdown(0), it never exits on its own. A production
262
- // daemon has no spawner to outlive (env unset) and arms an inert handle; only
263
- // a test-spawned daemon is anchored, so this is invisible to the real daemon.
264
- armParentWatchdog({
265
- anchor: anchorFromEnv(process.env),
266
- isAlive: pidAlive,
267
- onOrphaned: (reason) => {
268
- dlog("info", `parent watchdog: ${reason}; shutting down`);
269
- shutdown(0);
270
- },
271
- });
272
-
273
- const server = net.createServer({ allowHalfOpen: false }, (sock) => {
274
- handleConnection(sock);
275
- });
276
-
277
- // [LAW:single-enforcer] The atomic bind() is the daemon-singleton enforcer.
278
- // Two daemons cannot both bind the same Unix socket path; the kernel makes
279
- // duplicate-daemon unrepresentable. The pidfile is diagnostic only — never
280
- // load-bearing for exclusion.
281
- bindOrAttachAndExit(server, socketPath(), /* retried */ false);
282
- }
283
-
284
- // [LAW:dataflow-not-control-flow] One operation ("bring this server up or
285
- // discover an existing one"). The bind result is the data that decides the
286
- // next step; callers do not get to choose whether to spawn.
287
- function bindOrAttachAndExit(
288
- server: net.Server,
289
- sockPath: string,
290
- retried: boolean,
291
- ): void {
292
- server.removeAllListeners("error");
293
- // [LAW:no-ambient-temporal-coupling] server.listen(path, cb) registers cb as
294
- // a ONE-TIME 'listening' listener. A first listen that fails EADDRINUSE never
295
- // fires 'listening', so its callback stays pending; the reclaim retry adds a
296
- // second. Without clearing the stale one, a successful rebind fires BOTH and
297
- // onListening runs twice — double-arming the RSS backstop + watchers. Clear
298
- // pending 'listening' listeners so exactly one onListening fires per bind.
299
- server.removeAllListeners("listening");
300
- server.once("error", (err) => {
301
- const code = (err as NodeJS.ErrnoException).code;
302
- if (code !== "EADDRINUSE") {
303
- dlog("error", `server error: ${err.message}`);
304
- shutdown(1);
305
- return;
306
- }
307
- if (retried) {
308
- // Lost a rebind race with another duplicate. The kernel arbitrated; we
309
- // are the loser. Exit cleanly so the winner serves.
310
- dlog("info", "lost rebind race; another daemon is alive — exiting");
311
- process.exit(0);
312
- return;
313
- }
314
- handleAddressInUse(server, sockPath);
315
- });
316
- server.listen(sockPath, () => onListening(sockPath));
317
- }
318
-
319
- // [LAW:one-source-of-truth] EADDRINUSE arbitration consults the socket-derived
320
- // pid lease, NEVER a connect probe. The path already exists (a live daemon, or
321
- // a stale file from a crashed one); the lease's owner pid + kill(pid,0) decides
322
- // which. This is fully synchronous — reading the lease and testing liveness are
323
- // both sync — so there is no await gap for a concurrent recoverer to race
324
- // through (the old async probe had two such gaps and a hand-rolled re-check).
325
- //
326
- // [LAW:effects-at-boundaries] The decision is the pure arbitrateSocket fold
327
- // over the lease read + injected pidAlive; the kill / unlink / rebind effects
328
- // are performed here at the edge.
329
- function handleAddressInUse(server: net.Server, sockPath: string): void {
330
- // [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
331
- // through unlink + rebind below, not the re-derived global — one identity
332
- // source for the whole arbitration.
333
- const decision = arbitrateSocket(
334
- readLease(leasePathFor(sockPath)),
335
- (pid, startTime) =>
336
- sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
337
- );
338
- if (decision.kind === "attach-and-exit") {
339
- dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
340
- process.exit(0);
341
- // [LAW:no-ambient-temporal-coupling] process.exit() halts synchronously, so
342
- // the reclaim below is already unreachable — but the explicit return makes
343
- // that structural, matching the sibling `retried` branch, so no future
344
- // refactor of the exit path can accidentally fall through to unlinking a
345
- // live daemon's socket.
346
- return;
347
- }
348
- dlog(
349
- "warn",
350
- `EADDRINUSE: ${decision.reason} — unlinking stale socket and rebinding`,
351
- );
352
- // [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
353
- // FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
354
- // in the worst state: no daemon + stale socket blocking future starts.
355
- // Surface unrecoverable failures loudly. ENOENT is fine — the goal was
356
- // "make the path bindable" and a missing path already satisfies that.
357
- try {
358
- fs.unlinkSync(sockPath);
359
- } catch (e) {
360
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
361
- dlog(
362
- "error",
363
- `cannot unlink stale socket ${sockPath}: ${(e as Error).message}`,
364
- );
365
- shutdown(1);
366
- return;
367
- }
368
- }
369
- bindOrAttachAndExit(server, sockPath, /* retried */ true);
370
- }
371
-
372
- function onListening(sockPath: string): void {
373
- // [LAW:no-ambient-temporal-coupling] Capture the bound socket's kernel
374
- // identity (dev+ino) as the fingerprint the ownership self-check re-stats
375
- // against, BEFORE claiming the lease. Captured from the PATH, not the bound
376
- // FD: for an AF_UNIX listener, fstat(fd) reports the socket's inode in the
377
- // socket namespace — not the filesystem-entry inode that stat(path) sees — so
378
- // the FD yields no path-comparable identity. stat(path), taken as close to the
379
- // bind as possible, is the only path-comparable truth available.
380
- //
381
- // [FRAMING:representation] This inode capture still cannot close the capture
382
- // race on its own: if a second daemon completed its full EADDRINUSE →
383
- // read-lease → unlink → rebind cycle in the single event-loop tick between
384
- // bind() and this 'listening' callback, we stat the path and capture the
385
- // THIEF's inode as "ours". No race-free handle on our own socket's path
386
- // identity exists (an AF_UNIX listener's fstat is a different namespace's
387
- // inode), so the captured inode cannot be made trustworthy. But that is no
388
- // longer the immortal orphan it was (brandon-daemon-lifecycle-2b3.4 RESIDUAL
389
- // 2): the ownership self-check now ALSO requires the lease to still name us,
390
- // and a real thief writes its own pid into the lease — so a displaced daemon
391
- // drains within a bounded number of intervals instead of reading `owned`
392
- // forever (see checkOwnership). The absent/unreadable case below still exits
393
- // toward the SAFE direction without writing a lease that would stomp a thief's.
394
- const boundRead = readSocketIdentity(sockPath);
395
- if (boundRead.kind !== "present") {
396
- dlog(
397
- "warn",
398
- `no ownable socket at bind callback (${boundRead.kind}); exiting`,
399
- );
400
- shutdown(0);
401
- return;
402
- }
403
-
404
- // [LAW:no-ambient-temporal-coupling] Claim ownership FIRST among the post-bind
405
- // effects — before chmod or any other work — so the window between winning the
406
- // bind and the lease naming us is as small as possible. A second daemon that
407
- // binds in that sub-ms gap reads an absent lease and reclaims (displacing us);
408
- // the ownership self-check below makes that self-healing, but keeping the
409
- // window minimal keeps it vanishingly rare.
410
- // [LAW:one-source-of-truth] Derive the lease from the same sockPath we bound,
411
- // matching handleAddressInUse's read — one identity source across write + read.
412
- writeLeaseFile(sockPath);
413
- try {
414
- fs.chmodSync(sockPath, 0o600);
415
- } catch (e) {
416
- dlog("warn", `chmod socket failed: ${(e as Error).message}`);
417
- }
418
- dlog(
419
- "info",
420
- // [FRAMING:representation] Report the heap cap V8 actually applied (the
421
- // territory), not the flag the spawner meant to pass (the map) — the one
422
- // question a silent SIGABRT crash-loop leaves open is "which cap was live".
423
- `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath} ` +
424
- `heapCap=${Math.round(v8.getHeapStatistics().heap_size_limit / 1048576)}MB ` +
425
- `rssLimit=${Math.round(budgetBytes / 1048576)}MB`,
426
- );
427
- // [LAW:single-enforcer] This bind is the one process-wide fact that answers
428
- // "did an outage just end" — see resetSpawnBackoff's doc comment in
429
- // acquire.ts. Any consecutive-spawn backoff accumulated getting here no
430
- // longer applies once a daemon is actually serving.
431
- resetSpawnBackoff();
432
- armBinaryWatch();
433
- armLimits();
434
- armOwnershipWatch(sockPath, boundRead.identity);
435
- }
436
-
437
- // --- socket-ownership self-check ---
438
- //
439
- // [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
440
- // path over time" (brandon-daemon-lifecycle-2b3.2). Each interval it re-reads
441
- // BOTH representations a displacer can touch — the path's kernel identity (still
442
- // the inode we bound?) and the lease (still names our pid?) — and drains through
443
- // the SAME shutdown funnel as signals, the RSS backstop, and the watchdog on
444
- // either mismatch. The lease arm closes the capture race (RESIDUAL 2): a thief
445
- // that stole our socket inside the bind→listening tick, which the inode arm
446
- // cannot see because we captured the thief's inode, is caught the moment the
447
- // thief writes its own pid into the lease. No parallel exit path.
448
- function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
449
- makeOwnershipWatch({
450
- bound,
451
- myPid: process.pid,
452
- readIdentity: () => readSocketIdentity(sockPath),
453
- readLease: () => readLease(leasePathFor(sockPath)),
454
- shutdown: (code) => shutdown(code),
455
- log: dlog,
456
- }).arm();
457
- }
458
-
459
- // --- binary-mtime self-restart ---
460
- //
461
- // If the daemon's compiled output changes on disk (rebuild, upgrade, edit),
462
- // exit at the next sample so the next client respawns from the fresh code.
463
- // Cheap (one statSync/min) and avoids the user having to manually kill the
464
- // daemon during development. unref() so this timer doesn't hold the process alive.
465
- function armBinaryWatch(): void {
466
- // Watch the resolved entry point, not the bin shim — npm run build updates
467
- // dist/index.mjs but the bin/cc-candybar shim never changes.
468
- const entryUrl = import.meta.url;
469
- const targets: string[] = [];
470
- if (entryUrl.startsWith("file://")) {
471
- targets.push(fileURLToPath(entryUrl));
472
- }
473
- // Also watch argv[1] as fallback (covers global installs, symlinks, etc.)
474
- if (process.argv[1]) targets.push(process.argv[1]!);
475
-
476
- const originalMtimes = new Map<string, number>();
477
- for (const t of targets) {
478
- try {
479
- originalMtimes.set(t, fs.statSync(t).mtimeMs);
480
- } catch {
481
- // File may not exist yet — skip it.
482
- }
483
- }
484
- if (originalMtimes.size === 0) return;
485
-
486
- const timer = setInterval(() => {
487
- for (const [t, originalMtime] of originalMtimes) {
488
- try {
489
- const nowMtime = fs.statSync(t).mtimeMs;
490
- if (nowMtime !== originalMtime) {
491
- dlog("info", `binary mtime changed (${t}); shutting down`);
492
- clearInterval(timer);
493
- shutdown(0);
494
- return;
495
- }
496
- } catch (e) {
497
- dlog("warn", `bin stat failed: ${(e as Error).message}`);
498
- }
499
- }
500
- }, BIN_CHECK_INTERVAL_MS);
501
- timer.unref();
502
- }
503
-
504
- // --- self-shutdown on RSS / age ---
505
- let limits: LimitsHandle | null = null;
506
- function armLimits(): void {
507
- limits = makeLimits(
508
- realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code), {
509
- rssLimitBytes: budgetBytes,
510
- }),
511
- );
512
- limits.arm();
513
- }
514
-
515
- // --- socket-ownership lease ---
516
- //
517
- // [LAW:one-source-of-truth] The lease is the authority for socket ownership —
518
- // its owner pid + kill(pid,0) is what the next daemon's EADDRINUSE arbitration
519
- // consults (handleAddressInUse). Exclusion RIGHT NOW is still the atomic bind()
520
- // in bindOrAttachAndExit(); the lease answers the separate question "may I
521
- // destroy this existing path" over time. It also carries the same diagnostic
522
- // fields the old pidfile did, so one file serves both roles.
523
- //
524
- // Overwrite-on-write (no EEXIST check). We only reach onListening after winning
525
- // the bind, so whatever stale lease a dead owner left is ours to replace. Write
526
- // failure is non-fatal: the lease is best-effort ownership signalling on top of
527
- // bind()'s hard exclusion; a missing lease degrades a future arbitration to
528
- // "reclaim" (unlink + rebind), never to a wrong attach.
529
-
530
- function writeLeaseFile(sockPath: string): void {
531
- const reason = writeLease(leasePathFor(sockPath), {
532
- pid: process.pid,
533
- version: PROTOCOL_VERSION,
534
- binPath: process.argv[1],
535
- startTime: myStartTime,
536
- });
537
- if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
538
- }
539
-
540
- let inFlight = 0;
541
-
542
- // --- shutdown ---
543
-
544
- let shuttingDown = false;
545
- function shutdown(code: number): void {
546
- if (shuttingDown) return;
547
- shuttingDown = true;
548
- // [LAW:single-enforcer] Arm the SIGKILL backstop FIRST, before any cleanup.
549
- // The 452-daemon incident: shut-down daemons logged "shutting down" but
550
- // held the bound socket FD 42 minutes later — process.exit() reached the
551
- // call site but never completed because some active handle kept libuv's
552
- // event loop alive past exit's teardown. The prior shape had `.unref()`
553
- // on the SIGKILL timer, so the timer itself did NOT keep the loop alive
554
- // — leaving the loop's only remaining live handles to win the race.
555
- //
556
- // What this timer guarantees: as long as the event loop can still run
557
- // (handles that won't drop, async cleanup that schedules but never
558
- // completes — the realistic failure modes for the incident class), the
559
- // setTimeout callback fires within 500ms and SIGKILL terminates the
560
- // process from outside the loop's bookkeeping. Critically the timer is
561
- // NOT unref'd, so it is itself an active handle that keeps the loop
562
- // alive long enough for itself to fire.
563
- //
564
- // What this timer cannot do: rescue a truly synchronous thread block
565
- // (a C++ binding that never returns to JS, an infinite sync loop). No
566
- // JS timer can fire while the main thread is blocked; only an external
567
- // signal recovers that case. The realistic 452-corpse mode was async-
568
- // handle retention, not a synchronous block, so the backstop is
569
- // load-bearing for the observed failure pattern.
570
- setTimeout(() => process.kill(process.pid, "SIGKILL"), 500);
571
- // [LAW:single-enforcer] The atomic bind() on the unix socket path is the
572
- // ONLY mutex preventing duplicate daemons. The previous shape unlinked
573
- // the socket file FIRST, then spent O(100ms) closing watchers, flushing
574
- // session state, and tearing down log streams before process.exit().
575
- // The unlink frees the path the instant it runs; the listening FD stays
576
- // held only until process.exit. In between, Claude Code's next render
577
- // tick can spawn a fresh daemon that bind()s the same path and starts
578
- // serving while we are still finishing cleanup. Under OOM cycles the
579
- // overlap compounds — 12 daemons stacked up in the wild was the
580
- // observed symptom. Do NOT unlink here. The kernel releases the FD on
581
- // process.exit; the stale path that remains is recovered by the
582
- // existing handleAddressInUse logic on the next daemon's startup
583
- // (probe → dead → unlink + rebind, ~50ms one-shot cost).
584
- try {
585
- gitService.close();
586
- } catch (e) {
587
- dlog("warn", `gitService close failed: ${(e as Error).message}`);
588
- }
589
- try {
590
- usageStore.close();
591
- } catch (e) {
592
- dlog("warn", `usageStore close failed: ${(e as Error).message}`);
593
- }
594
- try {
595
- watcherRegistry.closeAll();
596
- } catch (e) {
597
- dlog("warn", `watcherRegistry close failed: ${(e as Error).message}`);
598
- }
599
- try {
600
- sessionState.flush();
601
- } catch (e) {
602
- dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
603
- }
604
- // [LAW:one-source-of-truth] Remove the lease only if it still names us. A
605
- // displaced daemon (socket stolen, thief wrote its own lease) must not delete
606
- // the live owner's lease on its way out, or the next EADDRINUSE would read
607
- // `absent` and reclaim the thief's live socket — cascading the theft.
608
- removeLeaseIfOwned(leasePath(), process.pid);
609
- // [LAW:one-source-of-truth] Same "only if it still names us" guard as the
610
- // lease above, reused via releaseRegistration — a slot this daemon never
611
- // claimed (exempt production, or refused before claiming one) is null and
612
- // skipped.
613
- if (breakerRegistryPath !== null) {
614
- releaseRegistration(
615
- breakerRegistryPath,
616
- process.pid,
617
- readRegistryEntry,
618
- (p) => fs.unlinkSync(p),
619
- );
620
- }
621
- // Every dlog above was a synchronous append (log.ts), so the death line is
622
- // already on disk; nothing to flush before exit.
623
- process.exit(code);
624
- }
625
-
626
- // --- per-connection handler ---
627
-
628
- function handleConnection(sock: net.Socket): void {
629
- inFlight++;
630
- stats.inFlight = inFlight;
631
- let responded = false;
632
-
633
- // [LAW:no-ambient-temporal-coupling] respond owns the response→exit
634
- // ordering. exitAfterFlush (an exit code; null = stay up) is performed
635
- // by sock.end's completion callback, which Node invokes on 'finish' OR
636
- // 'error' — a total signal. A peer that vanished mid-flush still settles,
637
- // so the exit wish can never be stranded on a dead socket, and a live
638
- // peer always has the frame in the kernel buffer before process.exit
639
- // (unix-socket data survives writer exit). No fixed sleep stands between
640
- // respond and exit; the SIGKILL backstop inside shutdown() is the
641
- // unrelated last-resort safety.
642
- const respond = (resp: Response, exitAfterFlush: number | null): void => {
643
- if (responded) {
644
- // First responder owns the flush. Reaching here with an exit wish is
645
- // unreachable today (both exit-carrying arms resolve synchronously,
646
- // far inside the request timeout) — but if it ever happens, say so
647
- // instead of silently leaving a daemon up that was told to exit.
648
- // [LAW:no-silent-failure]
649
- if (exitAfterFlush !== null) {
650
- dlog(
651
- "warn",
652
- "exit-after-flush dropped: an earlier responder settled this socket",
653
- );
654
- }
655
- return;
656
- }
657
- responded = true;
658
- const settle =
659
- exitAfterFlush === null
660
- ? undefined
661
- : (): void => shutdown(exitAfterFlush);
662
- try {
663
- sock.end(encodeFrame(resp), settle);
664
- } catch (e) {
665
- // [LAW:no-silent-failure] The response is lost (socket already torn
666
- // down), but the exit wish must not be.
667
- dlog("warn", `response write failed: ${(e as Error).message}`);
668
- settle?.();
669
- }
670
- };
671
-
672
- // Per-request timeout protects the daemon from a single slow request
673
- // (e.g. a hung git call) blocking subsequent connections. It abandons the
674
- // RESPONSE, not the work — the handler promise keeps running.
675
- //
676
- // [LAW:one-source-of-truth] That is safe for the transcript-fs path because
677
- // the work is bounded + shared, not orphaned: the today aggregate and
678
- // per-session usage compute behind a SingleFlight (src/utils/single-flight.ts),
679
- // so a timed-out render that abandoned its await leaves behind the ONE
680
- // canonical in-flight scan, which the next render coalesces onto rather than
681
- // duplicating. A timeout therefore adds zero new fs work — there is never
682
- // more than one scan per key to orphan. Cancellation would be both messier
683
- // and wasteful here (the in-flight scan is exactly what the next tick needs).
684
- const timer = setTimeout(() => {
685
- stats.requestsTimedOut++;
686
- respond(
687
- {
688
- ok: false,
689
- error: "request exceeded 200ms",
690
- code: "TIMEOUT",
691
- daemonV: PROTOCOL_VERSION,
692
- },
693
- null,
694
- );
695
- }, REQUEST_TIMEOUT_MS);
696
-
697
- const reader = makeFrameReader(
698
- (frame) => {
699
- void handleRequest(frame as Request)
700
- .then((r) => respond(r.resp, r.exitAfterFlush))
701
- .catch((err) => {
702
- dlog("error", `handler threw: ${err?.stack || err}`);
703
- respond(
704
- {
705
- ok: false,
706
- error: String(err?.message || err),
707
- code: "RENDER_FAILED",
708
- daemonV: PROTOCOL_VERSION,
709
- },
710
- null,
711
- );
712
- });
713
- },
714
- (err) => {
715
- dlog("warn", `frame parse failed: ${err.message}`);
716
- respond(
717
- {
718
- ok: false,
719
- error: err.message,
720
- code: "BAD_REQUEST",
721
- daemonV: PROTOCOL_VERSION,
722
- },
723
- null,
724
- );
725
- },
726
- );
727
-
728
- sock.on("data", reader);
729
- sock.on("error", (err) => {
730
- dlog("warn", `socket error: ${err.message}`);
731
- });
732
- sock.on("close", () => {
733
- clearTimeout(timer);
734
- inFlight = Math.max(0, inFlight - 1);
735
- stats.inFlight = inFlight;
736
- });
737
- }
738
-
739
- // [LAW:no-ambient-temporal-coupling] A request whose semantics include "then
740
- // exit" (the shutdown verb, the stale-binary version mismatch) must not exit
741
- // until its response has flushed — but handleRequest cannot see the socket.
742
- // So the exit is returned as DATA (the exit code; null = stay up) and the
743
- // connection boundary, which owns the flush, sequences shutdown on the write
744
- // completion. No timer stands between respond and exit.
745
- // [LAW:effects-at-boundaries] handleRequest computes the description; the
746
- // socket boundary performs it.
747
- interface HandledRequest {
748
- resp: Response;
749
- exitAfterFlush: number | null;
750
- }
751
-
752
- const stay = (resp: Response): HandledRequest => ({
753
- resp,
754
- exitAfterFlush: null,
755
- });
756
-
757
- async function handleRequest(req: Request): Promise<HandledRequest> {
758
- if (
759
- !req ||
760
- typeof req !== "object" ||
761
- typeof (req as Request).v !== "number"
762
- ) {
763
- return stay({
764
- ok: false,
765
- error: "malformed request",
766
- code: "BAD_REQUEST",
767
- daemonV: PROTOCOL_VERSION,
768
- });
769
- }
770
-
771
- if (req.v !== PROTOCOL_VERSION) {
772
- // [LAW:types-are-the-program] The asymmetry is data, not control flow.
773
- // client > daemon: the *binary* probably upgraded under us. Exit so the
774
- // next client respawns from the current artifact.
775
- // client < daemon: the *client* is stale. Respawning daemon does not
776
- // help (the new daemon will have the same version). Stay up and
777
- // return VERSION_MISMATCH — the client is responsible for surfacing
778
- // the diagnostic and refusing to kick. Shutting down here was the
779
- // load-bearing half of the 452-corpse spiral (kz8.5).
780
- if (req.v > PROTOCOL_VERSION) {
781
- dlog(
782
- "info",
783
- `version mismatch: client=${req.v} > daemon=${PROTOCOL_VERSION}; binary likely upgraded — exiting after the response flushes`,
784
- );
785
- } else {
786
- dlog(
787
- "info",
788
- `version mismatch: client=${req.v} < daemon=${PROTOCOL_VERSION}; client is stale — staying up`,
789
- );
790
- }
791
- return {
792
- resp: {
793
- ok: false,
794
- error: `protocol v${req.v} not supported (daemon at v${PROTOCOL_VERSION})`,
795
- code: "VERSION_MISMATCH",
796
- daemonV: PROTOCOL_VERSION,
797
- },
798
- // [LAW:dataflow-not-control-flow] The asymmetry above is this value.
799
- // Exit is sequenced on the response flush, so the client always sees
800
- // the VERSION_MISMATCH diagnostic — never a dead socket.
801
- exitAfterFlush: req.v > PROTOCOL_VERSION ? 0 : null,
802
- };
803
- }
804
-
805
- if (req.kind === "shutdown") {
806
- return { resp: { ok: true, output: "" }, exitAfterFlush: 0 };
807
- }
808
-
809
- if (req.kind === "stats") {
810
- // [LAW:single-enforcer] Stats requests do NOT bump request counters —
811
- // observability shouldn't pollute the metric being observed.
812
- return stay({
813
- ok: true,
814
- stats: stats.snapshot({
815
- gitCache: gitService.getStats(),
816
- usageCache: usageStore.getStats(),
817
- renderCacheSize: renderCache.size,
818
- watchersActive: watcherRegistry.size(),
819
- nextRestartReason: limits?.describeNextRestart() ?? null,
820
- }),
821
- });
822
- }
823
-
824
- if (req.kind === "render") {
825
- stats.requestsTotal++;
826
- const t0 = Date.now();
827
- try {
828
- // [LAW:single-enforcer] One trust-boundary check for incoming hookData.
829
- // The validator reports missing/wrong-typed required fields and unknown
830
- // top-level keys. Required-field problems are *protocol* failures
831
- // (Claude Code's schema guarantees these — their absence means the
832
- // sender is broken or malicious); unknown fields are advisory (Anthropic
833
- // may have added something).
834
- const { report } = validateHookData(req.hookData as unknown);
835
- for (const field of report.unknownTopLevelFields) {
836
- dlog(
837
- "info",
838
- `schema: unknown field '${field}' — Anthropic may have added it`,
839
- );
840
- }
841
- // [LAW:no-silent-fallbacks][LAW:types-are-the-program] Gate hard on
842
- // schema violations. Continuing with `workspace?.project_dir` would
843
- // collapse "absent" into an empty-string cache key — silently sharing
844
- // one entry across every malformed request — and downstream code would
845
- // have to defend against an empty projectDir forever. Reject here so
846
- // the types downstream carry the strongest true theorem: by the time
847
- // a cache entry is built, projectDir/cwd are real non-empty strings.
848
- const wireProblems: string[] = [];
849
- for (const path of report.missingRequired) {
850
- wireProblems.push(`missing required field '${path}'`);
851
- }
852
- for (const { path, expected, got } of report.typeMismatches) {
853
- wireProblems.push(`field '${path}' expected ${expected}, got ${got}`);
854
- }
855
- if (req.cwd === "") {
856
- wireProblems.push("request 'cwd' is empty");
857
- }
858
- if (wireProblems.length > 0) {
859
- stats.requestsErrored++;
860
- dlog("warn", `BAD_REQUEST: ${wireProblems.join("; ")}`);
861
- return stay({
862
- ok: false,
863
- error: `malformed hookData: ${wireProblems.join("; ")}`,
864
- code: "BAD_REQUEST",
865
- daemonV: PROTOCOL_VERSION,
866
- });
867
- }
868
- const projectDir = req.hookData.workspace.project_dir;
869
- // [LAW:dataflow-not-control-flow] thread the *request's* cwd, not the
870
- // daemon's process.cwd(), so config resolution depends only on request
871
- // data — the daemon's own working directory must not influence output.
872
- const { configFile, unknownFlagsError } = parseRenderArgs(req.args);
873
- // [LAW:effects-at-boundaries] The load-config verb writes per-session
874
- // config overrides into SessionState; this is the one read point.
875
- const sessionId = req.hookData.session_id;
876
- const sessionConfigFile =
877
- sessionState.get(sessionId, SESSION_CONFIG_OVERRIDE_KEY) ?? configFile;
878
- const entry = renderCache.getOrCreate(
879
- projectDir,
880
- req.cwd,
881
- sessionConfigFile,
882
- );
883
- // [LAW:parse-dont-validate] The ONE checkpoint for everything the client
884
- // observed and the daemon cannot. Raw `req.*` hint fields are not read
885
- // past this line; `hints` is the stamped type the render path consumes.
886
- //
887
- // [LAW:single-enforcer] Every hint is captured client-side because the
888
- // daemon is detached and shared: its env answers for whichever shell
889
- // spawned it. We do NOT consult getTerminalWidth's env/stderr fallbacks
890
- // for width, and we do NOT consult SSH_* for remoteness — both would
891
- // describe a different session than the one being rendered.
892
- // [LAW:one-source-of-truth] Both branches feed raw cols through
893
- // applyClaudeCodeReserve, so `width` always means "usable cells
894
- // post-reserve" with no semantic split between wire-supplied and
895
- // fallback values.
896
- const hints = parseClientHints(req);
897
- const termCols = hints.termCols;
898
- const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
899
- const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
900
- // [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
901
- // body = state ? renderDsl(state) : "" ; output = body + icon
902
- // No special-case branches — same composition every render.
903
- let body = "";
904
- if (entry.state !== null) {
905
- // [LAW:one-source-of-truth] Every globals field resolved ONCE per
906
- // render, here — before the payload build, so the same struct feeds
907
- // BOTH the payload's `*.effective` fields (what a trigger label says)
908
- // AND renderOpts below (what actually renders). One resolution, two
909
- // readers, so a label can never disagree with the bar. The precedence
910
- // the resolver applies, and why each rung sits where it does, lives
911
- // with the chain (resolveEffectiveGlobals, and src/config/presets.ts).
912
- // Read alongside the config, from the same entry, in one statement —
913
- // which is exactly what the closure below claims about it.
914
- const presetRootOps = entry.state.presetRootOps;
915
- const effective: EffectiveGlobals = resolveEffectiveGlobals(
916
- entry.state.config,
917
- (key: string) => sessionState.get(req.hookData.session_id, key),
918
- // [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — read from
919
- // THIS entry's own presetRootOps (the record that fed the SAME
920
- // reload that produced entry.state.config), never a fresh
921
- // loadOverrides() here — a second read could race a concurrent
922
- // write and disagree with the tree that actually rendered. That is
923
- // why it arrives as a closure over this entry rather than being
924
- // looked up inside the resolver.
925
- (preset: string) => presetIsCustomized(presetRootOps, preset),
926
- );
927
- const payload = await buildRenderPayload(
928
- req.hookData,
929
- payloadDeps,
930
- req.cwd,
931
- entry.state.neededInputPaths,
932
- effective,
933
- hints,
934
- );
935
- // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
936
- // is derived from the same effective theme resolved above — so a theme
937
- // click recolors the whole bar on the next render. Not frozen on the
938
- // cache entry (one entry serves many sessions). paletteForThemeName
939
- // memoizes, so the per-render cost is one Map lookup once the theme is
940
- // warm.
941
- const basePalette = paletteForThemeName(effective.theme);
942
- // [LAW:one-source-of-truth] Every renderOpts field below reuses the
943
- // SAME `effective` struct the payload was just built from — no second
944
- // `?? DEFAULT_X` computation to drift from it.
945
- renderOpts.style = effective.style;
946
- // The `plain` joiner's cell separator. Assigned unconditionally like
947
- // every field around it: `undefined` is a value pickJoiner already
948
- // reads as "PlainJoiner's own default", not an absence to branch on.
949
- renderOpts.separator = effective.separator;
950
- renderOpts.wrap = effective.autoWrap;
951
- renderOpts.padding = effective.padding;
952
- renderOpts.charset = effective.charset;
953
- renderOpts.colorCompatibility = effective.colorCompatibility;
954
- // [LAW:single-enforcer] renderDsl internally calls
955
- // `registry.applyInput(payload)` as its first step (see step 1 in
956
- // src/dsl/render.ts). The daemon must not pre-apply — doing so
957
- // would run the MobX action twice per render and clear last_error
958
- // diagnostics on the round trip.
959
- body = renderDsl(
960
- entry.state.config,
961
- entry.state.compiled,
962
- entry.state.store,
963
- entry.state.registry,
964
- payload,
965
- basePalette,
966
- renderOpts,
967
- // [LAW:single-enforcer] The per-segment StripCell sink for the
968
- // `debug segments` projection. Its identity stays stable for the
969
- // cache entry's lifetime; renderDsl clears + repopulates it
970
- // in place. Cells are cheap (already computed during the render);
971
- // the per-segment ANSI serialization happens lazily inside the
972
- // debug handler so normal renders pay no extra serializer cost.
973
- { perSegmentSink: entry.state.lastRenderCellsBySegment },
974
- {
975
- look: lookKeyByName(entry.state.config.looks, effective.look),
976
- preset: effective.preset,
977
- },
978
- );
979
- }
980
- // [LAW:one-source-of-truth] Consume the transient click error written by
981
- // dispatch on partial/total effect failure, then clear it so it shows
982
- // exactly once. Only called when non-null to avoid a no-op persist+MobX
983
- // tick on every render.
984
- const clickError = sessionState.get(
985
- req.hookData.session_id,
986
- "click.error",
987
- );
988
- if (clickError)
989
- sessionState.clear(req.hookData.session_id, "click.error");
990
- const combinedError =
991
- [unknownFlagsError, entry.lastError, clickError]
992
- .filter(Boolean)
993
- .join("\n") || null;
994
- const output = composeWithDiagnostics(
995
- body,
996
- combinedError,
997
- entry.lastWarning,
998
- );
999
- const ms = Date.now() - t0;
1000
- const g = gitService.getStats();
1001
- const u = usageStore.getStats();
1002
- dlog(
1003
- "info",
1004
- `render sid=${req.hookData.session_id ?? "?"} took=${ms}ms termCols=${termCols ?? "?"} width=${width} git=${g.size}/${g.hits}h/${g.misses}m usage=${u.size}/${u.hits}h/${u.misses}m err=${entry.lastError ? "Y" : "N"} warn=${entry.lastWarning ? "Y" : "N"}`,
1005
- );
1006
- return stay({ ok: true, output: output + "\n" });
1007
- } catch (e) {
1008
- stats.requestsErrored++;
1009
- throw e;
1010
- }
1011
- }
1012
-
1013
- if (req.kind === "click") {
1014
- return stay(await handleClick(req.verb, req.value));
1015
- }
1016
-
1017
- if (req.kind === "debug") {
1018
- // [LAW:single-enforcer] One trust-boundary check at the wire edge —
1019
- // `what` is untrusted JSON. isDebugWhat narrows it to the discriminated
1020
- // union the introspector consumes; an invalid value short-circuits
1021
- // here, not deep inside buildDebugSnapshot.
1022
- if (!isDebugWhat(req.what)) {
1023
- return stay({
1024
- ok: false,
1025
- // [LAW:errors-context-in-errors] Include the allowed values so a
1026
- // CLI consumer (or operator) sees what is supported without
1027
- // grep — same pattern as the set-state verb's unknown-key error
1028
- // in src/daemon/verbs/state-validators.ts.
1029
- error: `unknown debug 'what': ${String(req.what)} (have: ${DEBUG_WHATS.join(", ")})`,
1030
- code: "BAD_REQUEST",
1031
- daemonV: PROTOCOL_VERSION,
1032
- });
1033
- }
1034
- // [LAW:dataflow-not-control-flow] The debug projection samples whatever
1035
- // DSL state the cache currently holds. With cache keys scoped on
1036
- // (projectDir, cwd) and the debug request carrying neither, we sample
1037
- // the first populated existing entry — sufficient for `debug vars`,
1038
- // `debug segments`, `debug config` against the active workload.
1039
- // firstPopulatedState iterates existing entries only; it does NOT
1040
- // create a fresh one, so debug introspection never has the side effect
1041
- // of standing up a new (projectDir=undefined) cache entry tied to the
1042
- // daemon's own process.cwd(). A future debug-target selector would
1043
- // thread (projectDir, cwd) through the wire.
1044
- const dbgEntry = renderCache.firstPopulatedState();
1045
- // [LAW:dataflow-not-control-flow] Lazy per-segment serialization: the
1046
- // cache stores StripCell arrays (cheap, written by renderDsl).
1047
- // The debug projection needs strings, so serialize only for the
1048
- // `segments` projection (`vars` and `config` don't need it) and only
1049
- // when this request actually fires. Normal renders pay no per-segment
1050
- // serializer cost — that work shifts to debug-request time, which is
1051
- // operator-driven and rare.
1052
- const dbgState =
1053
- dbgEntry === null
1054
- ? null
1055
- : {
1056
- store: dbgEntry.store,
1057
- registry: dbgEntry.registry,
1058
- config: dbgEntry.config,
1059
- compiled: dbgEntry.compiled,
1060
- lastRenderBySegment:
1061
- req.what === "segments"
1062
- ? serializeSegmentCells(
1063
- dbgEntry.lastRenderCellsBySegment,
1064
- dbgEntry.config.globals.charset ?? DEFAULT_CHARSET,
1065
- dbgEntry.config.globals.colorCompatibility ??
1066
- DEFAULT_COLOR_COMPATIBILITY,
1067
- )
1068
- : EMPTY_RENDER_MAP,
1069
- };
1070
- return stay({ ok: true, debug: buildDebugSnapshot(req.what, dbgState) });
1071
- }
1072
-
1073
- return stay({
1074
- ok: false,
1075
- error: "unknown kind",
1076
- code: "BAD_REQUEST",
1077
- daemonV: PROTOCOL_VERSION,
1078
- });
1079
- }
1080
-
1081
- // --- diagnostics composition ---
1082
- //
1083
- // [LAW:no-silent-fallbacks] Bad config can't quietly degrade output. The
1084
- // render pipeline carries two independent diagnostic channels:
1085
- // error — load-fatal: parse/validation failed; bar is last-known-good
1086
- // or empty. Rendered red.
1087
- // warning — advisory: load succeeded but something needs attention (e.g.
1088
- // same-location .json5 + .json collision). Rendered amber.
1089
- // Either way the failure is visible at the point of impact, and each
1090
- // channel has its own click verb (show-config-error / show-config-warning)
1091
- // so the operator can copy the message to clipboard for inspection.
1092
- //
1093
- // [LAW:one-type-per-behavior] Two severities → two channels. The
1094
- // composer's signature carries both; severity is encoded in WHICH
1095
- // argument is non-null, not in a string prefix or a tag inside the
1096
- // message. The two icons render independently — both can show at once.
1097
- //
1098
- // [LAW:types-are-the-program] The diagnostic's visible text IS (a
1099
- // projection of) the underlying message — not a constant label that hides
1100
- // the content behind a click. The leading ⚠ + background color carry
1101
- // severity; the rest of the cell is the actual error/warning, sanitized
1102
- // and clipped to a single-line budget. A label divorced from the message
1103
- // would be the type lying about what's in the channel.
1104
- // [LAW:one-source-of-truth] Style constants come from the shared leaf
1105
- // (src/render/diagnostic-style.ts) — the same visual identity the client's
1106
- // permanent glyph uses. Only the OSC-8 link plumbing is local here.
1107
- const OSC8_OPEN = "\x1b]8;;";
1108
- const OSC8_CLOSE = "\x1b]8;;\x1b\\";
1109
- const ST = "\x1b\\";
1110
-
1111
- // [LAW:single-enforcer][LAW:no-silent-fallbacks] Parse render-path args with
1112
- // the standard util at the trust boundary. `--config <path>` is the sole
1113
- // valid render flag; every other flag is surfaced as a render-time
1114
- // diagnostic icon (caller composes it alongside config errors). The
1115
- // `--config` value is `~`-expanded here, so every consumer downstream
1116
- // receives a literal path — no caller has to remember to expand it.
1117
- //
1118
- // `tokens: true, strict: false, allowPositionals: true` together let the
1119
- // parser emit a token entry for every flag (known or unknown) without
1120
- // throwing on unknown ones, and without mis-classifying their values as
1121
- // positionals.
1122
- function parseRenderArgs(args: string[]): {
1123
- configFile: string | undefined;
1124
- unknownFlagsError: string | null;
1125
- } {
1126
- const { values, tokens } = parseArgs({
1127
- args: args.slice(1), // skip binary path
1128
- options: { config: { type: "string" } },
1129
- strict: false,
1130
- tokens: true,
1131
- allowPositionals: true,
1132
- });
1133
- const unknown = [
1134
- ...new Set(
1135
- (tokens ?? [])
1136
- .filter(
1137
- (t): t is Extract<typeof t, { kind: "option" }> =>
1138
- t.kind === "option" && t.name !== "config",
1139
- )
1140
- .map((t) => `--${t.name}`),
1141
- ),
1142
- ];
1143
- const rawConfig = values.config as string | undefined;
1144
- return {
1145
- configFile: rawConfig === undefined ? undefined : expandHome(rawConfig),
1146
- unknownFlagsError:
1147
- unknown.length > 0 ? `Unknown flags: ${unknown.join(", ")}` : null,
1148
- };
1149
- }
1150
-
1151
- // Per-line visible budget and max rows for multi-line diagnostic blocks.
1152
- // Messages from the config validator (formatIssues) are already structured
1153
- // as one line per issue, so splitting there is the natural unit of display.
1154
- // Deliberately decoupled from DEFAULT_TERMINAL_WIDTH: that constant means
1155
- // "raw terminal cols we assume" and is reserved-against before reaching the
1156
- // renderer; this one is a direct visible-char cap on already-rendered
1157
- // diagnostic text. They happen to share the value 120 today but have
1158
- // different semantic intents.
1159
- const MAX_DIAGNOSTIC_LINE_LEN = 120;
1160
- const MAX_DIAGNOSTIC_LINES = 8;
1161
-
1162
- function makeDiagnosticLink(
1163
- verb: typeof VERB_SHOW_CONFIG_ERROR | typeof VERB_SHOW_CONFIG_WARNING,
1164
- message: string,
1165
- bg: string,
1166
- fg: string,
1167
- ): string {
1168
- // Full message in the OSC-8 URL (clipboard-copy on click) — truncation
1169
- // only affects what is visible, never what is accessible. [LAW:single-enforcer]
1170
- // The click URL is born through effectsUrl like every other click — one
1171
- // single-effect dispatch list, no second URL-format in the codebase.
1172
- const url = effectsUrl([{ verb, args: [message] }]);
1173
- // [LAW:dataflow-not-control-flow] Split on natural line boundaries from
1174
- // the source message (config validator emits one issue per line), sanitize
1175
- // each line individually, then render each as a separate styled row.
1176
- // This preserves structured multi-line output instead of collapsing N
1177
- // issues into a single truncated string the user cannot read.
1178
- const lines = message
1179
- .split(/\r\n|\r|\n/)
1180
- .map((l) => sanitizeAndTruncate(l, MAX_DIAGNOSTIC_LINE_LEN))
1181
- .filter(Boolean)
1182
- .slice(0, MAX_DIAGNOSTIC_LINES);
1183
- if (lines.length === 0) return "";
1184
- const first = `${OSC8_OPEN}${url}${ST}${bg}${fg} ⚠ ${lines[0]} ${ANSI_RESET}${OSC8_CLOSE}`;
1185
- const rest = lines
1186
- .slice(1)
1187
- .map(
1188
- (l) =>
1189
- `${OSC8_OPEN}${url}${ST}${bg}${fg} ${l} ${ANSI_RESET}${OSC8_CLOSE}`,
1190
- );
1191
- return [first, ...rest].join("\n");
1192
- }
1193
-
1194
- function composeWithDiagnostics(
1195
- body: string,
1196
- error: string | null,
1197
- warning: string | null,
1198
- ): string {
1199
- // [LAW:dataflow-not-control-flow] Diagnostics list is data; the
1200
- // composer walks it. Each non-null channel contributes one or more prefix
1201
- // rows (makeDiagnosticLink returns a \n-joined multi-line block when the
1202
- // message has natural line breaks). Order is error-first (more severe),
1203
- // then warning, then body.
1204
- const prefixes: string[] = [];
1205
- if (error) {
1206
- prefixes.push(
1207
- makeDiagnosticLink(
1208
- VERB_SHOW_CONFIG_ERROR,
1209
- error,
1210
- DIAGNOSTIC_ERROR_BG,
1211
- DIAGNOSTIC_ERROR_FG,
1212
- ),
1213
- );
1214
- }
1215
- if (warning) {
1216
- prefixes.push(
1217
- makeDiagnosticLink(
1218
- VERB_SHOW_CONFIG_WARNING,
1219
- warning,
1220
- DIAGNOSTIC_WARNING_BG,
1221
- DIAGNOSTIC_WARNING_FG,
1222
- ),
1223
- );
1224
- }
1225
- if (prefixes.length === 0) return body;
1226
- // No body → emit the diagnostic strip alone (startup-error case). Body
1227
- // present → prepend on its own line so it's visible regardless of bar
1228
- // width. Multiple diagnostics stack on their own lines.
1229
- const strip = prefixes.join("\n");
1230
- return body ? `${strip}\n${body}` : strip;
1231
- }
1232
-
1233
- // --- click verb dispatch ---
1234
- // [LAW:dataflow-not-control-flow] The dispatcher is a table lookup. The verb
1235
- // table (src/daemon/verbs/index.ts) is the single canonical list of supported
1236
- // verbs — handlers live there, the dispatcher only routes.
1237
- //
1238
- // [LAW:types-are-the-program] The error class on the throw determines the
1239
- // response code: BadVerbArgs (invalid input shape) becomes BAD_REQUEST; any
1240
- // other Error (operational failure) becomes RENDER_FAILED. No string matching.
1241
-
1242
- const verbCtx = { sessionState, dlog };
1243
-
1244
- // [LAW:single-enforcer] Style + color compatibility shared by the render
1245
- // path and the lazy debug-side per-segment serializer. Per-request `width`
1246
- // is composed on top at the wire boundary (handleRequest("render")) and
1247
- // passed through as renderOpts. Debug serialization composes its own
1248
- // per-segment opts with width: Number.POSITIVE_INFINITY since each segment
1249
- // is rendered standalone (wrap doesn't apply to a one-segment projection).
1250
- const RENDER_OPTS_BASE = {
1251
- style: "powerline" as const,
1252
- colorCompatibility: DEFAULT_COLOR_COMPATIBILITY,
1253
- wrap: DEFAULT_WRAP,
1254
- padding: DEFAULT_PADDING,
1255
- charset: DEFAULT_CHARSET,
1256
- };
1257
- const DEBUG_RENDER_OPTS: BuildLineOptions = {
1258
- ...RENDER_OPTS_BASE,
1259
- width: Number.POSITIVE_INFINITY,
1260
- };
1261
-
1262
- // [LAW:no-defensive-null-guards] Reused empty map for the `vars` /
1263
- // `config` debug projections — they don't read lastRenderBySegment but
1264
- // the DaemonDslState type requires the field.
1265
- const EMPTY_RENDER_MAP = new Map<string, string>();
1266
-
1267
- // [LAW:one-source-of-truth] The joiner glyph vocabulary and the color depth
1268
- // are serialization-time choices, and both are config-only (no SessionState
1269
- // half) — so the faithful values are fully derivable from the sampled entry's
1270
- // config, unlike style, whose live session-over-config resolution needs a
1271
- // session a debug request doesn't carry. The caller threads the
1272
- // entry-resolved values; this serializer never re-defaults them.
1273
- function serializeSegmentCells(
1274
- cells: ReadonlyMap<string, readonly RichText[]>,
1275
- charset: Charset,
1276
- colorCompatibility: ColorCompatibility,
1277
- ): Map<string, string> {
1278
- const out = new Map<string, string>();
1279
- for (const [name, segCells] of cells) {
1280
- out.set(
1281
- name,
1282
- renderStripCells(segCells, {
1283
- ...DEBUG_RENDER_OPTS,
1284
- charset,
1285
- colorCompatibility,
1286
- }),
1287
- );
1288
- }
1289
- return out;
1290
- }
1291
-
1292
- // [LAW:single-enforcer] The payload-builder dependency bundle. One value
1293
- // passed through every render — the data the daemon brings to each tick.
1294
- const payloadDeps = {
1295
- gitProvider: gitService,
1296
- usageStore,
1297
- contextProvider,
1298
- metricsProvider,
1299
- tmuxService,
1300
- // [LAW:single-enforcer] buildRenderPayload is the one log site for the
1301
- // outcome-carrying provider lanes (git, cache).
1302
- log: dlog,
1303
- // [LAW:single-enforcer] The daemon's wall clock — the same instant source
1304
- // the rate-limit ETA projection and the template's reset countdown read.
1305
- clock: () => new Date(),
1306
- };
1307
-
1308
- function handleClick(verb: string, value: string): Response {
1309
- const handler = VERBS.get(verb);
1310
- if (!handler) {
1311
- return {
1312
- ok: false,
1313
- error: `unknown click verb: ${verb}`,
1314
- code: "BAD_REQUEST",
1315
- daemonV: PROTOCOL_VERSION,
1316
- };
1317
- }
1318
- try {
1319
- handler(value, verbCtx);
1320
- return { ok: true, output: "" };
1321
- } catch (e) {
1322
- const code = e instanceof BadVerbArgs ? "BAD_REQUEST" : "RENDER_FAILED";
1323
- return {
1324
- ok: false,
1325
- error: String(e instanceof Error ? e.message : e),
1326
- code,
1327
- daemonV: PROTOCOL_VERSION,
1328
- };
1329
- }
1330
- }