@promptctl/cc-candybar 1.22.0 → 1.23.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
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",
@@ -31,8 +31,6 @@
31
31
  "check:protocol": "node scripts/check-protocol.mjs",
32
32
  "gen:schema": "tsx scripts/gen-schema.ts",
33
33
  "check:schema": "tsx scripts/check-schema.ts",
34
- "prepack": "node scripts/write-bin-placeholder.mjs",
35
- "postinstall": "node scripts/postinstall.mjs",
36
34
  "prepublishOnly": "npm run lint && npm run typecheck && npm run check:protocol && npm run check:schema && npm run build"
37
35
  },
38
36
  "keywords": [
@@ -86,30 +84,16 @@
86
84
  "ts-jest": "^29.4.1",
87
85
  "tsdown": "^0.21.4",
88
86
  "tsx": "^4.21.0",
89
- "typescript": "^5.0.0"
90
- },
91
- "dependencies": {
87
+ "typescript": "^5.0.0",
92
88
  "@promptctl/go-template-js": "^0.7.0",
93
89
  "@promptctl/rich-js": "^0.6.0",
94
90
  "json5": "^2.2.3",
95
91
  "mobx": "^6.15.0"
96
92
  },
97
93
  "optionalDependencies": {
98
- "@promptctl/cc-candybar-darwin-arm64": "1.22.0",
99
- "@promptctl/cc-candybar-darwin-x64": "1.22.0",
100
- "@promptctl/cc-candybar-linux-x64": "1.22.0",
101
- "@promptctl/cc-candybar-linux-arm64": "1.22.0"
102
- },
103
- "pnpm": {
104
- "supportedArchitectures": {
105
- "os": [
106
- "darwin",
107
- "linux"
108
- ],
109
- "cpu": [
110
- "x64",
111
- "arm64"
112
- ]
113
- }
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.23.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.23.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.23.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.23.0"
114
98
  }
115
99
  }
@@ -7,6 +7,7 @@ import {
7
7
  socketPath,
8
8
  spawnLockPath,
9
9
  spawnCooldownPath,
10
+ spawnBackoffPath,
10
11
  daemonDir,
11
12
  } from "./paths";
12
13
 
@@ -335,32 +336,167 @@ export type CooldownDecision =
335
336
  | { kind: "allow-future-garbage"; futureMs: number }
336
337
  | { kind: "deny" };
337
338
 
338
- export function cooldownDecision(ageMs: number | null): CooldownDecision {
339
+ // [LAW:types-are-the-program] `cooldownMs` is the required window, not a
340
+ // captured constant — the decision is the same pure fold whether the caller
341
+ // is checking against the base SPAWN_COOLDOWN_MS or a backed-off window from
342
+ // effectiveCooldownMs(streak) below. Generalizing the threshold into a
343
+ // parameter is what let brandon-daemon-lifecycle-gad.3 add exponential
344
+ // backoff without touching this function's tested boundary arithmetic.
345
+ export function cooldownDecision(
346
+ ageMs: number | null,
347
+ cooldownMs: number,
348
+ ): CooldownDecision {
339
349
  if (ageMs === null) return { kind: "allow" };
340
350
  if (ageMs < -STALE_LOCK_MS)
341
351
  return { kind: "allow-future-garbage", futureMs: -ageMs };
342
- if (ageMs < SPAWN_COOLDOWN_MS) return { kind: "deny" };
352
+ if (ageMs < cooldownMs) return { kind: "deny" };
343
353
  return { kind: "allow" };
344
354
  }
345
355
 
356
+ // ─── Spawn backoff (consecutive non-convergence widens the cooldown) ────────
357
+ //
358
+ // [LAW:one-source-of-truth] spawn.cooldown's mtime answers "when was a spawn
359
+ // last attempted"; this streak answers "how many attempts in a row have
360
+ // failed to converge on a live daemon" — a fact spawn.cooldown's mtime alone
361
+ // cannot carry (mtime is overwritten on every attempt, losing the count). One
362
+ // small file, one fact, read/written by both runtimes exactly like
363
+ // spawn.cooldown itself.
364
+ //
365
+ // [LAW:single-enforcer] The daemon is the only process that can know
366
+ // "convergence achieved" (it just bound the socket and is about to serve) —
367
+ // see resetSpawnBackoff(), called once from server.ts's onListening(). A
368
+ // client-side reset would need a full successful render round-trip on the
369
+ // hot path to detect convergence, adding fs I/O to the common case for a
370
+ // signal the daemon already has for free at boot.
371
+ //
372
+ // Growth is capped at SPAWN_BACKOFF_MAX_STREAK shifts so effectiveCooldownMs
373
+ // never has to reason about an unbounded streak (a multi-day outage would
374
+ // otherwise grow the stored integer without bound) and so Rust's mirrored
375
+ // `<<` cannot overflow. 3_000ms << 5 = 96_000ms, already past the 60s cap, so
376
+ // 5 is sufficient — not tuned to any particular outage length.
377
+ export const SPAWN_BACKOFF_CAP_MS = 60_000;
378
+ export const SPAWN_BACKOFF_MAX_STREAK = 5;
379
+
380
+ // [LAW:behavior-not-structure] Pure over the streak; no filesystem. Mirrors
381
+ // Rust's effective_cooldown_ms exactly (diffed by check-protocol for the two
382
+ // constants; the arithmetic itself is pinned by the boundary unit tests on
383
+ // both sides, matching cooldownDecision's existing pattern).
384
+ export function effectiveCooldownMs(streak: number): number {
385
+ const capped = Math.min(Math.max(streak, 0), SPAWN_BACKOFF_MAX_STREAK);
386
+ return Math.min(SPAWN_COOLDOWN_MS * 2 ** capped, SPAWN_BACKOFF_CAP_MS);
387
+ }
388
+
389
+ // [LAW:no-defensive-null-guards] Number(raw) — not parseInt — so trailing
390
+ // garbage ("5abc", "5.0") fails closed to NaN instead of being silently
391
+ // truncated to a plausible-looking integer. parseInt's truncation is exactly
392
+ // the bug daemonCeiling() (fork-bomb-breaker.ts, brandon-daemon-lifecycle-gad.2)
393
+ // fixed for the same "small integer parsed from an untrusted local file" shape;
394
+ // repeating parseInt here would reintroduce it. The clamp to
395
+ // SPAWN_BACKOFF_MAX_STREAK also bounds every value this function can ever
396
+ // return, so no caller — including `streak + 1` — needs its own re-clamp to
397
+ // stay overflow-safe (Rust's mirror clamps at the identical boundary, since a
398
+ // raw u32 parsed from disk has no such guarantee otherwise).
399
+ // Exported so the parsing-strictness contract (Number, not parseInt — see
400
+ // the comment above) has a direct test independent of any caller's file
401
+ // content, matching cooldownDecision/effectiveCooldownMs's own exported-for-
402
+ // testing precedent.
403
+ export function readBackoffStreak(filePath: string): number {
404
+ // [LAW:no-silent-failure] A missing or garbage streak file is NOT
405
+ // ambiguous the way a missing cooldown mtime is: falling back to 0 always
406
+ // fails toward the SAME safe direction as the rest of this module (spawn
407
+ // permitted at the base rate, never wedged) — matching cooldownDecision's
408
+ // own `ageMs === null → allow`. Never loud here; the failure mode is
409
+ // "one extra spawn," which bind() already arbitrates.
410
+ try {
411
+ const raw = fs.readFileSync(filePath, "utf8").trim();
412
+ const n = Number(raw);
413
+ if (!Number.isInteger(n) || n < 0) return 0;
414
+ return Math.min(n, SPAWN_BACKOFF_MAX_STREAK);
415
+ } catch {
416
+ return 0;
417
+ }
418
+ }
419
+
420
+ // [LAW:no-ambient-temporal-coupling] The read-then-write here (and in
421
+ // claimSpawnCooldown below) is not atomic across process boundaries — two
422
+ // client processes racing through a daemon-miss window can both read the
423
+ // same streak and both write the same increment, undercounting by one. This
424
+ // is an accepted, bounded trade, not an oversight: the ONLY failure direction
425
+ // is undercounting (the streak can never advance faster than reality), so a
426
+ // race just means backoff ramps a little slower than ideal — it can never
427
+ // permit MORE spawning than a race-free count would. The hard rate ceiling
428
+ // remains spawn.cooldown's mtime gate, which spawn.lock already serializes
429
+ // for the common case; this file, like spawn.lock's own documented
430
+ // thundering-herd tolerance, is a best-effort optimization on top of that,
431
+ // not a second load-bearing lock.
432
+ function writeBackoffStreak(filePath: string, streak: number): void {
433
+ try {
434
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
435
+ fs.writeFileSync(filePath, String(streak), { mode: 0o600 });
436
+ } catch (e) {
437
+ process.stderr.write(
438
+ `cc-candybar: could not record spawn.backoff: ${(e as Error).message}\n`,
439
+ );
440
+ }
441
+ }
442
+
443
+ // Called once by the daemon (server.ts onListening) the moment it binds the
444
+ // socket — the one process-wide fact that answers "did an outage just end."
445
+ // Deletes rather than writes "0": absence already reads as streak 0 via
446
+ // readBackoffStreak's catch branch, so there is no separate reset format to
447
+ // keep in sync with the normal write path.
448
+ //
449
+ // [LAW:no-ambient-temporal-coupling] This fires on bind, not on confirmed
450
+ // sustained liveness — in the vanishingly rare socket-capture race
451
+ // documented above armOwnershipWatch (server.ts), a daemon that only THINKS
452
+ // it converged resets the streak early. That daemon self-heals the same way
453
+ // ownership does (armOwnershipWatch drains it once the mismatch is detected);
454
+ // worst case is one redundant reset in an already-rare race window, not a
455
+ // wrong steady-state outcome.
456
+ export function resetSpawnBackoff(): void {
457
+ try {
458
+ fs.unlinkSync(spawnBackoffPath());
459
+ } catch (e) {
460
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
461
+ process.stderr.write(
462
+ `cc-candybar: could not reset spawn.backoff: ${(e as Error).message}\n`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+
346
468
  // [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].
469
+ // and RECORDS the attempt (updating spawn.cooldown's mtime to now, advancing
470
+ // spawn.backoff's streak) — when a spawn is permitted; false when an attempt
471
+ // was recorded within the EFFECTIVE cooldown window (SPAWN_COOLDOWN_MS,
472
+ // widened by effectiveCooldownMs(streak) once consecutive attempts have
473
+ // failed to converge see brandon-daemon-lifecycle-gad.3). Recording-on-grant
474
+ // (BEFORE the caller spawns) is load-bearing: a spawn that then throws or
475
+ // returns false still counts against the rate, so a broken binary is not
476
+ // retried in a tight loop. A future-mtime garbage record warns loudly and
477
+ // falls toward ALLOWING the spawn [LAW:no-silent-failure].
354
478
  function claimSpawnCooldown(): boolean {
355
- const path = spawnCooldownPath();
356
- const decision = cooldownDecision(cooldownAgeMs(path));
479
+ const cooldownPath = spawnCooldownPath();
480
+ const backoffPath = spawnBackoffPath();
481
+ const streak = readBackoffStreak(backoffPath);
482
+ const decision = cooldownDecision(
483
+ cooldownAgeMs(cooldownPath),
484
+ effectiveCooldownMs(streak),
485
+ );
357
486
  if (decision.kind === "deny") return false;
358
487
  if (decision.kind === "allow-future-garbage") {
359
488
  process.stderr.write(
360
489
  `cc-candybar: spawn.cooldown mtime is ${decision.futureMs}ms in the future — ignoring and spawning\n`,
361
490
  );
362
491
  }
363
- recordSpawnAttempt(path);
492
+ recordSpawnAttempt(cooldownPath);
493
+ // [LAW:dataflow-not-control-flow] Every granted spawn advances the streak
494
+ // by exactly one, unconditionally — the cap lives in the read side
495
+ // (effectiveCooldownMs) and here (Math.min), never as a skip.
496
+ writeBackoffStreak(
497
+ backoffPath,
498
+ Math.min(streak + 1, SPAWN_BACKOFF_MAX_STREAK),
499
+ );
364
500
  return true;
365
501
  }
366
502
 
@@ -185,6 +185,17 @@ export function spawnCooldownPath(): string {
185
185
  return path.join(stateDir(), SPAWN_COOLDOWN_FILE);
186
186
  }
187
187
 
188
+ // [LAW:one-source-of-truth] Sibling of spawn.cooldown: that file's mtime
189
+ // answers "when was a spawn last attempted"; this file's content answers
190
+ // "how many attempts in a row have failed to converge on a live daemon" —
191
+ // the consecutive-non-convergence streak that widens the cooldown window
192
+ // (see effectiveCooldownMs in acquire.ts). Same filename mirrored TS↔Rust,
193
+ // diffed by scripts/check-protocol.mjs.
194
+ const SPAWN_BACKOFF_FILE = "spawn.backoff";
195
+ export function spawnBackoffPath(): string {
196
+ return path.join(stateDir(), SPAWN_BACKOFF_FILE);
197
+ }
198
+
188
199
  export function logPath(): string {
189
200
  return path.join(stateDir(), "daemon.log");
190
201
  }
@@ -48,6 +48,7 @@ import { WatcherRegistry } from "./cache/watchers";
48
48
  import { RuntimeStats } from "./stats";
49
49
  import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
50
50
  import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
51
+ import { resetSpawnBackoff } from "./acquire";
51
52
  import { SessionState } from "./session-state";
52
53
  import { FileSessionStorage } from "./session-state-file";
53
54
  import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
@@ -377,6 +378,11 @@ function onListening(sockPath: string): void {
377
378
  "info",
378
379
  `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
379
380
  );
381
+ // [LAW:single-enforcer] This bind is the one process-wide fact that answers
382
+ // "did an outage just end" — see resetSpawnBackoff's doc comment in
383
+ // acquire.ts. Any consecutive-spawn backoff accumulated getting here no
384
+ // longer applies once a daemon is actually serving.
385
+ resetSpawnBackoff();
380
386
  armBinaryWatch();
381
387
  armLimits();
382
388
  armOwnershipWatch(sockPath, boundRead.identity);
package/src/index.ts CHANGED
@@ -53,12 +53,14 @@ Configuration:
53
53
  to point at a specific file. See the default config for all available options:
54
54
  node dist/index.mjs debug --project-dir . --cwd .
55
55
 
56
- Subcommands (macOS):
57
- install One-shot setup: creates the URL handler app, registers
58
- the cc-candybar:// scheme, and writes the statusLine
59
- command into ~/.claude/settings.json.
60
- install-url-handler Just create + register the URL handler app
61
- (~/Applications/CCCandybarURLHandler.app).
56
+ Subcommands:
57
+ install One-shot setup: stages the runtime (native render
58
+ binary + dist bundle) at a stable path, creates the
59
+ URL handler app + cc-candybar:// scheme (macOS), and
60
+ writes the staged entry as the statusLine command in
61
+ ~/.claude/settings.json. Re-run to update.
62
+ install-url-handler Just stage the runtime and create + register the URL
63
+ handler app (macOS only).
62
64
  url-handle URL Internal — invoked by the URL handler app on
63
65
  cmd-click. Parses cc-candybar://<verb>/<value> and
64
66
  dispatches (currently: copy to clipboard).