@promptctl/cc-candybar 1.17.2 → 1.17.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.17.2",
3
+ "version": "1.17.3",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.17.2",
95
- "@promptctl/cc-candybar-darwin-x64": "1.17.2",
96
- "@promptctl/cc-candybar-linux-x64": "1.17.2",
97
- "@promptctl/cc-candybar-linux-arm64": "1.17.2"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.17.3",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.17.3",
96
+ "@promptctl/cc-candybar-linux-x64": "1.17.3",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.17.3"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -1,8 +1,14 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
+ import path from "node:path";
3
4
  import { launchDetachedSync } from "../proc/launch";
4
5
  import process from "node:process";
5
- import { socketPath, spawnLockPath, daemonDir } from "./paths";
6
+ import {
7
+ socketPath,
8
+ spawnLockPath,
9
+ spawnCooldownPath,
10
+ daemonDir,
11
+ } from "./paths";
6
12
 
7
13
  // [LAW:single-enforcer] One primitive per runtime that owns the entire
8
14
  // "obtain a daemon" verb. Every spawn site in the Node runtime now flows
@@ -133,6 +139,28 @@ async function spawnAndWaitForReady(
133
139
  outerDeadline: number,
134
140
  reasonSuffix: string,
135
141
  ): Promise<ObtainResult> {
142
+ const readyDeadline = Math.min(
143
+ Date.now() + spawnReadyTimeoutMs,
144
+ outerDeadline,
145
+ );
146
+
147
+ // [LAW:dataflow-not-control-flow] The spawn-rate bound is consulted as data,
148
+ // not a mode. If a spawn was attempted within SPAWN_COOLDOWN_MS, one is
149
+ // already in flight — do NOT add another Node process; wait for the in-flight
150
+ // boot. This keeps obtainDaemon under the same global rate cap as the kick
151
+ // path, so the rate bound holds for EVERY spawn site [LAW:one-source-of-truth].
152
+ if (!claimSpawnCooldown()) {
153
+ // [LAW:no-silent-failure] This failure's cause is the cooldown gate, not the
154
+ // lock path we arrived through — so it does NOT inherit reasonSuffix (which
155
+ // tags lock-held vs lock-fallback *spawn* provenance). We never spawned here.
156
+ return (await pollUntilReady(connectTimeoutMs, readyDeadline))
157
+ ? { kind: "attached" }
158
+ : {
159
+ kind: "failed",
160
+ reason: "spawn on cooldown; no daemon became ready during the wait",
161
+ };
162
+ }
163
+
136
164
  // [LAW:no-defensive-null-guards] obtainDaemon is typed Promise<ObtainResult>.
137
165
  // A synchronous throw from child_process.spawn (ENOENT, invalid options)
138
166
  // must become a typed failure, not a rejected promise.
@@ -151,20 +179,27 @@ async function spawnAndWaitForReady(
151
179
  reason: `spawn returned false${reasonSuffix}`,
152
180
  };
153
181
  }
154
- const readyDeadline = Math.min(
155
- Date.now() + spawnReadyTimeoutMs,
156
- outerDeadline,
157
- );
182
+ return (await pollUntilReady(connectTimeoutMs, readyDeadline))
183
+ ? { kind: "started" }
184
+ : {
185
+ kind: "failed",
186
+ reason: `daemon did not bind in time${reasonSuffix}`,
187
+ };
188
+ }
189
+
190
+ // Poll the socket until a daemon answers or the deadline elapses. Shared by the
191
+ // spawn path (→ "started") and the cooldown-blocked wait path (→ "attached"):
192
+ // both need "did a daemon come up in time", they differ only in how they label
193
+ // the outcome.
194
+ async function pollUntilReady(
195
+ connectTimeoutMs: number,
196
+ readyDeadline: number,
197
+ ): Promise<boolean> {
158
198
  while (Date.now() < readyDeadline) {
159
- if (await canConnect(socketPath(), connectTimeoutMs)) {
160
- return { kind: "started" };
161
- }
199
+ if (await canConnect(socketPath(), connectTimeoutMs)) return true;
162
200
  await sleep(20);
163
201
  }
164
- return {
165
- kind: "failed",
166
- reason: `daemon did not bind in time${reasonSuffix}`,
167
- };
202
+ return false;
168
203
  }
169
204
 
170
205
  // Synchronous fire-and-forget kick — used for "daemon-miss" recovery where
@@ -214,7 +249,7 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
214
249
  process.stderr.write(
215
250
  `cc-candybar: spawn-lock held ${ageMs}ms (likely crashed holder) — spawning unlocked\n`,
216
251
  );
217
- safeSpawn(spawnFn);
252
+ cooldownGatedSpawn(spawnFn);
218
253
  }
219
254
  return;
220
255
  }
@@ -222,16 +257,26 @@ export function obtainDaemonKick(opts: { spawn?: () => boolean } = {}): void {
222
257
  process.stderr.write(
223
258
  `cc-candybar: spawn-lock unavailable (${lock.reason}) — spawning unlocked\n`,
224
259
  );
225
- safeSpawn(spawnFn);
260
+ cooldownGatedSpawn(spawnFn);
226
261
  return;
227
262
  }
228
263
  try {
229
- safeSpawn(spawnFn);
264
+ cooldownGatedSpawn(spawnFn);
230
265
  } finally {
231
266
  releaseSpawnLock();
232
267
  }
233
268
  }
234
269
 
270
+ // [LAW:single-enforcer] Every kick spawn site routes through here, so the
271
+ // spawn-rate bound is applied at exactly one boundary — mirror of Rust's
272
+ // spawn_daemon_rate_limited. On cooldown we do nothing: a spawn was attempted
273
+ // within SPAWN_COOLDOWN_MS and is likely still booting; the kick is
274
+ // fire-and-forget, so "already in flight" is a complete answer.
275
+ function cooldownGatedSpawn(spawnFn: () => boolean): void {
276
+ if (!claimSpawnCooldown()) return;
277
+ safeSpawn(spawnFn);
278
+ }
279
+
235
280
  function spawnLockAgeMs(): number | null {
236
281
  try {
237
282
  const st = fs.statSync(spawnLockPath());
@@ -260,6 +305,93 @@ function safeSpawn(spawnFn: () => boolean): void {
260
305
  }
261
306
  }
262
307
 
308
+ // ─── Spawn cooldown (shared spawn-RATE bound) ────────────────────────────────
309
+ //
310
+ // [LAW:one-source-of-truth] spawn.lock dedups spawns at one INSTANT; the
311
+ // cooldown bounds them over TIME. Without it, the Rust kick — which releases
312
+ // spawn.lock milliseconds after forking, before the 0.5-3s Node boot window —
313
+ // re-spawns on every render tick during an outage (spawn rate ≈ tick rate:
314
+ // dozens/sec, process-table exhaustion). One file's mtime records the last spawn
315
+ // ATTEMPT; both runtimes consult it. The constant and filename are mirrored TS↔
316
+ // Rust (scripts/check-protocol.mjs). Worst case with the bound: ~20 spawns/min
317
+ // globally, each of which exits cleanly via the sibling socket-lease defenses.
318
+ // Exported for the boundary unit tests (test/daemon-acquire.test.ts), which pin
319
+ // the window arithmetic against the exact constant the same way the Rust unit
320
+ // tests do — so a TS↔Rust decision divergence at a boundary is caught even
321
+ // though check-protocol only diffs the constant's value.
322
+ export const SPAWN_COOLDOWN_MS = 3_000;
323
+
324
+ // [LAW:effects-at-boundaries] The window arithmetic — the subtle part: a
325
+ // future-mtime garbage record (beyond the stale-lock window) must not pin the
326
+ // cooldown forever, while a small negative age is just ms-truncation of
327
+ // Date.now() against the higher-precision fs mtime and still counts as a
328
+ // just-recorded attempt — is a pure function of the record's age, extracted from
329
+ // the fs read so it is unit-tested without touching the filesystem. Mirrors the
330
+ // Rust cooldown_decision. `null` age (missing/unreadable file) allows (first
331
+ // spawn); the mtime IS the timestamp, so "unparseable timestamp" is
332
+ // unrepresentable by construction.
333
+ export type CooldownDecision =
334
+ | { kind: "allow" }
335
+ | { kind: "allow-future-garbage"; futureMs: number }
336
+ | { kind: "deny" };
337
+
338
+ export function cooldownDecision(ageMs: number | null): CooldownDecision {
339
+ if (ageMs === null) return { kind: "allow" };
340
+ if (ageMs < -STALE_LOCK_MS)
341
+ return { kind: "allow-future-garbage", futureMs: -ageMs };
342
+ if (ageMs < SPAWN_COOLDOWN_MS) return { kind: "deny" };
343
+ return { kind: "allow" };
344
+ }
345
+
346
+ // [LAW:single-enforcer] The sole authority on daemon-spawn RATE. Returns true —
347
+ // and RECORDS the attempt (updating spawn.cooldown's mtime to now) — when a
348
+ // spawn is permitted; false when an attempt was recorded within
349
+ // SPAWN_COOLDOWN_MS. Recording-on-grant (BEFORE the caller spawns) is
350
+ // load-bearing: a spawn that then throws or returns false still counts against
351
+ // the rate, so a broken binary is not retried in a tight loop. A future-mtime
352
+ // garbage record warns loudly and falls toward ALLOWING the spawn
353
+ // [LAW:no-silent-failure].
354
+ function claimSpawnCooldown(): boolean {
355
+ const path = spawnCooldownPath();
356
+ const decision = cooldownDecision(cooldownAgeMs(path));
357
+ if (decision.kind === "deny") return false;
358
+ if (decision.kind === "allow-future-garbage") {
359
+ process.stderr.write(
360
+ `cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
361
+ );
362
+ }
363
+ recordSpawnAttempt(path);
364
+ return true;
365
+ }
366
+
367
+ function cooldownAgeMs(path: string): number | null {
368
+ try {
369
+ return Date.now() - fs.statSync(path).mtimeMs;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ function recordSpawnAttempt(filePath: string): void {
376
+ // [LAW:composability] Self-sufficient — ensure the state dir exists rather
377
+ // than leaning on an ambient ensureStateDir() precondition, so any spawn site
378
+ // routing through claimSpawnCooldown records correctly (mirrors Rust's
379
+ // record_spawn_attempt). Content is human-diagnostic only; the mtime is the
380
+ // authority. A write failure means no cooldown recorded — worst case one extra
381
+ // spawn, which bind() arbitrates — but surface it loudly rather than silently
382
+ // un-bound the rate [LAW:no-silent-failure].
383
+ try {
384
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
385
+ fs.writeFileSync(filePath, `${process.pid} ${Date.now()}\n`, {
386
+ mode: 0o600,
387
+ });
388
+ } catch (e) {
389
+ process.stderr.write(
390
+ `cc-candybar: could not record spawn.cooldown: ${(e as Error).message}\n`,
391
+ );
392
+ }
393
+ }
394
+
263
395
  // ─── Spawn-lock (Node side) ──────────────────────────────────────────────────
264
396
  //
265
397
  // O_EXLOCK / fcntl(F_SETLK) aren't reliably exposed across Node platforms, so
@@ -274,7 +406,9 @@ function safeSpawn(spawnFn: () => boolean): void {
274
406
  // lock is a thundering-herd optimization, so a missed dedup just means one
275
407
  // extra Node process eats a bind() race and exits.
276
408
 
277
- const STALE_LOCK_MS = 10_000;
409
+ // Exported for the cooldownDecision boundary tests. Mirrored TS↔Rust (diffed by
410
+ // check-protocol) since both runtimes apply it to the same spawn.cooldown file.
411
+ export const STALE_LOCK_MS = 10_000;
278
412
 
279
413
  let heldLock: { fd: number; path: string } | null = null;
280
414
 
@@ -146,6 +146,17 @@ export function spawnLockPath(): string {
146
146
  return path.join(stateDir(), "spawn.lock");
147
147
  }
148
148
 
149
+ // [LAW:one-source-of-truth] The spawn-RATE bound (as distinct from spawn.lock's
150
+ // instantaneous dedup) is anchored to one file's mtime beside spawn.lock: the
151
+ // time of the last daemon-spawn ATTEMPT. Both runtimes gate on the SAME file, so
152
+ // the filename is mirrored TS↔Rust (rust-client/src/main.rs SPAWN_COOLDOWN_FILE)
153
+ // and diffed by scripts/check-protocol.mjs — a drift would silently split the
154
+ // rate bound in two.
155
+ const SPAWN_COOLDOWN_FILE = "spawn.cooldown";
156
+ export function spawnCooldownPath(): string {
157
+ return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
158
+ }
159
+
149
160
  export function logPath(): string {
150
161
  return path.join(stateDir(), "daemon.log");
151
162
  }