@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,1103 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { parseArgs } from "node:util";
6
+ import {
7
+ daemonDir,
8
+ ensureSocketParentSafe,
9
+ pidPath,
10
+ socketPath,
11
+ sessionStatePath,
12
+ } from "./paths";
13
+ import { dlog, closeLog } from "./log";
14
+ import {
15
+ PROTOCOL_VERSION,
16
+ encodeFrame,
17
+ makeFrameReader,
18
+ sanitizeTermCols,
19
+ } from "./protocol";
20
+ import type { Request, Response } from "./protocol";
21
+ import { GitDataProvider } from "./cache/git";
22
+ import { SessionUsageStore } from "./cache/session-usage-store";
23
+ import { RenderCache } from "./cache/render";
24
+ import { WatcherRegistry } from "./cache/watchers";
25
+ import { RuntimeStats } from "./stats";
26
+ import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
27
+ import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
28
+ import { SessionState } from "./session-state";
29
+ import { FileSessionStorage } from "./session-state-file";
30
+ import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
31
+ import {
32
+ effectsUrl,
33
+ VERB_SHOW_CONFIG_ERROR,
34
+ VERB_SHOW_CONFIG_WARNING,
35
+ } from "../click/wire.js";
36
+ import { validateHookData } from "../utils/schema-validator.js";
37
+ import { setLaunchStats } from "../proc/launch";
38
+ import { buildDebugSnapshot } from "./debug";
39
+ import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
40
+ import { expandHome } from "../config/dsl-loader.js";
41
+ import { renderDsl } from "../dsl/render.js";
42
+ import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
43
+ import {
44
+ renderStripCells,
45
+ DEFAULT_TERMINAL_WIDTH,
46
+ type BuildLineOptions,
47
+ } from "../render/strip.js";
48
+ import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
49
+ import type { RichText } from "@promptctl/rich-js";
50
+ import { buildRenderPayload } from "./render-payload.js";
51
+ import { ContextProvider } from "../segments/context.js";
52
+ import { MetricsProvider } from "../segments/metrics.js";
53
+ import { TmuxService } from "../segments/tmux.js";
54
+ import { sanitizeAndTruncate } from "../render/diagnostic-text.js";
55
+ import {
56
+ ANSI_RESET,
57
+ DIAGNOSTIC_ERROR_BG,
58
+ DIAGNOSTIC_ERROR_FG,
59
+ DIAGNOSTIC_WARNING_BG,
60
+ DIAGNOSTIC_WARNING_FG,
61
+ } from "../render/diagnostic-style.js";
62
+
63
+ // [LAW:one-source-of-truth] one cache instance per daemon process — multiple
64
+ // instances would defeat the share-across-sessions invariant.
65
+ const stats = new RuntimeStats();
66
+ // [LAW:single-enforcer] Route all child_process spawns through src/proc/launch.
67
+ // Installing the metering handle here makes subprocess counts visible in
68
+ // daemon-stats.
69
+ setLaunchStats(stats.launchStats);
70
+ // [LAW:single-enforcer] The daemon injects `dlog` into both registries so
71
+ // cache + watcher lifecycle events land in daemon.log at the right level.
72
+ // Non-daemon consumers (var-system tests, future library use) take the
73
+ // default debug-routed loggers and never write to daemon log files.
74
+ const watcherRegistry = new WatcherRegistry({
75
+ counters: stats,
76
+ logger: dlog,
77
+ });
78
+ const gitService = new GitDataProvider({
79
+ watchers: watcherRegistry,
80
+ logger: dlog,
81
+ });
82
+ const usageStore = new SessionUsageStore();
83
+ // [LAW:locality-or-seam] Constructed ephemeral so importing this module (CLI
84
+ // relay, subcommands) does no disk I/O. The daemon binds the file-backed
85
+ // storage in runDaemon(), making it the sole reader/writer of the state file.
86
+ const sessionState = new SessionState();
87
+ // [LAW:one-source-of-truth] One provider per data shape, shared across every
88
+ // render in this daemon. The render cache owns DSL-state-per-config; these
89
+ // providers serve the augmented payload that flows through every render.
90
+ const contextProvider = new ContextProvider();
91
+ const metricsProvider = new MetricsProvider();
92
+ const tmuxService = new TmuxService();
93
+ const renderCache = new RenderCache({
94
+ gitService,
95
+ sessionState,
96
+ watchers: watcherRegistry,
97
+ });
98
+
99
+ const REQUEST_TIMEOUT_MS = 200;
100
+ const BIN_CHECK_INTERVAL_MS = 60 * 1000;
101
+
102
+ // Daemon entry point. Tries to bind the Unix socket — atomic bind() is the
103
+ // single-instance enforcer (two daemons cannot both bind the same path; the
104
+ // kernel makes duplicate-daemon unrepresentable). Listens for one request per
105
+ // connection. Any uncaught error exits non-zero; the next client obtains a
106
+ // fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
107
+ // obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
108
+ export function runDaemon(): void {
109
+ fs.mkdirSync(daemonDir(), { recursive: true });
110
+ // [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
111
+ // not a symlink before we bind. Without this check, a same-host attacker
112
+ // could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
113
+ // squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
114
+ // location — every bind path goes through the same trust precondition.
115
+ // No symmetric client-side check: the daemon is the sole creator, so a
116
+ // successful bind already proves the parent is trusted. Failure here surfaces
117
+ // as a daemon exit; the client falls back to the last cached render.
118
+ ensureSocketParentSafe(socketPath());
119
+
120
+ // Bind disk persistence now that we know we are the daemon process — load
121
+ // prior session state and become the sole writer of the state file.
122
+ sessionState.useStorage(
123
+ new FileSessionStorage(sessionStatePath(), 500, dlog),
124
+ );
125
+
126
+ // Catch-alls log + exit so the supervisor (the next client) can restart us.
127
+ // [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
128
+ // catching all of unknown space, not skipping known optional values.
129
+ process.on("uncaughtException", (err) => {
130
+ dlog("error", `uncaughtException: ${err.stack || err.message}`);
131
+ shutdown(1);
132
+ });
133
+ process.on("unhandledRejection", (reason) => {
134
+ dlog("error", `unhandledRejection: ${String(reason)}`);
135
+ shutdown(1);
136
+ });
137
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
138
+ process.on(sig, () => {
139
+ dlog("info", `received ${sig}, shutting down`);
140
+ shutdown(0);
141
+ });
142
+ }
143
+
144
+ // [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
145
+ // the watchdog calls shutdown(0), it never exits on its own. A production
146
+ // daemon has no spawner to outlive (env unset) and arms an inert handle; only
147
+ // a test-spawned daemon is anchored, so this is invisible to the real daemon.
148
+ armParentWatchdog({
149
+ anchor: anchorFromEnv(process.env),
150
+ isAlive: pidAlive,
151
+ onOrphaned: (reason) => {
152
+ dlog("info", `parent watchdog: ${reason}; shutting down`);
153
+ shutdown(0);
154
+ },
155
+ });
156
+
157
+ const server = net.createServer({ allowHalfOpen: false }, (sock) => {
158
+ handleConnection(sock);
159
+ });
160
+
161
+ // [LAW:single-enforcer] The atomic bind() is the daemon-singleton enforcer.
162
+ // Two daemons cannot both bind the same Unix socket path; the kernel makes
163
+ // duplicate-daemon unrepresentable. The pidfile is diagnostic only — never
164
+ // load-bearing for exclusion.
165
+ bindOrAttachAndExit(server, socketPath(), /* retried */ false);
166
+ }
167
+
168
+ // [LAW:dataflow-not-control-flow] One operation ("bring this server up or
169
+ // discover an existing one"). The bind result is the data that decides the
170
+ // next step; callers do not get to choose whether to spawn.
171
+ function bindOrAttachAndExit(
172
+ server: net.Server,
173
+ sockPath: string,
174
+ retried: boolean,
175
+ ): void {
176
+ server.removeAllListeners("error");
177
+ server.once("error", (err) => {
178
+ const code = (err as NodeJS.ErrnoException).code;
179
+ if (code !== "EADDRINUSE") {
180
+ dlog("error", `server error: ${err.message}`);
181
+ shutdown(1);
182
+ return;
183
+ }
184
+ if (retried) {
185
+ // Lost a rebind race with another duplicate. The kernel arbitrated; we
186
+ // are the loser. Exit cleanly so the winner serves.
187
+ dlog("info", "lost rebind race; another daemon is alive — exiting");
188
+ process.exit(0);
189
+ return;
190
+ }
191
+ void handleAddressInUse(server, sockPath);
192
+ });
193
+ server.listen(sockPath, () => onListening(sockPath));
194
+ }
195
+
196
+ async function handleAddressInUse(
197
+ server: net.Server,
198
+ sockPath: string,
199
+ ): Promise<void> {
200
+ // EADDRINUSE: either a live daemon (we are a duplicate — exit), or a stale
201
+ // socket file from a crashed prior daemon (unlink + rebind).
202
+ const alive = await isSocketAlive(sockPath);
203
+ if (alive) {
204
+ dlog("info", "another daemon is listening on socket — exiting");
205
+ process.exit(0);
206
+ }
207
+ // Race-window guard: between our first `isSocketAlive` returning false and
208
+ // our `unlinkSync` running, another concurrent recoverer could unlink+bind
209
+ // the path. Without re-checking, our unlink would remove their *live*
210
+ // socket, leaving two daemons (one orphaned-but-listening, one freshly
211
+ // bound). Re-check immediately before unlink. If a live listener appeared
212
+ // between checks, exit instead of stomping on it.
213
+ if (await isSocketAlive(sockPath)) {
214
+ dlog(
215
+ "info",
216
+ "race: another daemon claimed the socket during recovery — exiting",
217
+ );
218
+ process.exit(0);
219
+ }
220
+ dlog("warn", "stale socket from crashed daemon — unlinking and rebinding");
221
+ // [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
222
+ // FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
223
+ // in the worst state: no daemon + stale socket blocking future starts.
224
+ // Surface unrecoverable failures loudly. ENOENT is fine — the goal was
225
+ // "make the path bindable" and a missing path already satisfies that.
226
+ try {
227
+ fs.unlinkSync(sockPath);
228
+ } catch (e) {
229
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
230
+ dlog(
231
+ "error",
232
+ `cannot unlink stale socket ${sockPath}: ${(e as Error).message}`,
233
+ );
234
+ shutdown(1);
235
+ return;
236
+ }
237
+ }
238
+ bindOrAttachAndExit(server, sockPath, /* retried */ true);
239
+ }
240
+
241
+ // [LAW:no-defensive-null-guards] Three-state outcome distinguishes
242
+ // "definitely no listener" from "probably alive but slow." Callers that
243
+ // might destroy state on "no listener" (the stale-socket unlink path) must
244
+ // treat "unknown" as alive to avoid stomping on a slow live daemon.
245
+ type SocketAliveness = "alive" | "dead" | "unknown";
246
+
247
+ function probeSocket(sockPath: string): Promise<SocketAliveness> {
248
+ return new Promise((resolve) => {
249
+ const sock = net.connect(sockPath);
250
+ let settled = false;
251
+ let timer: ReturnType<typeof setTimeout> | null = null;
252
+ const done = (result: SocketAliveness): void => {
253
+ if (settled) return;
254
+ settled = true;
255
+ if (timer) clearTimeout(timer);
256
+ sock.removeAllListeners();
257
+ sock.destroy();
258
+ resolve(result);
259
+ };
260
+ sock.once("connect", () => done("alive"));
261
+ sock.once("error", (err) => {
262
+ // Only these error codes definitively mean "no listener at this path":
263
+ // ECONNREFUSED — socket file present, kernel rejected connect
264
+ // ENOENT — socket file absent
265
+ // ENOTSOCK — path exists but isn't a socket
266
+ // Anything else (EPERM, EACCES, EAGAIN, …) is ambiguous — assume the
267
+ // daemon is alive to avoid destructive false negatives.
268
+ const code = (err as NodeJS.ErrnoException).code;
269
+ const dead =
270
+ code === "ECONNREFUSED" || code === "ENOENT" || code === "ENOTSOCK";
271
+ done(dead ? "dead" : "unknown");
272
+ });
273
+ // 50ms is generous; localhost AF_UNIX connect is sub-ms when a listener
274
+ // exists. Timeout means "we couldn't tell" — treat as unknown so the
275
+ // stale-socket recovery path doesn't unlink a live (but slow) daemon's
276
+ // socket.
277
+ timer = setTimeout(() => done("unknown"), 50);
278
+ timer.unref();
279
+ });
280
+ }
281
+
282
+ // Convenience: callers that just want "is something listening" treat
283
+ // "unknown" as alive (conservative — used by the EADDRINUSE attach branch).
284
+ async function isSocketAlive(sockPath: string): Promise<boolean> {
285
+ const result = await probeSocket(sockPath);
286
+ return result !== "dead";
287
+ }
288
+
289
+ function onListening(sockPath: string): void {
290
+ try {
291
+ fs.chmodSync(sockPath, 0o600);
292
+ } catch (e) {
293
+ dlog("warn", `chmod socket failed: ${(e as Error).message}`);
294
+ }
295
+ writePidfileDiagnostic();
296
+ dlog(
297
+ "info",
298
+ `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
299
+ );
300
+ armBinaryWatch();
301
+ armLimits();
302
+ }
303
+
304
+ // --- binary-mtime self-restart ---
305
+ //
306
+ // If the daemon's compiled output changes on disk (rebuild, upgrade, edit),
307
+ // exit at the next sample so the next client respawns from the fresh code.
308
+ // Cheap (one statSync/min) and avoids the user having to manually kill the
309
+ // daemon during development. unref() so this timer doesn't hold the process alive.
310
+ function armBinaryWatch(): void {
311
+ // Watch the resolved entry point, not the bin shim — npm run build updates
312
+ // dist/index.mjs but the bin/cc-candybar shim never changes.
313
+ const entryUrl = import.meta.url;
314
+ const targets: string[] = [];
315
+ if (entryUrl.startsWith("file://")) {
316
+ targets.push(fileURLToPath(entryUrl));
317
+ }
318
+ // Also watch argv[1] as fallback (covers global installs, symlinks, etc.)
319
+ if (process.argv[1]) targets.push(process.argv[1]!);
320
+
321
+ const originalMtimes = new Map<string, number>();
322
+ for (const t of targets) {
323
+ try {
324
+ originalMtimes.set(t, fs.statSync(t).mtimeMs);
325
+ } catch {
326
+ // File may not exist yet — skip it.
327
+ }
328
+ }
329
+ if (originalMtimes.size === 0) return;
330
+
331
+ const timer = setInterval(() => {
332
+ for (const [t, originalMtime] of originalMtimes) {
333
+ try {
334
+ const nowMtime = fs.statSync(t).mtimeMs;
335
+ if (nowMtime !== originalMtime) {
336
+ dlog("info", `binary mtime changed (${t}); shutting down`);
337
+ clearInterval(timer);
338
+ shutdown(0);
339
+ return;
340
+ }
341
+ } catch (e) {
342
+ dlog("warn", `bin stat failed: ${(e as Error).message}`);
343
+ }
344
+ }
345
+ }, BIN_CHECK_INTERVAL_MS);
346
+ timer.unref();
347
+ }
348
+
349
+ // --- self-shutdown on RSS / age ---
350
+ let limits: LimitsHandle | null = null;
351
+ function armLimits(): void {
352
+ limits = makeLimits(
353
+ realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code)),
354
+ );
355
+ limits.arm();
356
+ }
357
+
358
+ // --- diagnostic pidfile ---
359
+ //
360
+ // [LAW:one-source-of-truth] The pidfile is *diagnostic only*. It records who
361
+ // the running daemon is so `daemon-stats` can report it; it plays no role in
362
+ // exclusion. Exclusion is the atomic bind() in bindOrAttachAndExit().
363
+ //
364
+ // Overwrite-on-write (no EEXIST check). If a stale pidfile exists from a
365
+ // crashed prior daemon, we replace it. The bind() above already proved no
366
+ // other daemon is alive.
367
+
368
+ function writePidfileDiagnostic(): void {
369
+ const payload = JSON.stringify({
370
+ pid: process.pid,
371
+ version: PROTOCOL_VERSION,
372
+ binPath: process.argv[1],
373
+ startedAt: new Date().toISOString(),
374
+ });
375
+ try {
376
+ fs.writeFileSync(pidPath(), payload, { mode: 0o600 });
377
+ // [LAW:single-enforcer] writeFileSync's `mode` only applies when the file
378
+ // is created. If a stale pidfile from a prior run was left with broader
379
+ // permissions, the write above won't tighten them — chmod explicitly so
380
+ // 0600 is the invariant regardless of prior state.
381
+ fs.chmodSync(pidPath(), 0o600);
382
+ } catch (e) {
383
+ // Diagnostic only — failure does not block the daemon from serving.
384
+ dlog("warn", `pidfile write failed: ${(e as Error).message}`);
385
+ }
386
+ }
387
+
388
+ function removePidfileDiagnostic(): void {
389
+ try {
390
+ fs.unlinkSync(pidPath());
391
+ } catch {}
392
+ }
393
+
394
+ let inFlight = 0;
395
+
396
+ // --- shutdown ---
397
+
398
+ let shuttingDown = false;
399
+ function shutdown(code: number): void {
400
+ if (shuttingDown) return;
401
+ shuttingDown = true;
402
+ // [LAW:single-enforcer] Arm the SIGKILL backstop FIRST, before any cleanup.
403
+ // The 452-daemon incident: shut-down daemons logged "shutting down" but
404
+ // held the bound socket FD 42 minutes later — process.exit() reached the
405
+ // call site but never completed because some active handle kept libuv's
406
+ // event loop alive past exit's teardown. The prior shape had `.unref()`
407
+ // on the SIGKILL timer, so the timer itself did NOT keep the loop alive
408
+ // — leaving the loop's only remaining live handles to win the race.
409
+ //
410
+ // What this timer guarantees: as long as the event loop can still run
411
+ // (handles that won't drop, async cleanup that schedules but never
412
+ // completes — the realistic failure modes for the incident class), the
413
+ // setTimeout callback fires within 500ms and SIGKILL terminates the
414
+ // process from outside the loop's bookkeeping. Critically the timer is
415
+ // NOT unref'd, so it is itself an active handle that keeps the loop
416
+ // alive long enough for itself to fire.
417
+ //
418
+ // What this timer cannot do: rescue a truly synchronous thread block
419
+ // (a C++ binding that never returns to JS, an infinite sync loop). No
420
+ // JS timer can fire while the main thread is blocked; only an external
421
+ // signal recovers that case. The realistic 452-corpse mode was async-
422
+ // handle retention, not a synchronous block, so the backstop is
423
+ // load-bearing for the observed failure pattern.
424
+ setTimeout(() => process.kill(process.pid, "SIGKILL"), 500);
425
+ // [LAW:single-enforcer] The atomic bind() on the unix socket path is the
426
+ // ONLY mutex preventing duplicate daemons. The previous shape unlinked
427
+ // the socket file FIRST, then spent O(100ms) closing watchers, flushing
428
+ // session state, and tearing down log streams before process.exit().
429
+ // The unlink frees the path the instant it runs; the listening FD stays
430
+ // held only until process.exit. In between, Claude Code's next render
431
+ // tick can spawn a fresh daemon that bind()s the same path and starts
432
+ // serving while we are still finishing cleanup. Under OOM cycles the
433
+ // overlap compounds — 12 daemons stacked up in the wild was the
434
+ // observed symptom. Do NOT unlink here. The kernel releases the FD on
435
+ // process.exit; the stale path that remains is recovered by the
436
+ // existing handleAddressInUse logic on the next daemon's startup
437
+ // (probe → dead → unlink + rebind, ~50ms one-shot cost).
438
+ try {
439
+ gitService.close();
440
+ } catch (e) {
441
+ dlog("warn", `gitService close failed: ${(e as Error).message}`);
442
+ }
443
+ try {
444
+ usageStore.close();
445
+ } catch (e) {
446
+ dlog("warn", `usageStore close failed: ${(e as Error).message}`);
447
+ }
448
+ try {
449
+ watcherRegistry.closeAll();
450
+ } catch (e) {
451
+ dlog("warn", `watcherRegistry close failed: ${(e as Error).message}`);
452
+ }
453
+ try {
454
+ sessionState.flush();
455
+ } catch (e) {
456
+ dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
457
+ }
458
+ removePidfileDiagnostic();
459
+ closeLog();
460
+ process.exit(code);
461
+ }
462
+
463
+ // --- per-connection handler ---
464
+
465
+ function handleConnection(sock: net.Socket): void {
466
+ inFlight++;
467
+ stats.inFlight = inFlight;
468
+ let responded = false;
469
+
470
+ // [LAW:no-ambient-temporal-coupling] respond owns the response→exit
471
+ // ordering. exitAfterFlush (an exit code; null = stay up) is performed
472
+ // by sock.end's completion callback, which Node invokes on 'finish' OR
473
+ // 'error' — a total signal. A peer that vanished mid-flush still settles,
474
+ // so the exit wish can never be stranded on a dead socket, and a live
475
+ // peer always has the frame in the kernel buffer before process.exit
476
+ // (unix-socket data survives writer exit). No fixed sleep stands between
477
+ // respond and exit; the SIGKILL backstop inside shutdown() is the
478
+ // unrelated last-resort safety.
479
+ const respond = (resp: Response, exitAfterFlush: number | null): void => {
480
+ if (responded) {
481
+ // First responder owns the flush. Reaching here with an exit wish is
482
+ // unreachable today (both exit-carrying arms resolve synchronously,
483
+ // far inside the request timeout) — but if it ever happens, say so
484
+ // instead of silently leaving a daemon up that was told to exit.
485
+ // [LAW:no-silent-failure]
486
+ if (exitAfterFlush !== null) {
487
+ dlog(
488
+ "warn",
489
+ "exit-after-flush dropped: an earlier responder settled this socket",
490
+ );
491
+ }
492
+ return;
493
+ }
494
+ responded = true;
495
+ const settle =
496
+ exitAfterFlush === null
497
+ ? undefined
498
+ : (): void => shutdown(exitAfterFlush);
499
+ try {
500
+ sock.end(encodeFrame(resp), settle);
501
+ } catch (e) {
502
+ // [LAW:no-silent-failure] The response is lost (socket already torn
503
+ // down), but the exit wish must not be.
504
+ dlog("warn", `response write failed: ${(e as Error).message}`);
505
+ settle?.();
506
+ }
507
+ };
508
+
509
+ // Per-request timeout protects the daemon from a single slow request
510
+ // (e.g. a hung git call) blocking subsequent connections. It abandons the
511
+ // RESPONSE, not the work — the handler promise keeps running.
512
+ //
513
+ // [LAW:one-source-of-truth] That is safe for the transcript-fs path because
514
+ // the work is bounded + shared, not orphaned: the today aggregate and
515
+ // per-session usage compute behind a SingleFlight (src/utils/single-flight.ts),
516
+ // so a timed-out render that abandoned its await leaves behind the ONE
517
+ // canonical in-flight scan, which the next render coalesces onto rather than
518
+ // duplicating. A timeout therefore adds zero new fs work — there is never
519
+ // more than one scan per key to orphan. Cancellation would be both messier
520
+ // and wasteful here (the in-flight scan is exactly what the next tick needs).
521
+ const timer = setTimeout(() => {
522
+ stats.requestsTimedOut++;
523
+ respond(
524
+ {
525
+ ok: false,
526
+ error: "request exceeded 200ms",
527
+ code: "TIMEOUT",
528
+ daemonV: PROTOCOL_VERSION,
529
+ },
530
+ null,
531
+ );
532
+ }, REQUEST_TIMEOUT_MS);
533
+
534
+ const reader = makeFrameReader(
535
+ (frame) => {
536
+ void handleRequest(frame as Request)
537
+ .then((r) => respond(r.resp, r.exitAfterFlush))
538
+ .catch((err) => {
539
+ dlog("error", `handler threw: ${err?.stack || err}`);
540
+ respond(
541
+ {
542
+ ok: false,
543
+ error: String(err?.message || err),
544
+ code: "RENDER_FAILED",
545
+ daemonV: PROTOCOL_VERSION,
546
+ },
547
+ null,
548
+ );
549
+ });
550
+ },
551
+ (err) => {
552
+ dlog("warn", `frame parse failed: ${err.message}`);
553
+ respond(
554
+ {
555
+ ok: false,
556
+ error: err.message,
557
+ code: "BAD_REQUEST",
558
+ daemonV: PROTOCOL_VERSION,
559
+ },
560
+ null,
561
+ );
562
+ },
563
+ );
564
+
565
+ sock.on("data", reader);
566
+ sock.on("error", (err) => {
567
+ dlog("warn", `socket error: ${err.message}`);
568
+ });
569
+ sock.on("close", () => {
570
+ clearTimeout(timer);
571
+ inFlight = Math.max(0, inFlight - 1);
572
+ stats.inFlight = inFlight;
573
+ });
574
+ }
575
+
576
+ // [LAW:no-ambient-temporal-coupling] A request whose semantics include "then
577
+ // exit" (the shutdown verb, the stale-binary version mismatch) must not exit
578
+ // until its response has flushed — but handleRequest cannot see the socket.
579
+ // So the exit is returned as DATA (the exit code; null = stay up) and the
580
+ // connection boundary, which owns the flush, sequences shutdown on the write
581
+ // completion. No timer stands between respond and exit.
582
+ // [LAW:effects-at-boundaries] handleRequest computes the description; the
583
+ // socket boundary performs it.
584
+ interface HandledRequest {
585
+ resp: Response;
586
+ exitAfterFlush: number | null;
587
+ }
588
+
589
+ const stay = (resp: Response): HandledRequest => ({
590
+ resp,
591
+ exitAfterFlush: null,
592
+ });
593
+
594
+ async function handleRequest(req: Request): Promise<HandledRequest> {
595
+ if (
596
+ !req ||
597
+ typeof req !== "object" ||
598
+ typeof (req as Request).v !== "number"
599
+ ) {
600
+ return stay({
601
+ ok: false,
602
+ error: "malformed request",
603
+ code: "BAD_REQUEST",
604
+ daemonV: PROTOCOL_VERSION,
605
+ });
606
+ }
607
+
608
+ if (req.v !== PROTOCOL_VERSION) {
609
+ // [LAW:types-are-the-program] The asymmetry is data, not control flow.
610
+ // client > daemon: the *binary* probably upgraded under us. Exit so the
611
+ // next client respawns from the current artifact.
612
+ // client < daemon: the *client* is stale. Respawning daemon does not
613
+ // help (the new daemon will have the same version). Stay up and
614
+ // return VERSION_MISMATCH — the client is responsible for surfacing
615
+ // the diagnostic and refusing to kick. Shutting down here was the
616
+ // load-bearing half of the 452-corpse spiral (kz8.5).
617
+ if (req.v > PROTOCOL_VERSION) {
618
+ dlog(
619
+ "info",
620
+ `version mismatch: client=${req.v} > daemon=${PROTOCOL_VERSION}; binary likely upgraded — exiting after the response flushes`,
621
+ );
622
+ } else {
623
+ dlog(
624
+ "info",
625
+ `version mismatch: client=${req.v} < daemon=${PROTOCOL_VERSION}; client is stale — staying up`,
626
+ );
627
+ }
628
+ return {
629
+ resp: {
630
+ ok: false,
631
+ error: `protocol v${req.v} not supported (daemon at v${PROTOCOL_VERSION})`,
632
+ code: "VERSION_MISMATCH",
633
+ daemonV: PROTOCOL_VERSION,
634
+ },
635
+ // [LAW:dataflow-not-control-flow] The asymmetry above is this value.
636
+ // Exit is sequenced on the response flush, so the client always sees
637
+ // the VERSION_MISMATCH diagnostic — never a dead socket.
638
+ exitAfterFlush: req.v > PROTOCOL_VERSION ? 0 : null,
639
+ };
640
+ }
641
+
642
+ if (req.kind === "shutdown") {
643
+ return { resp: { ok: true, output: "" }, exitAfterFlush: 0 };
644
+ }
645
+
646
+ if (req.kind === "stats") {
647
+ // [LAW:single-enforcer] Stats requests do NOT bump request counters —
648
+ // observability shouldn't pollute the metric being observed.
649
+ return stay({
650
+ ok: true,
651
+ stats: stats.snapshot({
652
+ gitCache: gitService.getStats(),
653
+ usageCache: usageStore.getStats(),
654
+ renderCacheSize: renderCache.size,
655
+ watchersActive: watcherRegistry.size(),
656
+ nextRestartReason: limits?.describeNextRestart() ?? null,
657
+ }),
658
+ });
659
+ }
660
+
661
+ if (req.kind === "render") {
662
+ stats.requestsTotal++;
663
+ const t0 = Date.now();
664
+ try {
665
+ // [LAW:single-enforcer] One trust-boundary check for incoming hookData.
666
+ // The validator reports missing/wrong-typed required fields and unknown
667
+ // top-level keys. Required-field problems are *protocol* failures
668
+ // (Claude Code's schema guarantees these — their absence means the
669
+ // sender is broken or malicious); unknown fields are advisory (Anthropic
670
+ // may have added something).
671
+ const { report } = validateHookData(req.hookData as unknown);
672
+ for (const field of report.unknownTopLevelFields) {
673
+ dlog(
674
+ "info",
675
+ `schema: unknown field '${field}' — Anthropic may have added it`,
676
+ );
677
+ }
678
+ // [LAW:no-silent-fallbacks][LAW:types-are-the-program] Gate hard on
679
+ // schema violations. Continuing with `workspace?.project_dir` would
680
+ // collapse "absent" into an empty-string cache key — silently sharing
681
+ // one entry across every malformed request — and downstream code would
682
+ // have to defend against an empty projectDir forever. Reject here so
683
+ // the types downstream carry the strongest true theorem: by the time
684
+ // a cache entry is built, projectDir/cwd are real non-empty strings.
685
+ const wireProblems: string[] = [];
686
+ for (const path of report.missingRequired) {
687
+ wireProblems.push(`missing required field '${path}'`);
688
+ }
689
+ for (const { path, expected, got } of report.typeMismatches) {
690
+ wireProblems.push(`field '${path}' expected ${expected}, got ${got}`);
691
+ }
692
+ if (req.cwd === "") {
693
+ wireProblems.push("request 'cwd' is empty");
694
+ }
695
+ if (wireProblems.length > 0) {
696
+ stats.requestsErrored++;
697
+ dlog("warn", `BAD_REQUEST: ${wireProblems.join("; ")}`);
698
+ return stay({
699
+ ok: false,
700
+ error: `malformed hookData: ${wireProblems.join("; ")}`,
701
+ code: "BAD_REQUEST",
702
+ daemonV: PROTOCOL_VERSION,
703
+ });
704
+ }
705
+ const projectDir = req.hookData.workspace.project_dir;
706
+ // [LAW:dataflow-not-control-flow] thread the *request's* cwd, not the
707
+ // daemon's process.cwd(), so config resolution depends only on request
708
+ // data — the daemon's own working directory must not influence output.
709
+ const { configFile, unknownFlagsError } = parseRenderArgs(req.args);
710
+ // [LAW:effects-at-boundaries] The load-config verb writes per-session
711
+ // config overrides into SessionState; this is the one read point.
712
+ const sessionId = req.hookData.session_id;
713
+ const sessionConfigFile =
714
+ sessionState.get(sessionId, SESSION_CONFIG_OVERRIDE_KEY) ?? configFile;
715
+ const entry = renderCache.getOrCreate(
716
+ projectDir,
717
+ req.cwd,
718
+ sessionConfigFile,
719
+ );
720
+ // [LAW:single-enforcer] Width capture lives at the wire boundary.
721
+ // The client (Rust + TTY) is the only process that can see the real
722
+ // terminal; the daemon is detached. We do NOT consult getTerminalWidth's
723
+ // env/stderr fallbacks here — they would let the daemon's stale
724
+ // launch-time COLUMNS env shape rendering for a different terminal,
725
+ // which is exactly the wrong source.
726
+ // [LAW:one-source-of-truth] Both branches feed raw cols through
727
+ // applyClaudeCodeReserve, so `width` always means "usable cells
728
+ // post-reserve" with no semantic split between wire-supplied and
729
+ // fallback values.
730
+ const termCols = sanitizeTermCols(req.termCols);
731
+ const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
732
+ const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
733
+ // [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
734
+ // body = state ? renderDsl(state) : "" ; output = body + icon
735
+ // No special-case branches — same composition every render.
736
+ let body = "";
737
+ if (entry.state !== null) {
738
+ const payload = await buildRenderPayload(
739
+ req.hookData,
740
+ payloadDeps,
741
+ req.cwd,
742
+ entry.state.neededInputPaths,
743
+ );
744
+ // [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
745
+ // is derived per render from the effective theme — the session's chosen
746
+ // theme (SessionState) over the config default — so a theme click
747
+ // recolors the whole bar on the next render. Not frozen on the cache
748
+ // entry (one entry serves many sessions). resolverForThemeName memoizes,
749
+ // so the per-render cost is one Map lookup once the theme is warm.
750
+ const basePalette = resolverForThemeName(
751
+ effectiveThemeName(
752
+ sessionState.get(req.hookData.session_id, "theme"),
753
+ entry.state.config.globals.palette,
754
+ ),
755
+ );
756
+ // [LAW:single-enforcer] renderDsl internally calls
757
+ // `registry.applyInput(payload)` as its first step (see step 1 in
758
+ // src/dsl/render.ts). The daemon must not pre-apply — doing so
759
+ // would run the MobX action twice per render and clear last_error
760
+ // diagnostics on the round trip.
761
+ body = renderDsl(
762
+ entry.state.config,
763
+ entry.state.compiled,
764
+ entry.state.store,
765
+ entry.state.registry,
766
+ payload,
767
+ basePalette,
768
+ renderOpts,
769
+ // [LAW:single-enforcer] The per-segment StripCell sink for the
770
+ // `debug segments` projection. Its identity stays stable for the
771
+ // cache entry's lifetime; renderDsl clears + repopulates it
772
+ // in place. Cells are cheap (already computed during the render);
773
+ // the per-segment ANSI serialization happens lazily inside the
774
+ // debug handler so normal renders pay no extra serializer cost.
775
+ entry.state.lastRenderCellsBySegment,
776
+ );
777
+ }
778
+ // [LAW:one-source-of-truth] Consume the transient click error written by
779
+ // dispatch on partial/total effect failure, then clear it so it shows
780
+ // exactly once. Only called when non-null to avoid a no-op persist+MobX
781
+ // tick on every render.
782
+ const clickError = sessionState.get(
783
+ req.hookData.session_id,
784
+ "click.error",
785
+ );
786
+ if (clickError)
787
+ sessionState.clear(req.hookData.session_id, "click.error");
788
+ const combinedError =
789
+ [unknownFlagsError, entry.lastError, clickError]
790
+ .filter(Boolean)
791
+ .join("\n") || null;
792
+ const output = composeWithDiagnostics(
793
+ body,
794
+ combinedError,
795
+ entry.lastWarning,
796
+ );
797
+ const ms = Date.now() - t0;
798
+ const g = gitService.getStats();
799
+ const u = usageStore.getStats();
800
+ dlog(
801
+ "info",
802
+ `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"}`,
803
+ );
804
+ return stay({ ok: true, output: output + "\n" });
805
+ } catch (e) {
806
+ stats.requestsErrored++;
807
+ throw e;
808
+ }
809
+ }
810
+
811
+ if (req.kind === "click") {
812
+ return stay(await handleClick(req.verb, req.value));
813
+ }
814
+
815
+ if (req.kind === "debug") {
816
+ // [LAW:single-enforcer] One trust-boundary check at the wire edge —
817
+ // `what` is untrusted JSON. isDebugWhat narrows it to the discriminated
818
+ // union the introspector consumes; an invalid value short-circuits
819
+ // here, not deep inside buildDebugSnapshot.
820
+ if (!isDebugWhat(req.what)) {
821
+ return stay({
822
+ ok: false,
823
+ // [LAW:errors-context-in-errors] Include the allowed values so a
824
+ // CLI consumer (or operator) sees what is supported without
825
+ // grep — same pattern as the set-state verb's unknown-key error
826
+ // in src/daemon/verbs/state-validators.ts.
827
+ error: `unknown debug 'what': ${String(req.what)} (have: ${DEBUG_WHATS.join(", ")})`,
828
+ code: "BAD_REQUEST",
829
+ daemonV: PROTOCOL_VERSION,
830
+ });
831
+ }
832
+ // [LAW:dataflow-not-control-flow] The debug projection samples whatever
833
+ // DSL state the cache currently holds. With cache keys scoped on
834
+ // (projectDir, cwd) and the debug request carrying neither, we sample
835
+ // the first populated existing entry — sufficient for `debug vars`,
836
+ // `debug segments`, `debug config` against the active workload.
837
+ // firstPopulatedState iterates existing entries only; it does NOT
838
+ // create a fresh one, so debug introspection never has the side effect
839
+ // of standing up a new (projectDir=undefined) cache entry tied to the
840
+ // daemon's own process.cwd(). A future debug-target selector would
841
+ // thread (projectDir, cwd) through the wire.
842
+ const dbgEntry = renderCache.firstPopulatedState();
843
+ // [LAW:dataflow-not-control-flow] Lazy per-segment serialization: the
844
+ // cache stores StripCell arrays (cheap, written by renderDsl).
845
+ // The debug projection needs strings, so serialize only for the
846
+ // `segments` projection (`vars` and `config` don't need it) and only
847
+ // when this request actually fires. Normal renders pay no per-segment
848
+ // serializer cost — that work shifts to debug-request time, which is
849
+ // operator-driven and rare.
850
+ const dbgState =
851
+ dbgEntry === null
852
+ ? null
853
+ : {
854
+ store: dbgEntry.store,
855
+ registry: dbgEntry.registry,
856
+ config: dbgEntry.config,
857
+ compiled: dbgEntry.compiled,
858
+ lastRenderBySegment:
859
+ req.what === "segments"
860
+ ? serializeSegmentCells(dbgEntry.lastRenderCellsBySegment)
861
+ : EMPTY_RENDER_MAP,
862
+ };
863
+ return stay({ ok: true, debug: buildDebugSnapshot(req.what, dbgState) });
864
+ }
865
+
866
+ return stay({
867
+ ok: false,
868
+ error: "unknown kind",
869
+ code: "BAD_REQUEST",
870
+ daemonV: PROTOCOL_VERSION,
871
+ });
872
+ }
873
+
874
+ // --- diagnostics composition ---
875
+ //
876
+ // [LAW:no-silent-fallbacks] Bad config can't quietly degrade output. The
877
+ // render pipeline carries two independent diagnostic channels:
878
+ // error — load-fatal: parse/validation failed; bar is last-known-good
879
+ // or empty. Rendered red.
880
+ // warning — advisory: load succeeded but something needs attention (e.g.
881
+ // same-location .json5 + .json collision). Rendered amber.
882
+ // Either way the failure is visible at the point of impact, and each
883
+ // channel has its own click verb (show-config-error / show-config-warning)
884
+ // so the operator can copy the message to clipboard for inspection.
885
+ //
886
+ // [LAW:one-type-per-behavior] Two severities → two channels. The
887
+ // composer's signature carries both; severity is encoded in WHICH
888
+ // argument is non-null, not in a string prefix or a tag inside the
889
+ // message. The two icons render independently — both can show at once.
890
+ //
891
+ // [LAW:types-are-the-program] The diagnostic's visible text IS (a
892
+ // projection of) the underlying message — not a constant label that hides
893
+ // the content behind a click. The leading ⚠ + background color carry
894
+ // severity; the rest of the cell is the actual error/warning, sanitized
895
+ // and clipped to a single-line budget. A label divorced from the message
896
+ // would be the type lying about what's in the channel.
897
+ // [LAW:one-source-of-truth] Style constants come from the shared leaf
898
+ // (src/render/diagnostic-style.ts) — the same visual identity the client's
899
+ // permanent glyph uses. Only the OSC-8 link plumbing is local here.
900
+ const OSC8_OPEN = "\x1b]8;;";
901
+ const OSC8_CLOSE = "\x1b]8;;\x1b\\";
902
+ const ST = "\x1b\\";
903
+
904
+ // [LAW:single-enforcer][LAW:no-silent-fallbacks] Parse render-path args with
905
+ // the standard util at the trust boundary. `--config <path>` is the sole
906
+ // valid render flag; every other flag is surfaced as a render-time
907
+ // diagnostic icon (caller composes it alongside config errors). The
908
+ // `--config` value is `~`-expanded here, so every consumer downstream
909
+ // receives a literal path — no caller has to remember to expand it.
910
+ //
911
+ // `tokens: true, strict: false, allowPositionals: true` together let the
912
+ // parser emit a token entry for every flag (known or unknown) without
913
+ // throwing on unknown ones, and without mis-classifying their values as
914
+ // positionals.
915
+ function parseRenderArgs(args: string[]): {
916
+ configFile: string | undefined;
917
+ unknownFlagsError: string | null;
918
+ } {
919
+ const { values, tokens } = parseArgs({
920
+ args: args.slice(1), // skip binary path
921
+ options: { config: { type: "string" } },
922
+ strict: false,
923
+ tokens: true,
924
+ allowPositionals: true,
925
+ });
926
+ const unknown = [
927
+ ...new Set(
928
+ (tokens ?? [])
929
+ .filter(
930
+ (t): t is Extract<typeof t, { kind: "option" }> =>
931
+ t.kind === "option" && t.name !== "config",
932
+ )
933
+ .map((t) => `--${t.name}`),
934
+ ),
935
+ ];
936
+ const rawConfig = values.config as string | undefined;
937
+ return {
938
+ configFile: rawConfig === undefined ? undefined : expandHome(rawConfig),
939
+ unknownFlagsError:
940
+ unknown.length > 0 ? `Unknown flags: ${unknown.join(", ")}` : null,
941
+ };
942
+ }
943
+
944
+ // Per-line visible budget and max rows for multi-line diagnostic blocks.
945
+ // Messages from the config validator (formatIssues) are already structured
946
+ // as one line per issue, so splitting there is the natural unit of display.
947
+ // Deliberately decoupled from DEFAULT_TERMINAL_WIDTH: that constant means
948
+ // "raw terminal cols we assume" and is reserved-against before reaching the
949
+ // renderer; this one is a direct visible-char cap on already-rendered
950
+ // diagnostic text. They happen to share the value 120 today but have
951
+ // different semantic intents.
952
+ const MAX_DIAGNOSTIC_LINE_LEN = 120;
953
+ const MAX_DIAGNOSTIC_LINES = 8;
954
+
955
+ function makeDiagnosticLink(
956
+ verb: typeof VERB_SHOW_CONFIG_ERROR | typeof VERB_SHOW_CONFIG_WARNING,
957
+ message: string,
958
+ bg: string,
959
+ fg: string,
960
+ ): string {
961
+ // Full message in the OSC-8 URL (clipboard-copy on click) — truncation
962
+ // only affects what is visible, never what is accessible. [LAW:single-enforcer]
963
+ // The click URL is born through effectsUrl like every other click — one
964
+ // single-effect dispatch list, no second URL-format in the codebase.
965
+ const url = effectsUrl([{ verb, args: [message] }]);
966
+ // [LAW:dataflow-not-control-flow] Split on natural line boundaries from
967
+ // the source message (config validator emits one issue per line), sanitize
968
+ // each line individually, then render each as a separate styled row.
969
+ // This preserves structured multi-line output instead of collapsing N
970
+ // issues into a single truncated string the user cannot read.
971
+ const lines = message
972
+ .split(/\r\n|\r|\n/)
973
+ .map((l) => sanitizeAndTruncate(l, MAX_DIAGNOSTIC_LINE_LEN))
974
+ .filter(Boolean)
975
+ .slice(0, MAX_DIAGNOSTIC_LINES);
976
+ if (lines.length === 0) return "";
977
+ const first = `${OSC8_OPEN}${url}${ST}${bg}${fg} ⚠ ${lines[0]} ${ANSI_RESET}${OSC8_CLOSE}`;
978
+ const rest = lines
979
+ .slice(1)
980
+ .map(
981
+ (l) =>
982
+ `${OSC8_OPEN}${url}${ST}${bg}${fg} ${l} ${ANSI_RESET}${OSC8_CLOSE}`,
983
+ );
984
+ return [first, ...rest].join("\n");
985
+ }
986
+
987
+ function composeWithDiagnostics(
988
+ body: string,
989
+ error: string | null,
990
+ warning: string | null,
991
+ ): string {
992
+ // [LAW:dataflow-not-control-flow] Diagnostics list is data; the
993
+ // composer walks it. Each non-null channel contributes one or more prefix
994
+ // rows (makeDiagnosticLink returns a \n-joined multi-line block when the
995
+ // message has natural line breaks). Order is error-first (more severe),
996
+ // then warning, then body.
997
+ const prefixes: string[] = [];
998
+ if (error) {
999
+ prefixes.push(
1000
+ makeDiagnosticLink(
1001
+ VERB_SHOW_CONFIG_ERROR,
1002
+ error,
1003
+ DIAGNOSTIC_ERROR_BG,
1004
+ DIAGNOSTIC_ERROR_FG,
1005
+ ),
1006
+ );
1007
+ }
1008
+ if (warning) {
1009
+ prefixes.push(
1010
+ makeDiagnosticLink(
1011
+ VERB_SHOW_CONFIG_WARNING,
1012
+ warning,
1013
+ DIAGNOSTIC_WARNING_BG,
1014
+ DIAGNOSTIC_WARNING_FG,
1015
+ ),
1016
+ );
1017
+ }
1018
+ if (prefixes.length === 0) return body;
1019
+ // No body → emit the diagnostic strip alone (startup-error case). Body
1020
+ // present → prepend on its own line so it's visible regardless of bar
1021
+ // width. Multiple diagnostics stack on their own lines.
1022
+ const strip = prefixes.join("\n");
1023
+ return body ? `${strip}\n${body}` : strip;
1024
+ }
1025
+
1026
+ // --- click verb dispatch ---
1027
+ // [LAW:dataflow-not-control-flow] The dispatcher is a table lookup. The verb
1028
+ // table (src/daemon/verbs/index.ts) is the single canonical list of supported
1029
+ // verbs — handlers live there, the dispatcher only routes.
1030
+ //
1031
+ // [LAW:types-are-the-program] The error class on the throw determines the
1032
+ // response code: BadVerbArgs (invalid input shape) becomes BAD_REQUEST; any
1033
+ // other Error (operational failure) becomes RENDER_FAILED. No string matching.
1034
+
1035
+ const verbCtx = { sessionState, dlog };
1036
+
1037
+ // [LAW:single-enforcer] Style + color compatibility shared by the render
1038
+ // path and the lazy debug-side per-segment serializer. Per-request `width`
1039
+ // is composed on top at the wire boundary (handleRequest("render")) and
1040
+ // passed through as renderOpts. Debug serialization composes its own
1041
+ // per-segment opts with width: Number.POSITIVE_INFINITY since each segment
1042
+ // is rendered standalone (wrap doesn't apply to a one-segment projection).
1043
+ const RENDER_OPTS_BASE = {
1044
+ style: "powerline" as const,
1045
+ colorCompatibility: "truecolor" as const,
1046
+ };
1047
+ const DEBUG_RENDER_OPTS: BuildLineOptions = {
1048
+ ...RENDER_OPTS_BASE,
1049
+ width: Number.POSITIVE_INFINITY,
1050
+ };
1051
+
1052
+ // [LAW:no-defensive-null-guards] Reused empty map for the `vars` /
1053
+ // `config` debug projections — they don't read lastRenderBySegment but
1054
+ // the DaemonDslState type requires the field.
1055
+ const EMPTY_RENDER_MAP = new Map<string, string>();
1056
+
1057
+ function serializeSegmentCells(
1058
+ cells: ReadonlyMap<string, readonly RichText[]>,
1059
+ ): Map<string, string> {
1060
+ const out = new Map<string, string>();
1061
+ for (const [name, segCells] of cells) {
1062
+ out.set(name, renderStripCells(segCells, DEBUG_RENDER_OPTS));
1063
+ }
1064
+ return out;
1065
+ }
1066
+
1067
+ // [LAW:single-enforcer] The payload-builder dependency bundle. One value
1068
+ // passed through every render — the data the daemon brings to each tick.
1069
+ const payloadDeps = {
1070
+ gitProvider: gitService,
1071
+ usageStore,
1072
+ contextProvider,
1073
+ metricsProvider,
1074
+ tmuxService,
1075
+ sessionState,
1076
+ // [LAW:single-enforcer] buildRenderPayload is the one log site for the
1077
+ // outcome-carrying provider lanes (git, cache).
1078
+ log: dlog,
1079
+ };
1080
+
1081
+ function handleClick(verb: string, value: string): Response {
1082
+ const handler = VERBS.get(verb);
1083
+ if (!handler) {
1084
+ return {
1085
+ ok: false,
1086
+ error: `unknown click verb: ${verb}`,
1087
+ code: "BAD_REQUEST",
1088
+ daemonV: PROTOCOL_VERSION,
1089
+ };
1090
+ }
1091
+ try {
1092
+ handler(value, verbCtx);
1093
+ return { ok: true, output: "" };
1094
+ } catch (e) {
1095
+ const code = e instanceof BadVerbArgs ? "BAD_REQUEST" : "RENDER_FAILED";
1096
+ return {
1097
+ ok: false,
1098
+ error: String(e instanceof Error ? e.message : e),
1099
+ code,
1100
+ daemonV: PROTOCOL_VERSION,
1101
+ };
1102
+ }
1103
+ }