@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,684 +0,0 @@
1
- import fs from "node:fs";
2
- import net from "node:net";
3
- import path from "node:path";
4
- import { launchDetachedSync } from "../proc/launch";
5
- import { heapCapMb } from "./limits";
6
- import process from "node:process";
7
- import {
8
- socketPath,
9
- spawnLockPath,
10
- spawnCooldownPath,
11
- spawnBackoffPath,
12
- daemonDir,
13
- } from "./paths";
14
-
15
- // [LAW:single-enforcer] One primitive per runtime that owns the entire
16
- // "obtain a daemon" verb. Every spawn site in the Node runtime now flows
17
- // through this one path; the Rust client mirrors it in its own
18
- // obtain-daemon primitive. Both runtimes agree on socketPath() and
19
- // spawnLockPath() *and* on the lock mechanism: open(path, O_CREAT | O_EXCL)
20
- // — existence == held; release by unlinking. A Rust kick and a Node kick
21
- // are mutually recognizable.
22
- //
23
- // [LAW:dataflow-not-control-flow] Callers do not get to choose whether to
24
- // spawn. They request a daemon; this function returns one of three typed
25
- // outcomes. The decision lives in the data (connect result, lock acquisition,
26
- // re-check after lock).
27
-
28
- export type ObtainResult =
29
- | { kind: "attached" }
30
- | { kind: "started" }
31
- | { kind: "failed"; reason: string };
32
-
33
- interface ObtainOpts {
34
- // Total deadline for the entire obtain operation. After this elapses we
35
- // return { kind: "failed" } even if a daemon could come up shortly. Default
36
- // 2000ms — generous for cold start, tight enough to bound user-visible
37
- // latency on a busted state directory.
38
- totalTimeoutMs?: number;
39
- // Per-connect probe timeout. AF_UNIX connect to a live listener is sub-ms;
40
- // anything slower implies "no listener" in practice.
41
- connectTimeoutMs?: number;
42
- // How long to wait after spawning before giving up on the daemon coming up.
43
- spawnReadyTimeoutMs?: number;
44
- // After this much continuous contention on spawn.lock without a daemon
45
- // appearing, give up on the lock and spawn anyway. The bind() inside the
46
- // daemon arbitrates duplicates, so a stuck lock degrades to bind-arbitrated
47
- // contention instead of a multi-second availability gap. Default
48
- // totalTimeoutMs / 2.
49
- lockFallbackMs?: number;
50
- // Test hook: replace the actual spawn call. Returning false simulates
51
- // "spawn failed"; default spawns the real daemon.
52
- spawn?: () => boolean;
53
- }
54
-
55
- const DEFAULT_OPTS: Required<Omit<ObtainOpts, "spawn" | "lockFallbackMs">> = {
56
- totalTimeoutMs: 2000,
57
- connectTimeoutMs: 50,
58
- spawnReadyTimeoutMs: 1500,
59
- };
60
-
61
- export async function obtainDaemon(
62
- opts: ObtainOpts = {},
63
- ): Promise<ObtainResult> {
64
- const settings = { ...DEFAULT_OPTS, ...opts };
65
- const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
66
- const deadline = Date.now() + settings.totalTimeoutMs;
67
- const lockFallbackMs =
68
- opts.lockFallbackMs ?? Math.floor(settings.totalTimeoutMs / 2);
69
-
70
- // [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>
71
- // — synchronous filesystem failures (read-only FS, permission denial) must
72
- // become typed failure outcomes, not throws that surprise the caller.
73
- const setupErr = ensureStateDir();
74
- if (setupErr) return { kind: "failed", reason: setupErr };
75
-
76
- // Fast path: is a daemon already listening?
77
- if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
78
- return { kind: "attached" };
79
- }
80
-
81
- // No daemon yet. Try to win the spawn-lock so we are the one to bring it up.
82
- const contentionStart = Date.now();
83
- while (Date.now() < deadline) {
84
- const lock = tryAcquireSpawnLock();
85
- if (lock.kind === "error") {
86
- // [LAW:no-silent-fallbacks] Unrecoverable errors (EACCES, ENOTDIR,
87
- // broken state dir) must not silently degrade into a contention loop
88
- // + timeout. The caller gets an actionable reason.
89
- return { kind: "failed", reason: `spawn-lock: ${lock.reason}` };
90
- }
91
- if (lock.kind === "held") {
92
- try {
93
- // Re-check: another caller may have spawned a daemon between our
94
- // initial connect and our lock acquisition.
95
- if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
96
- return { kind: "attached" };
97
- }
98
- return await spawnAndWaitForReady(
99
- spawnFn,
100
- settings.spawnReadyTimeoutMs,
101
- settings.connectTimeoutMs,
102
- deadline,
103
- "",
104
- );
105
- } finally {
106
- releaseSpawnLock();
107
- }
108
- }
109
- // Lock contended. Another caller is in the spawn window — brief wait,
110
- // then re-check for the socket they're bringing up.
111
- await sleep(20);
112
- if (await canConnect(socketPath(), settings.connectTimeoutMs)) {
113
- return { kind: "attached" };
114
- }
115
- // [LAW:dataflow-not-control-flow] spawn.lock is an optimization; bind()
116
- // is the load-bearing exclusion. If we've been contended past the
117
- // fallback threshold (e.g. crashed lock holder, slow staleness reclaim),
118
- // bypass the lock and let bind() inside the daemon arbitrate duplicates.
119
- // Same shape as a fresh spawn — caller pays one extra Node startup cost
120
- // in the worst case, which is the right trade for availability.
121
- if (Date.now() - contentionStart > lockFallbackMs) {
122
- return await spawnAndWaitForReady(
123
- spawnFn,
124
- settings.spawnReadyTimeoutMs,
125
- settings.connectTimeoutMs,
126
- deadline,
127
- " (lock-fallback)",
128
- );
129
- }
130
- }
131
-
132
- return { kind: "failed", reason: "timeout obtaining daemon" };
133
- }
134
-
135
- // Spawn the daemon, then poll for it to bind. Returns the typed outcome.
136
- // Shared by the lock-held path and the lock-fallback path so they don't drift.
137
- async function spawnAndWaitForReady(
138
- spawnFn: () => boolean,
139
- spawnReadyTimeoutMs: number,
140
- connectTimeoutMs: number,
141
- outerDeadline: number,
142
- reasonSuffix: string,
143
- ): Promise<ObtainResult> {
144
- const readyDeadline = Math.min(
145
- Date.now() + spawnReadyTimeoutMs,
146
- outerDeadline,
147
- );
148
-
149
- // [LAW:dataflow-not-control-flow] The spawn-rate bound is consulted as data,
150
- // not a mode. If a spawn was attempted within SPAWN_COOLDOWN_MS, one is
151
- // already in flight — do NOT add another Node process; wait for the in-flight
152
- // boot. This keeps obtainDaemon under the same global rate cap as the kick
153
- // path, so the rate bound holds for EVERY spawn site [LAW:one-source-of-truth].
154
- if (!claimSpawnCooldown()) {
155
- // [LAW:no-silent-failure] This failure's cause is the cooldown gate, not the
156
- // lock path we arrived through — so it does NOT inherit reasonSuffix (which
157
- // tags lock-held vs lock-fallback *spawn* provenance). We never spawned here.
158
- return (await pollUntilReady(connectTimeoutMs, readyDeadline))
159
- ? { kind: "attached" }
160
- : {
161
- kind: "failed",
162
- reason: "spawn on cooldown; no daemon became ready during the wait",
163
- };
164
- }
165
-
166
- // [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
167
- // A synchronous throw from child_process.spawn (ENOENT, invalid options)
168
- // must become a typed failure, not a rejected promise.
169
- let didSpawn = false;
170
- try {
171
- didSpawn = spawnFn();
172
- } catch (e) {
173
- return {
174
- kind: "failed",
175
- reason: `spawn threw${reasonSuffix}: ${(e as Error).message}`,
176
- };
177
- }
178
- if (!didSpawn) {
179
- return {
180
- kind: "failed",
181
- reason: `spawn returned false${reasonSuffix}`,
182
- };
183
- }
184
- return (await pollUntilReady(connectTimeoutMs, readyDeadline))
185
- ? { kind: "started" }
186
- : {
187
- kind: "failed",
188
- reason: `daemon did not bind in time${reasonSuffix}`,
189
- };
190
- }
191
-
192
- // Poll the socket until a daemon answers or the deadline elapses. Shared by the
193
- // spawn path (→ "started") and the cooldown-blocked wait path (→ "attached"):
194
- // both need "did a daemon come up in time", they differ only in how they label
195
- // the outcome.
196
- async function pollUntilReady(
197
- connectTimeoutMs: number,
198
- readyDeadline: number,
199
- ): Promise<boolean> {
200
- while (Date.now() < readyDeadline) {
201
- if (await canConnect(socketPath(), connectTimeoutMs)) return true;
202
- await sleep(20);
203
- }
204
- return false;
205
- }
206
-
207
- // Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
208
- // the current render is already lost and we just want to warm the daemon for
209
- // the next refresh. Mirrors the Rust client's obtain_daemon_kick at the same
210
- // shape: lock + spawn + release, all synchronous, no await.
211
- //
212
- // [LAW:one-type-per-behavior] This is the Node mirror of Rust's
213
- // obtain_daemon_kick in rust-client/src/main.rs. Both runtimes use the same
214
- // existence-as-lock semantics so a Rust kick and a Node kick are mutually
215
- // recognizable. The bind() inside the daemon arbitrates any duplicate spawns
216
- // that slip past the lock.
217
- //
218
- // Why synchronous: callers (src/index.ts, src/install/index.ts) call this
219
- // immediately before process.exit(). An async variant would suspend on the
220
- // first await and never resume — process.exit would kill the process before
221
- // child_process.spawn ever runs. The lock+spawn must complete in synchronous
222
- // turn for the daemon to actually start.
223
- // If the spawn.lock has existed longer than this, the kick path assumes the
224
- // holder crashed mid-spawn and overrides to preserve availability.
225
- // Calibrated to be much larger than any legitimate hold:
226
- // - A healthy kick holds for <10ms (fork + release).
227
- // - obtainDaemon's lock-held path can hold up to spawnReadyTimeoutMs
228
- // (1500ms default) while polling for the new daemon to bind.
229
- // 2s leaves a comfortable margin above the slowest legitimate holder while
230
- // still recovering from a crashed-mid-spawn holder well before STALE_LOCK_MS.
231
- const KICK_CONTENDED_OVERRIDE_MS = 2_000;
232
-
233
- export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
234
- if (ensureStateDir() !== null) return;
235
- const spawnFn = opts.spawn ?? spawnDaemonDetachedReal;
236
- const lock = tryAcquireSpawnLock();
237
-
238
- // [LAW:dataflow-not-control-flow] Lock outcome is data, not control flow.
239
- // - "contended": typically means another caller is in the spawn window;
240
- // trust them and return. BUT: if the lock has been held suspiciously
241
- // long, the holder is likely crashed mid-spawn — override and spawn
242
- // unlocked. bind() arbitrates any duplicates.
243
- // - "error": spawn-lock unavailable (broken state dir, perms). Per the
244
- // architecture, spawn.lock is an *optimization* on top of bind()'s
245
- // load-bearing exclusion — a lock error should NOT make this kick a
246
- // hard stop on availability. Fall through to an unlocked spawn.
247
- // - "held": normal path — spawn under the lock.
248
- if (lock.kind === "contended") {
249
- const ageMs = spawnLockAgeMs();
250
- if (ageMs !== null && ageMs > KICK_CONTENDED_OVERRIDE_MS) {
251
- process.stderr.write(
252
- `cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
253
- );
254
- cooldownGatedSpawn(spawnFn);
255
- }
256
- return;
257
- }
258
- if (lock.kind === "error") {
259
- process.stderr.write(
260
- `cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
261
- );
262
- cooldownGatedSpawn(spawnFn);
263
- return;
264
- }
265
- try {
266
- cooldownGatedSpawn(spawnFn);
267
- } finally {
268
- releaseSpawnLock();
269
- }
270
- }
271
-
272
- // [LAW:single-enforcer] Every kick spawn site routes through here, so the
273
- // spawn-rate bound is applied at exactly one boundary — mirror of Rust's
274
- // spawn_daemon_rate_limited. On cooldown we do nothing: a spawn was attempted
275
- // within SPAWN_COOLDOWN_MS and is likely still booting; the kick is
276
- // fire-and-forget, so "already in flight" is a complete answer.
277
- function cooldownGatedSpawn(spawnFn: () => boolean): void {
278
- if (!claimSpawnCooldown()) return;
279
- safeSpawn(spawnFn);
280
- }
281
-
282
- function spawnLockAgeMs(): number | null {
283
- try {
284
- const st = fs.statSync(spawnLockPath());
285
- return Date.now() - st.mtimeMs;
286
- } catch {
287
- return null;
288
- }
289
- }
290
-
291
- // [LAW:no-defensive-null-guards] Kick path is fire-and-forget; a spawn
292
- // failure here is best-effort. Swallowing prevents an uncaught throw from
293
- // crashing the calling process at the wrong moment (right before its own
294
- // exit). Both failure modes (throw and false-return) are logged via stderr
295
- // so kick failures stay visible — silent failure is the worst outcome.
296
- function safeSpawn(spawnFn: () => boolean): void {
297
- try {
298
- if (!spawnFn()) {
299
- process.stderr.write(
300
- "cc-candybar: daemon spawn returned false (unable to resolve script path?)\n",
301
- );
302
- }
303
- } catch (e) {
304
- process.stderr.write(
305
- `cc-candybar: daemon spawn failed: ${(e as Error).message}\n`,
306
- );
307
- }
308
- }
309
-
310
- // ─── Spawn cooldown (shared spawn-RATE bound) ────────────────────────────────
311
- //
312
- // [LAW:one-source-of-truth] spawn.lock dedups spawns at one INSTANT; the
313
- // cooldown bounds them over TIME. Without it, the Rust kick — which releases
314
- // spawn.lock milliseconds after forking, before the 0.5-3s Node boot window —
315
- // re-spawns on every render tick during an outage (spawn rate ≈ tick rate:
316
- // dozens/sec, process-table exhaustion). One file's mtime records the last spawn
317
- // ATTEMPT; both runtimes consult it. The constant and filename are mirrored TS↔
318
- // Rust (scripts/check-protocol.mjs). Worst case with the bound: ~20 spawns/min
319
- // globally, each of which exits cleanly via the sibling socket-lease defenses.
320
- // Exported for the boundary unit tests (test/daemon-acquire.test.ts), which pin
321
- // the window arithmetic against the exact constant the same way the Rust unit
322
- // tests do — so a TS↔Rust decision divergence at a boundary is caught even
323
- // though check-protocol only diffs the constant's value.
324
- export const SPAWN_COOLDOWN_MS = 3_000;
325
-
326
- // [LAW:effects-at-boundaries] The window arithmetic — the subtle part: a
327
- // future-mtime garbage record (beyond the stale-lock window) must not pin the
328
- // cooldown forever, while a small negative age is just ms-truncation of
329
- // Date.now() against the higher-precision fs mtime and still counts as a
330
- // just-recorded attempt — is a pure function of the record's age, extracted from
331
- // the fs read so it is unit-tested without touching the filesystem. Mirrors the
332
- // Rust cooldown_decision. `null` age (missing/unreadable file) allows (first
333
- // spawn); the mtime IS the timestamp, so "unparseable timestamp" is
334
- // unrepresentable by construction.
335
- export type CooldownDecision =
336
- | { kind: "allow" }
337
- | { kind: "allow-future-garbage"; futureMs: number }
338
- | { kind: "deny" };
339
-
340
- // [LAW:types-are-the-program] `cooldownMs` is the required window, not a
341
- // captured constant — the decision is the same pure fold whether the caller
342
- // is checking against the base SPAWN_COOLDOWN_MS or a backed-off window from
343
- // effectiveCooldownMs(streak) below. Generalizing the threshold into a
344
- // parameter is what let brandon-daemon-lifecycle-gad.3 add exponential
345
- // backoff without touching this function's tested boundary arithmetic.
346
- export function cooldownDecision(
347
- ageMs: number | null,
348
- cooldownMs: number,
349
- ): CooldownDecision {
350
- if (ageMs === null) return { kind: "allow" };
351
- if (ageMs < -STALE_LOCK_MS)
352
- return { kind: "allow-future-garbage", futureMs: -ageMs };
353
- if (ageMs < cooldownMs) return { kind: "deny" };
354
- return { kind: "allow" };
355
- }
356
-
357
- // ─── Spawn backoff (consecutive non-convergence widens the cooldown) ────────
358
- //
359
- // [LAW:one-source-of-truth] spawn.cooldown's mtime answers "when was a spawn
360
- // last attempted"; this streak answers "how many attempts in a row have
361
- // failed to converge on a live daemon" — a fact spawn.cooldown's mtime alone
362
- // cannot carry (mtime is overwritten on every attempt, losing the count). One
363
- // small file, one fact, read/written by both runtimes exactly like
364
- // spawn.cooldown itself.
365
- //
366
- // [LAW:single-enforcer] The daemon is the only process that can know
367
- // "convergence achieved" (it just bound the socket and is about to serve) —
368
- // see resetSpawnBackoff(), called once from server.ts's onListening(). A
369
- // client-side reset would need a full successful render round-trip on the
370
- // hot path to detect convergence, adding fs I/O to the common case for a
371
- // signal the daemon already has for free at boot.
372
- //
373
- // Growth is capped at SPAWN_BACKOFF_MAX_STREAK shifts so effectiveCooldownMs
374
- // never has to reason about an unbounded streak (a multi-day outage would
375
- // otherwise grow the stored integer without bound) and so Rust's mirrored
376
- // `<<` cannot overflow. 3_000ms << 5 = 96_000ms, already past the 60s cap, so
377
- // 5 is sufficient — not tuned to any particular outage length.
378
- export const SPAWN_BACKOFF_CAP_MS = 60_000;
379
- export const SPAWN_BACKOFF_MAX_STREAK = 5;
380
-
381
- // [LAW:behavior-not-structure] Pure over the streak; no filesystem. Mirrors
382
- // Rust's effective_cooldown_ms exactly (diffed by check-protocol for the two
383
- // constants; the arithmetic itself is pinned by the boundary unit tests on
384
- // both sides, matching cooldownDecision's existing pattern).
385
- export function effectiveCooldownMs(streak: number): number {
386
- const capped = Math.min(Math.max(streak, 0), SPAWN_BACKOFF_MAX_STREAK);
387
- return Math.min(SPAWN_COOLDOWN_MS * 2 ** capped, SPAWN_BACKOFF_CAP_MS);
388
- }
389
-
390
- // [LAW:no-defensive-null-guards] Number(raw) — not parseInt — so trailing
391
- // garbage ("5abc", "5.0") fails closed to NaN instead of being silently
392
- // truncated to a plausible-looking integer. parseInt's truncation is exactly
393
- // the bug daemonCeiling() (fork-bomb-breaker.ts, brandon-daemon-lifecycle-gad.2)
394
- // fixed for the same "small integer parsed from an untrusted local file" shape;
395
- // repeating parseInt here would reintroduce it. The clamp to
396
- // SPAWN_BACKOFF_MAX_STREAK also bounds every value this function can ever
397
- // return, so no caller — including `streak + 1` — needs its own re-clamp to
398
- // stay overflow-safe (Rust's mirror clamps at the identical boundary, since a
399
- // raw u32 parsed from disk has no such guarantee otherwise).
400
- // Exported so the parsing-strictness contract (Number, not parseInt — see
401
- // the comment above) has a direct test independent of any caller's file
402
- // content, matching cooldownDecision/effectiveCooldownMs's own exported-for-
403
- // testing precedent.
404
- export function readBackoffStreak(filePath: string): number {
405
- // [LAW:no-silent-failure] A missing or garbage streak file is NOT
406
- // ambiguous the way a missing cooldown mtime is: falling back to 0 always
407
- // fails toward the SAME safe direction as the rest of this module (spawn
408
- // permitted at the base rate, never wedged) — matching cooldownDecision's
409
- // own `ageMs === null → allow`. Never loud here; the failure mode is
410
- // "one extra spawn," which bind() already arbitrates.
411
- try {
412
- const raw = fs.readFileSync(filePath, "utf8").trim();
413
- const n = Number(raw);
414
- if (!Number.isInteger(n) || n < 0) return 0;
415
- return Math.min(n, SPAWN_BACKOFF_MAX_STREAK);
416
- } catch {
417
- return 0;
418
- }
419
- }
420
-
421
- // [LAW:no-ambient-temporal-coupling] The read-then-write here (and in
422
- // claimSpawnCooldown below) is not atomic across process boundaries — two
423
- // client processes racing through a daemon-miss window can both read the
424
- // same streak and both write the same increment, undercounting by one. This
425
- // is an accepted, bounded trade, not an oversight: the ONLY failure direction
426
- // is undercounting (the streak can never advance faster than reality), so a
427
- // race just means backoff ramps a little slower than ideal — it can never
428
- // permit MORE spawning than a race-free count would. The hard rate ceiling
429
- // remains spawn.cooldown's mtime gate, which spawn.lock already serializes
430
- // for the common case; this file, like spawn.lock's own documented
431
- // thundering-herd tolerance, is a best-effort optimization on top of that,
432
- // not a second load-bearing lock.
433
- function writeBackoffStreak(filePath: string, streak: number): void {
434
- try {
435
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
436
- fs.writeFileSync(filePath, String(streak), { mode: 0o600 });
437
- } catch (e) {
438
- process.stderr.write(
439
- `cc-candybar: could not record spawn.backoff: ${(e as Error).message}\n`,
440
- );
441
- }
442
- }
443
-
444
- // Called once by the daemon (server.ts onListening) the moment it binds the
445
- // socket — the one process-wide fact that answers "did an outage just end."
446
- // Deletes rather than writes "0": absence already reads as streak 0 via
447
- // readBackoffStreak's catch branch, so there is no separate reset format to
448
- // keep in sync with the normal write path.
449
- //
450
- // [LAW:no-ambient-temporal-coupling] This fires on bind, not on confirmed
451
- // sustained liveness — in the vanishingly rare socket-capture race
452
- // documented above armOwnershipWatch (server.ts), a daemon that only THINKS
453
- // it converged resets the streak early. That daemon self-heals the same way
454
- // ownership does (armOwnershipWatch drains it once the mismatch is detected);
455
- // worst case is one redundant reset in an already-rare race window, not a
456
- // wrong steady-state outcome.
457
- export function resetSpawnBackoff(): void {
458
- try {
459
- fs.unlinkSync(spawnBackoffPath());
460
- } catch (e) {
461
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
462
- process.stderr.write(
463
- `cc-candybar: could not reset spawn.backoff: ${(e as Error).message}\n`,
464
- );
465
- }
466
- }
467
- }
468
-
469
- // [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
470
- // and RECORDS the attempt (updating spawn.cooldown's mtime to now, advancing
471
- // spawn.backoff's streak) — when a spawn is permitted; false when an attempt
472
- // was recorded within the EFFECTIVE cooldown window (SPAWN_COOLDOWN_MS,
473
- // widened by effectiveCooldownMs(streak) once consecutive attempts have
474
- // failed to converge — see brandon-daemon-lifecycle-gad.3). Recording-on-grant
475
- // (BEFORE the caller spawns) is load-bearing: a spawn that then throws or
476
- // returns false still counts against the rate, so a broken binary is not
477
- // retried in a tight loop. A future-mtime garbage record warns loudly and
478
- // falls toward ALLOWING the spawn [LAW:no-silent-failure].
479
- function claimSpawnCooldown(): boolean {
480
- const cooldownPath = spawnCooldownPath();
481
- const backoffPath = spawnBackoffPath();
482
- const streak = readBackoffStreak(backoffPath);
483
- const decision = cooldownDecision(
484
- cooldownAgeMs(cooldownPath),
485
- effectiveCooldownMs(streak),
486
- );
487
- if (decision.kind === "deny") return false;
488
- if (decision.kind === "allow-future-garbage") {
489
- process.stderr.write(
490
- `cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
491
- );
492
- }
493
- recordSpawnAttempt(cooldownPath);
494
- // [LAW:dataflow-not-control-flow] Every granted spawn advances the streak
495
- // by exactly one, unconditionally — the cap lives in the read side
496
- // (effectiveCooldownMs) and here (Math.min), never as a skip.
497
- writeBackoffStreak(
498
- backoffPath,
499
- Math.min(streak + 1, SPAWN_BACKOFF_MAX_STREAK),
500
- );
501
- return true;
502
- }
503
-
504
- function cooldownAgeMs(path: string): number | null {
505
- try {
506
- return Date.now() - fs.statSync(path).mtimeMs;
507
- } catch {
508
- return null;
509
- }
510
- }
511
-
512
- function recordSpawnAttempt(filePath: string): void {
513
- // [LAW:composability] Self-sufficient — ensure the state dir exists rather
514
- // than leaning on an ambient ensureStateDir() precondition, so any spawn site
515
- // routing through claimSpawnCooldown records correctly (mirrors Rust's
516
- // record_spawn_attempt). Content is human-diagnostic only; the mtime is the
517
- // authority. A write failure means no cooldown recorded — worst case one extra
518
- // spawn, which bind() arbitrates — but surface it loudly rather than silently
519
- // un-bound the rate [LAW:no-silent-failure].
520
- try {
521
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
522
- fs.writeFileSync(filePath, `${process.pid} ${Date.now()}\n`, {
523
- mode: 0o600,
524
- });
525
- } catch (e) {
526
- process.stderr.write(
527
- `cc-candybar: could not record spawn.cooldown: ${(e as Error).message}\n`,
528
- );
529
- }
530
- }
531
-
532
- // ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
533
- //
534
- // O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
535
- // we use the simplest portable atomic primitive: open(path, "wx"). The file
536
- // records owner pid + timestamp. Staleness is time-based — if the file is
537
- // older than STALE_LOCK_MS we forcibly claim it. The spawn window is
538
- // sub-second in practice; 10s is a wide tolerance.
539
- //
540
- // [LAW:no-defensive-null-guards] The staleness reclaim is not a "the holder
541
- // might be dead, let me check" guard — it is the bounded-staleness policy of
542
- // the lock. The bind() in the daemon is the real correctness boundary; this
543
- // lock is a thundering-herd optimization, so a missed dedup just means one
544
- // extra Node process eats a bind() race and exits.
545
-
546
- // Exported for the cooldownDecision boundary tests. Mirrored TS↔Rust (diffed by
547
- // check-protocol) since both runtimes apply it to the same spawn.cooldown file.
548
- export const STALE_LOCK_MS = 10_000;
549
-
550
- let heldLock: { fd: number; path: string } | null = null;
551
-
552
- type LockOutcome =
553
- | { kind: "held" }
554
- | { kind: "contended" }
555
- | { kind: "error"; reason: string };
556
-
557
- function tryAcquireSpawnLock(): LockOutcome {
558
- const path = spawnLockPath();
559
- for (let attempt = 0; attempt < 2; attempt++) {
560
- try {
561
- const fd = fs.openSync(path, "wx", 0o600);
562
- try {
563
- fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
564
- } catch {}
565
- heldLock = { fd, path };
566
- return { kind: "held" };
567
- } catch (e) {
568
- const code = (e as NodeJS.ErrnoException).code;
569
- if (code !== "EEXIST") {
570
- // EACCES, ENOTDIR, ENOSPC, etc. are not contention — they are
571
- // unrecoverable. Surface upward instead of pretending we lost a race.
572
- return {
573
- kind: "error",
574
- reason: `openSync(${path}): ${code ?? (e as Error).message}`,
575
- };
576
- }
577
- }
578
- if (!isLockStale(path)) return { kind: "contended" };
579
- try {
580
- fs.unlinkSync(path);
581
- } catch (e) {
582
- // ENOENT means someone (the rightful holder, or another reclaimer)
583
- // already removed the file — that's the desired post-condition, so
584
- // continue to the retry. Other failures (EACCES, ENOSPC) are real.
585
- const code = (e as NodeJS.ErrnoException).code;
586
- if (code !== "ENOENT") {
587
- return {
588
- kind: "error",
589
- reason: `unlink stale spawn.lock: ${(e as Error).message}`,
590
- };
591
- }
592
- }
593
- }
594
- return { kind: "contended" };
595
- }
596
-
597
- function isLockStale(path: string): boolean {
598
- try {
599
- const st = fs.statSync(path);
600
- return Date.now() - st.mtimeMs > STALE_LOCK_MS;
601
- } catch {
602
- return true;
603
- }
604
- }
605
-
606
- function releaseSpawnLock(): void {
607
- if (!heldLock) return;
608
- const { fd, path } = heldLock;
609
- heldLock = null;
610
- try {
611
- fs.closeSync(fd);
612
- } catch {}
613
- try {
614
- fs.unlinkSync(path);
615
- } catch {}
616
- }
617
-
618
- // ─── State-dir setup ────────────────────────────────────────────────────────
619
-
620
- // Returns null on success, or a reason string on unrecoverable failure. Used
621
- // by both obtainDaemon (which converts to a `failed` result) and
622
- // obtainDaemonKick (which silently gives up — there is no caller to report to).
623
- function ensureStateDir(): string | null {
624
- try {
625
- fs.mkdirSync(daemonDir(), { recursive: true });
626
- return null;
627
- } catch (e) {
628
- return `mkdir ${daemonDir()}: ${(e as Error).message}`;
629
- }
630
- }
631
-
632
- // ─── Connect probe ──────────────────────────────────────────────────────────
633
-
634
- function canConnect(sockPath: string, timeoutMs: number): Promise<boolean> {
635
- return new Promise((resolve) => {
636
- const sock = net.connect(sockPath);
637
- let settled = false;
638
- let timer: ReturnType<typeof setTimeout> | null = null;
639
- const done = (result: boolean): void => {
640
- if (settled) return;
641
- settled = true;
642
- if (timer) clearTimeout(timer);
643
- sock.removeAllListeners();
644
- sock.destroy();
645
- resolve(result);
646
- };
647
- sock.once("connect", () => done(true));
648
- sock.once("error", () => done(false));
649
- timer = setTimeout(() => done(false), timeoutMs);
650
- timer.unref();
651
- });
652
- }
653
-
654
- function sleep(ms: number): Promise<void> {
655
- return new Promise((resolve) => setTimeout(resolve, ms).unref());
656
- }
657
-
658
- // ─── Default spawn implementation ───────────────────────────────────────────
659
- //
660
- // [LAW:one-source-of-truth] The V8 old-space cap is derived from the daemon's
661
- // RSS budget (limits.ts: heapCapMb), never a literal here — the cap must sit
662
- // ABOVE the RSS backstop so the graceful path fires first, and only one owner
663
- // of the budget can keep that order true. The Rust client derives the same
664
- // value the same way (rust-client/src/launch.rs).
665
- //
666
- // [LAW:single-enforcer] Routes through src/proc/launch so daemon-spawn shows
667
- // up in subprocess metering (category "daemon-spawn"). The launch primitive
668
- // owns the only child_process import in this file.
669
- function spawnDaemonDetachedReal(): boolean {
670
- const node = process.execPath;
671
- const script = process.argv[1];
672
- if (!script) return false;
673
- // [LAW:no-silent-fallbacks] launchDetachedSync returns the typed outcome
674
- // synchronously, so the spawn-failure case (ENOENT, EACCES, EAGAIN under
675
- // process-table pressure) propagates as `false` instead of being silently
676
- // reported as success. The previous `void launch({detached:true})` form
677
- // discarded the Promise and unconditionally returned true.
678
- const result = launchDetachedSync({
679
- bin: node,
680
- args: [`--max-old-space-size=${heapCapMb(process.env)}`, script, "daemon"],
681
- category: "daemon-spawn",
682
- });
683
- return result.ok;
684
- }