@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/README.md +13 -9
- package/bin/cc-candybar +18 -6
- package/dist/index.mjs +89 -86
- package/package.json +6 -22
- package/src/daemon/acquire.ts +148 -12
- package/src/daemon/paths.ts +11 -0
- package/src/daemon/server.ts +6 -0
- package/src/index.ts +8 -6
- package/src/install/index.ts +207 -85
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
99
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
100
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
101
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
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
|
}
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -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
|
-
|
|
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 <
|
|
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
|
|
348
|
-
// spawn is permitted; false when an attempt
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
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
|
|
356
|
-
const
|
|
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(
|
|
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
|
|
package/src/daemon/paths.ts
CHANGED
|
@@ -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
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
|
57
|
-
install One-shot setup:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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).
|