@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/dist/index.mjs +78 -78
- package/package.json +5 -5
- package/src/daemon/acquire.ts +150 -16
- package/src/daemon/paths.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.17.
|
|
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.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.17.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.17.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.17.
|
|
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": {
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
+
cooldownGatedSpawn(spawnFn);
|
|
226
261
|
return;
|
|
227
262
|
}
|
|
228
263
|
try {
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
package/src/daemon/paths.ts
CHANGED
|
@@ -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
|
}
|