@promptctl/cc-candybar 1.40.0 → 1.41.1

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.
@@ -49,8 +49,13 @@ import {
49
49
  DISCLOSURE_GLYPH_CLOSED,
50
50
  DISCLOSURE_GLYPH_OPEN,
51
51
  disclosureCycleAction,
52
+ disclosureGate,
52
53
  disclosureStateVar,
54
+ disclosureTrigger,
55
+ type DisclosureRef,
53
56
  } from "./disclosure.js";
57
+ import { declareHelp, type HelpDisclosure } from "./help.js";
58
+ import { PERSIST_HELP } from "../help-text.js";
54
59
  import {
55
60
  EDIT_MODE_KEY,
56
61
  EDIT_MODE_OPEN,
@@ -127,11 +132,34 @@ const CONFIG_SEG = `${SETTINGS_NS}config`;
127
132
  // cannot silently write a durable default today — SessionState is per session.
128
133
  const PERSIST_KEY = PERSIST_SEG;
129
134
 
130
- // [LAW:one-source-of-truth] The config menu's own disclosure, spelled the same
131
- // way the anchor above is: ONE name that is the segment, the state variable and
132
- // the cycle action, so its toggle's click and its body's `when` cannot address
133
- // different keys.
134
- const CONFIG_OPEN_GATE = `{{ and (eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}") (eq .${CONFIG_SEG} "${SETTINGS_OPEN}") }}`;
135
+ // [LAW:one-source-of-truth] The two disclosures this menu IS, as refs rather
136
+ // than as gate strings: every gate below — and every `(?)` nested inside them —
137
+ // derives from these, so the toggle that writes a key and the `when` that reads
138
+ // it cannot name different variables.
139
+ const SETTINGS_REF: DisclosureRef = {
140
+ variable: SETTINGS_ANCHOR,
141
+ member: SETTINGS_OPEN,
142
+ };
143
+ const CONFIG_REF: DisclosureRef = {
144
+ variable: CONFIG_SEG,
145
+ member: SETTINGS_OPEN,
146
+ };
147
+
148
+ // Gated on BOTH keys — a config row left open yesterday must not render beside
149
+ // a closed menu today. Nesting is conjunction, which is why it is one list.
150
+ const CONFIG_OPEN_GATE = disclosureGate(SETTINGS_REF, CONFIG_REF);
151
+
152
+ // The `(?)` that explains `persist?` — the one control in this menu whose
153
+ // behaviour a user cannot infer from its label, which is exactly why the ticket
154
+ // named it as a required use site. Its body says what the NEXT click does, in
155
+ // the same two sentences `--help` prints.
156
+ const PERSIST_HELP_SEG = `${SETTINGS_NS}help.persist`;
157
+
158
+ // [LAW:one-source-of-truth] The panel's surface colours, spelled once. The help
159
+ // cells must wear the same ones as the controls they explain — a `(?)` body in
160
+ // a different colour reads as a different panel — and that agreement is only
161
+ // guaranteed if there is one value to hand both.
162
+ const SETTINGS_SURFACE = { bg: "surface", fg: "foreground" } as const;
135
163
 
136
164
  // [LAW:one-source-of-truth] One accordion key for every picker in the menu:
137
165
  // one key holds one open member, so opening a theme picker closes the look
@@ -278,7 +306,7 @@ const controlReset = (name: string): string => `${SETTINGS_NS}reset.${name}`;
278
306
  // from the same anchor string the toggle's cycle writes — spelled once here,
279
307
  // exactly as lowerGroup derives a group body's `when` from the group's own
280
308
  // reference name.
281
- const SETTINGS_OPEN_GATE = `{{ eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}" }}`;
309
+ const SETTINGS_OPEN_GATE = disclosureGate(SETTINGS_REF);
282
310
 
283
311
  // [LAW:single-enforcer] The one answer to "is this segment reference the global
284
312
  // menu's anchor". cross-ref.ts asks it to accept an authored placement of a name
@@ -379,7 +407,10 @@ export function countAnchors(node: LayoutNode): number {
379
407
  // a vertical pair of the toggle segment and a `when`-gated body. Replaces the
380
408
  // anchor leaf wherever it sits, so the author's chosen position is the menu's
381
409
  // position with nothing else moved.
382
- function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
410
+ function expandAnchor(
411
+ node: AnchoredRoot | LayoutNode,
412
+ help: HelpDisclosure,
413
+ ): LayoutNode {
383
414
  if (node.kind === "segment") {
384
415
  return isSettingsAnchor(node.name)
385
416
  ? {
@@ -395,6 +426,32 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
395
426
  direction: "horizontal",
396
427
  children: [
397
428
  { kind: "segment", name: PERSIST_SEG },
429
+ // The `(?)` rides the row that already exists, immediately
430
+ // after the control it explains — so closed help costs no row
431
+ // and widens the bar by one cell, and open help reads as an
432
+ // answer to the checkbox on its left.
433
+ //
434
+ // Mid-row, DELIBERATELY, unlike edit mode's `(?)`, which
435
+ // edit-chrome.ts goes to lengths to trail. The difference is
436
+ // structural, not a discipline applied in one file and skipped
437
+ // here. `nextHueShift` (src/dsl/render.ts:697) counts segment
438
+ // leaves in pre-order, so a leaf's hue index is the number of
439
+ // leaves before it — which makes the consequence arithmetic:
440
+ // reordering leaves WITHIN a subtree cannot change the index of
441
+ // any leaf AFTER it, since the subtree's leaf count does not
442
+ // move. Edit chrome WRAPS the whole tree, so trailing there is
443
+ // after every existing leaf and costs zero. This menu splices
444
+ // MID-TREE at an anchor `withAnchor` lets the author put
445
+ // anywhere, so no position inside it is after the rest of the
446
+ // bar: the leaves it adds — this trigger plus one per
447
+ // PERSIST_HELP line, a count that lives in help-text.ts and is
448
+ // deliberately not copied here — shift everything past the
449
+ // anchor wherever inside the menu they sit. Trailing would cost
450
+ // the adjacency that IS the affordance. The fix is decoupling
451
+ // colour from tree position — candybar-render-y5h, which fixes
452
+ // every mid-tree synthesis at once rather than one file at a
453
+ // time.
454
+ help.trigger,
398
455
  ...PRIMARY_CONTROLS.map(
399
456
  (c): LayoutNode => ({
400
457
  kind: "segment",
@@ -406,6 +463,9 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
406
463
  ],
407
464
  when: SETTINGS_OPEN_GATE,
408
465
  },
466
+ // The help body: one row, present only while the `(?)` is open,
467
+ // directly under the row that asked the question.
468
+ help.body,
409
469
  // Row two: the display settings, behind their own disclosure so
410
470
  // the menu opens narrow. Gated on BOTH keys — a config row left
411
471
  // open yesterday must not render beside a closed menu today; one
@@ -429,7 +489,10 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
429
489
  }
430
490
  : node;
431
491
  }
432
- return { ...node, children: node.children.map(expandAnchor) };
492
+ return {
493
+ ...node,
494
+ children: node.children.map((child) => expandAnchor(child, help)),
495
+ };
433
496
  }
434
497
 
435
498
  // ─── The artifacts ──────────────────────────────────────────────────────────
@@ -476,7 +539,10 @@ function declareHostedMenu(
476
539
  // reference, and a second reference to one declaration is a reuse, not the
477
540
  // self-collision a second `kind: "group"` node would be (see the settingsDrawer
478
541
  // comment in default-dsl-config.ts for that hazard in its original form).
479
- function settingsArtifacts(): MenuArtifacts {
542
+ function settingsArtifacts(): {
543
+ artifacts: MenuArtifacts;
544
+ help: HelpDisclosure;
545
+ } {
480
546
  const artifacts: MenuArtifacts = {
481
547
  variables: {
482
548
  [SETTINGS_ANCHOR]: disclosureStateVar(SETTINGS_ANCHOR, DISCLOSURE_CLOSED),
@@ -497,22 +563,27 @@ function settingsArtifacts(): MenuArtifacts {
497
563
  // [LAW:representation] The glyph trails the label it gates, per the
498
564
  // disclosure vocabulary every other toggle in the bar reads by.
499
565
  [SETTINGS_ANCHOR]: {
500
- template: `{{ action "${SETTINGS_ANCHOR}" "☰ ${DISCLOSURE_GLYPH_CLOSED}" "☰ ${DISCLOSURE_GLYPH_OPEN}" }}`,
501
- bg: "surface",
502
- fg: "foreground",
566
+ template: disclosureTrigger(
567
+ SETTINGS_ANCHOR,
568
+ `☰ ${DISCLOSURE_GLYPH_CLOSED}`,
569
+ `☰ ${DISCLOSURE_GLYPH_OPEN}`,
570
+ ),
571
+ ...SETTINGS_SURFACE,
503
572
  },
504
573
  // [LAW:representation] The checkbox states what the NEXT write does,
505
574
  // which is why the glyph and the word live together: "☑ persist?" is
506
575
  // the whole explanation of where the click below it lands.
507
576
  [PERSIST_SEG]: {
508
577
  template: `{{ action "${PERSIST_SEG}" "☐ persist?" "☑ persist?" }}`,
509
- bg: "surface",
510
- fg: "foreground",
578
+ ...SETTINGS_SURFACE,
511
579
  },
512
580
  [CONFIG_SEG]: {
513
- template: `{{ action "${CONFIG_SEG}" "⚙ config ${DISCLOSURE_GLYPH_CLOSED}" "⚙ config ${DISCLOSURE_GLYPH_OPEN}" }}`,
514
- bg: "surface",
515
- fg: "foreground",
581
+ template: disclosureTrigger(
582
+ CONFIG_SEG,
583
+ `⚙ config ${DISCLOSURE_GLYPH_CLOSED}`,
584
+ `⚙ config ${DISCLOSURE_GLYPH_OPEN}`,
585
+ ),
586
+ ...SETTINGS_SURFACE,
516
587
  },
517
588
  // [LAW:one-type-per-behavior] Both non-picker controls read the same
518
589
  // `.effective` projection their picker siblings read, and write the
@@ -522,8 +593,7 @@ function settingsArtifacts(): MenuArtifacts {
522
593
  template:
523
594
  `{{ action "${controlApply("wrap")}" "wrap: on" "wrap: off" }} ` +
524
595
  `{{ action "${controlReset("wrap")}" "↺" }}`,
525
- bg: "surface",
526
- fg: "foreground",
596
+ ...SETTINGS_SURFACE,
527
597
  },
528
598
  [PADDING_SEG]: {
529
599
  template:
@@ -531,8 +601,7 @@ function settingsArtifacts(): MenuArtifacts {
531
601
  "padding {{ .padding.effective }} " +
532
602
  `{{ action "${controlApply("padding")}.up" "▶" }} ` +
533
603
  `{{ action "${controlReset("padding")}" "↺" }}`,
534
- bg: "surface",
535
- fg: "foreground",
604
+ ...SETTINGS_SURFACE,
536
605
  },
537
606
  // The entry point edit mode never had: `edit.toggle` is a reserved action
538
607
  // whose only bundled reference lives in the `toolbar` segment, which a
@@ -540,8 +609,7 @@ function settingsArtifacts(): MenuArtifacts {
540
609
  // from a segment no config can drop.
541
610
  [EDIT_SEG]: {
542
611
  template: `{{ action "${EDIT_TOGGLE_ACTION}" "✎ edit" "✎ done" }}`,
543
- bg: "surface",
544
- fg: "foreground",
612
+ ...SETTINGS_SURFACE,
545
613
  },
546
614
  },
547
615
  };
@@ -555,7 +623,19 @@ function settingsArtifacts(): MenuArtifacts {
555
623
  DISCLOSURE_CLOSED,
556
624
  );
557
625
  declareSettingControls(artifacts);
558
- return artifacts;
626
+ // [LAW:one-source-of-truth] The `(?)` is minted here, with the panel it
627
+ // belongs to, and its two NODES are returned so `expandAnchor` places them by
628
+ // the value it is handed rather than by re-deriving names this pass already
629
+ // owns. Nested in SETTINGS_REF, so closing the menu takes the open help with
630
+ // it.
631
+ const help = declareHelp(
632
+ PERSIST_HELP_SEG,
633
+ PERSIST_HELP,
634
+ [SETTINGS_REF],
635
+ artifacts,
636
+ SETTINGS_SURFACE,
637
+ );
638
+ return { artifacts, help };
559
639
  }
560
640
 
561
641
  // [LAW:one-source-of-truth] Every setting the menu offers, minted from the one
@@ -581,8 +661,7 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
581
661
  `{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
582
662
  `(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
583
663
  `{{ action "${controlReset(c.name)}" "↺" }}`,
584
- bg: "surface",
585
- fg: "foreground",
664
+ ...SETTINGS_SURFACE,
586
665
  };
587
666
  artifacts.actions[apply] = {
588
667
  set: c.sessionKey,
@@ -676,14 +755,14 @@ export function canHostSessionState(config: DslConfig): boolean {
676
755
  // and every name now declares one.
677
756
  export function synthesizeSettingsMenu(config: DslConfig): DslConfig {
678
757
  if (!canHostSessionState(config)) return config;
679
- const artifacts = settingsArtifacts();
758
+ const { artifacts, help } = settingsArtifacts();
680
759
  ensureEditToggle(artifacts);
681
760
  const presets: Record<string, PresetDecl> = { ...config.presets };
682
761
  for (const name of presetNames(config.presets)) {
683
762
  const { node } = presetRoot(config, name);
684
763
  presets[name] = {
685
764
  ...presetByName(config.presets, name),
686
- root: expandAnchor(withAnchor(node)),
765
+ root: expandAnchor(withAnchor(node), help),
687
766
  };
688
767
  }
689
768
  return {
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import net from "node:net";
3
3
  import path from "node:path";
4
4
  import { launchDetachedSync } from "../proc/launch";
5
+ import { heapCapMb } from "./limits";
5
6
  import process from "node:process";
6
7
  import {
7
8
  socketPath,
@@ -656,9 +657,11 @@ function sleep(ms: number): Promise<void> {
656
657
 
657
658
  // ─── Default spawn implementation ───────────────────────────────────────────
658
659
  //
659
- // Cap V8 old-generation at 400 MB so GC fires before RSS hits the 512 MB hard
660
- // limit. The Rust client mirrors this in rust-client/src/main.rs
661
- // (spawn_daemon_detached) — keep the two in sync when changing this value.
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).
662
665
  //
663
666
  // [LAW:single-enforcer] Routes through src/proc/launch so daemon-spawn shows
664
667
  // up in subprocess metering (category "daemon-spawn"). The launch primitive
@@ -674,7 +677,7 @@ function spawnDaemonDetachedReal(): boolean {
674
677
  // discarded the Promise and unconditionally returned true.
675
678
  const result = launchDetachedSync({
676
679
  bin: node,
677
- args: ["--max-old-space-size=400", script, "daemon"],
680
+ args: [`--max-old-space-size=${heapCapMb(process.env)}`, script, "daemon"],
678
681
  category: "daemon-spawn",
679
682
  });
680
683
  return result.ok;
@@ -8,10 +8,66 @@ import { dlog, type DaemonLogger } from "./log";
8
8
  // Only the RSS trigger remains — idle and age limits were removed because they
9
9
  // interrupted active sessions. The RSS limit is a true anomaly backstop; normal
10
10
  // operation should never approach it now that transcript parsing is pruned.
11
- const DEFAULT_RSS_LIMIT =
12
- (parseInt(process.env["CC_CANDYBAR_RSS_LIMIT_MB"] ?? "", 10) || 512) *
13
- 1024 *
14
- 1024;
11
+ //
12
+ // [LAW:one-source-of-truth] The daemon's memory budget is ONE number, read from
13
+ // ONE place. Two limits derive from it and their ORDER is the whole point:
14
+ //
15
+ // RSS backstop (this module) — graceful: heap snapshot, logged shutdown,
16
+ // clean restart on the next tick.
17
+ // V8 old-space cap (spawners) — hard: V8 aborts with SIGABRT below every JS
18
+ // handler, so no log line, no snapshot, and
19
+ // the next daemon finds only a stale socket.
20
+ //
21
+ // The cap sits at HEAP_CAP_OVER_RSS × the backstop, a margin wide enough that
22
+ // the graceful path fires first under any growth the 60 s poll can see (a
23
+ // burst that doubles RSS inside one poll window can still reach the hard cap).
24
+ // Before this the two were unrelated literals (400 MB heap in
25
+ // each spawner, 512 MB RSS here): a cold daemon seeding a large transcript tree
26
+ // for a dozen sessions blew the heap in seconds, aborted silently, and crash-
27
+ // looped on every render tick while the backstop — a 60 s poll — never got a
28
+ // turn. Raising the env override raises BOTH, because both spawners derive the
29
+ // cap through heapCapMb below. The Rust client mirrors RSS_LIMIT_ENV,
30
+ // DEFAULT_RSS_LIMIT_MB, and HEAP_CAP_OVER_RSS as literals
31
+ // (rust-client/src/launch.rs); scripts/check-protocol.mjs fails the build on
32
+ // drift.
33
+ export const RSS_LIMIT_ENV = "CC_CANDYBAR_RSS_LIMIT_MB";
34
+ export const DEFAULT_RSS_LIMIT_MB = 2048;
35
+ export const HEAP_CAP_OVER_RSS = 2;
36
+
37
+ // [LAW:parse-dont-validate] Absent → default; a positive integer → that; present
38
+ // but malformed → throw. Only an operator ever sets this variable, so garbage
39
+ // is an operator error, and `|| default` would silently run at a budget they
40
+ // did not ask for. [LAW:no-silent-failure]
41
+ //
42
+ // [LAW:one-source-of-truth] The grammar is ONE rule both runtimes apply
43
+ // verbatim — ASCII digits only, > 0, within the safe-integer range —
44
+ // so the spawner and the daemon it spawns accept and reject the same values
45
+ // (rust-client/src/launch.rs heap_cap_mb). A grammar that differed by so much
46
+ // as a leading `+` would let a client spawn a daemon that refuses to boot.
47
+ export function rssLimitMb(env: NodeJS.ProcessEnv): number {
48
+ const raw = env[RSS_LIMIT_ENV];
49
+ if (raw === undefined) return DEFAULT_RSS_LIMIT_MB;
50
+ const mb = /^\d+$/.test(raw) ? Number(raw) : NaN;
51
+ if (!Number.isSafeInteger(mb) || mb <= 0) {
52
+ throw new Error(
53
+ `${RSS_LIMIT_ENV} must be a positive integer (MB), got ${JSON.stringify(raw)}`,
54
+ );
55
+ }
56
+ return mb;
57
+ }
58
+
59
+ // The `--max-old-space-size` value a spawner hands node for the daemon.
60
+ export function heapCapMb(env: NodeJS.ProcessEnv): number {
61
+ return rssLimitMb(env) * HEAP_CAP_OVER_RSS;
62
+ }
63
+
64
+ const BYTES_PER_MB = 1024 * 1024;
65
+
66
+ // The budget in the unit `process.memoryUsage().rss` reports.
67
+ export function rssLimitBytes(env: NodeJS.ProcessEnv): number {
68
+ return rssLimitMb(env) * BYTES_PER_MB;
69
+ }
70
+
15
71
  const DEFAULT_CHECK_INTERVAL = 60 * 1000;
16
72
  const HEAP_SNAPSHOT_KEEP = 3;
17
73
 
@@ -42,7 +98,7 @@ export interface LimitsHandle {
42
98
  }
43
99
 
44
100
  export function makeLimits(deps: LimitsDeps): LimitsHandle {
45
- const rssLimit = deps.rssLimitBytes ?? DEFAULT_RSS_LIMIT;
101
+ const rssLimit = deps.rssLimitBytes ?? DEFAULT_RSS_LIMIT_MB * BYTES_PER_MB;
46
102
  const keep = deps.snapshotsKeep ?? HEAP_SNAPSHOT_KEEP;
47
103
  let triggered = false;
48
104
 
package/src/daemon/log.ts CHANGED
@@ -2,35 +2,34 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { logPath } from "./paths";
4
4
 
5
- const MAX_BYTES = 5 * 1024 * 1024;
5
+ export const MAX_BYTES = 5 * 1024 * 1024;
6
6
  const KEEP_GENERATIONS = 3;
7
7
 
8
- let stream: fs.WriteStream | null = null;
9
- let bytesWritten = 0;
8
+ // [LAW:no-ambient-temporal-coupling] Every line is a synchronous append, so a
9
+ // line is on disk the moment the call returns — including the death line each
10
+ // shutdown path writes last, which an async stream dropped whenever
11
+ // `process.exit` outran its flush. The daemon writes a handful of short lines
12
+ // per second; a sync append is microseconds. No stream means nothing to flush,
13
+ // nothing to close, and no window in which a late writer can reopen a sink
14
+ // that nobody waits for. [LAW:polishing-by-subtraction]
15
+ let bytesWritten: number | null = null;
10
16
 
11
- function ensureStream(): fs.WriteStream {
12
- if (stream) return stream;
13
- const filePath = logPath();
17
+ // Pre-load size once so rotation triggers correctly across daemon restarts.
18
+ function currentBytes(filePath: string): number {
19
+ if (bytesWritten !== null) return bytesWritten;
14
20
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
15
- // Pre-load size so rotation triggers correctly across daemon restarts.
16
21
  try {
17
22
  bytesWritten = fs.statSync(filePath).size;
18
23
  } catch {
19
24
  bytesWritten = 0;
20
25
  }
21
- stream = fs.createWriteStream(filePath, { flags: "a" });
22
- return stream;
26
+ return bytesWritten;
23
27
  }
24
28
 
25
29
  // Self-rotation: when daemon.log exceeds MAX_BYTES, shift .1→.2, .2→.3, drop
26
30
  // the oldest, and start fresh. Daemon-internal so we don't depend on any
27
31
  // external rotator. Cheap because rotation only runs at the rollover boundary.
28
- function rotate(): void {
29
- const filePath = logPath();
30
- if (stream) {
31
- stream.end();
32
- stream = null;
33
- }
32
+ function rotate(filePath: string): void {
34
33
  for (let i = KEEP_GENERATIONS - 1; i >= 1; i--) {
35
34
  const src = `${filePath}.${i}`;
36
35
  const dst = `${filePath}.${i + 1}`;
@@ -44,26 +43,39 @@ function rotate(): void {
44
43
  bytesWritten = 0;
45
44
  }
46
45
 
46
+ export type LogLevel = "info" | "warn" | "error";
47
+
47
48
  // [LAW:locality-or-seam] The logging capability daemon components depend on.
48
49
  // `dlog` is the daemon's implementation (writes to daemon.log); consumers that
49
50
  // inject a different impl (a quiet default in tests) take this shape.
50
- export type DaemonLogger = (
51
- level: "info" | "warn" | "error",
52
- msg: string,
53
- ) => void;
51
+ export type DaemonLogger = (level: LogLevel, msg: string) => void;
54
52
 
55
- export function dlog(level: "info" | "warn" | "error", msg: string): void {
53
+ export function dlog(level: LogLevel, msg: string): void {
56
54
  const line = `${new Date().toISOString()} [${level}] ${msg}\n`;
57
- const buf = Buffer.from(line, "utf8");
58
- const s = ensureStream();
59
- s.write(buf);
60
- bytesWritten += buf.length;
61
- if (bytesWritten >= MAX_BYTES) rotate();
62
- }
63
-
64
- export function closeLog(): void {
65
- if (stream) {
66
- stream.end();
67
- stream = null;
55
+ try {
56
+ // [LAW:single-enforcer] Path resolution reaches os.homedir(); it lives
57
+ // inside the one boundary that makes dlog total.
58
+ const filePath = logPath();
59
+ const before = currentBytes(filePath);
60
+ fs.appendFileSync(filePath, line);
61
+ bytesWritten = before + Buffer.byteLength(line, "utf8");
62
+ if (bytesWritten >= MAX_BYTES) rotate(filePath);
63
+ } catch (e) {
64
+ // [LAW:no-silent-failure] exception: the failure IS the log sink, so it
65
+ // cannot be reported through the log sink. A throw here would escape the
66
+ // crash handlers that call dlog first (uncaughtException → dlog →
67
+ // shutdown), taking the clean-death path down with it. stderr carries the
68
+ // line and the reason — the terminal when the daemon runs by hand, and
69
+ // /dev/null under a detached spawn; the daemon keeps serving either way.
70
+ // Nulling the counter re-runs the lazy init on the next call, so a state
71
+ // dir removed out from under the daemon is recreated for the next line.
72
+ bytesWritten = null;
73
+ try {
74
+ process.stderr.write(
75
+ `${line}cc-candybar: daemon.log unwritable: ${(e as Error).message}\n`,
76
+ );
77
+ } catch {
78
+ // stderr was the last channel.
79
+ }
68
80
  }
69
81
  }
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
+ import v8 from "node:v8";
3
4
  import process from "node:process";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { parseArgs } from "node:util";
@@ -33,7 +34,7 @@ import {
33
34
  releaseRegistration,
34
35
  readRegistryEntry,
35
36
  } from "./fork-bomb-breaker";
36
- import { dlog, closeLog } from "./log";
37
+ import { dlog } from "./log";
37
38
  import {
38
39
  PROTOCOL_VERSION,
39
40
  encodeFrame,
@@ -46,7 +47,12 @@ import { SessionUsageStore } from "./cache/session-usage-store";
46
47
  import { RenderCache } from "./cache/render";
47
48
  import { WatcherRegistry } from "./cache/watchers";
48
49
  import { RuntimeStats } from "./stats";
49
- import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
50
+ import {
51
+ makeLimits,
52
+ realLimitsDeps,
53
+ rssLimitBytes,
54
+ type LimitsHandle,
55
+ } from "./limits";
50
56
  import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
51
57
  import { resetSpawnBackoff } from "./acquire";
52
58
  import { SessionState } from "./session-state";
@@ -154,6 +160,9 @@ let myStartTime: string | null = null;
154
160
  // waiting for the next boot's stale-sweep.
155
161
  let breakerRegistryPath: string | null = null;
156
162
 
163
+ // The parsed memory budget (bytes); set first thing in runDaemon.
164
+ let budgetBytes = 0;
165
+
157
166
  export function runDaemon(): void {
158
167
  // Catch-alls log + exit so the supervisor (the next client) can restart us.
159
168
  // [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
@@ -179,6 +188,23 @@ export function runDaemon(): void {
179
188
  });
180
189
  }
181
190
 
191
+ // [LAW:effects-at-boundaries] The memory budget is parsed here, before any
192
+ // resource is committed — a malformed override is refused before the breaker
193
+ // registers us, before the bind, before the lease, and before a `daemon up`
194
+ // line could claim a boot that is about to die. Parsed once, threaded into
195
+ // armLimits and the boot line.
196
+ // [LAW:single-enforcer] Refused through the same death funnel as every other
197
+ // boot failure. A synchronous throw here is NOT uncaught — it lands in
198
+ // index.ts's catch, whose stderr the detached spawn discards — so the one
199
+ // line that says why the daemon never came up would go nowhere.
200
+ try {
201
+ budgetBytes = rssLimitBytes(process.env);
202
+ } catch (err) {
203
+ dlog("error", `refusing to boot: ${(err as Error).message}`);
204
+ shutdown(1);
205
+ return;
206
+ }
207
+
182
208
  // [LAW:single-enforcer] The fork-bomb circuit breaker runs FIRST among the
183
209
  // resource-committing steps (no dir created, no socket touched, no session
184
210
  // state loaded) — the whole point of a load-independent backstop is that it
@@ -375,7 +401,12 @@ function onListening(sockPath: string): void {
375
401
  }
376
402
  dlog(
377
403
  "info",
378
- `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
404
+ // [FRAMING:representation] Report the heap cap V8 actually applied (the
405
+ // territory), not the flag the spawner meant to pass (the map) — the one
406
+ // question a silent SIGABRT crash-loop leaves open is "which cap was live".
407
+ `daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath} ` +
408
+ `heapCap=${Math.round(v8.getHeapStatistics().heap_size_limit / 1048576)}MB ` +
409
+ `rssLimit=${Math.round(budgetBytes / 1048576)}MB`,
379
410
  );
380
411
  // [LAW:single-enforcer] This bind is the one process-wide fact that answers
381
412
  // "did an outage just end" — see resetSpawnBackoff's doc comment in
@@ -458,7 +489,9 @@ function armBinaryWatch(): void {
458
489
  let limits: LimitsHandle | null = null;
459
490
  function armLimits(): void {
460
491
  limits = makeLimits(
461
- realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code)),
492
+ realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code), {
493
+ rssLimitBytes: budgetBytes,
494
+ }),
462
495
  );
463
496
  limits.arm();
464
497
  }
@@ -569,7 +602,8 @@ function shutdown(code: number): void {
569
602
  (p) => fs.unlinkSync(p),
570
603
  );
571
604
  }
572
- closeLog();
605
+ // Every dlog above was a synchronous append (log.ts), so the death line is
606
+ // already on disk; nothing to flush before exit.
573
607
  process.exit(code);
574
608
  }
575
609
 
package/src/help-text.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
2
+ import { HELP_GLYPH_CLOSED } from "./config/help";
2
3
 
3
4
  // [LAW:effects-at-boundaries] Pure data, no I/O — index.ts owns the console.log
4
5
  // effect. Kept as its own module so the text is importable (and testable) without
@@ -6,6 +7,29 @@ import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
6
7
  // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts
7
8
  // (the same constant the theme/look picker itself renders with), so this text
8
9
  // can't drift from what a user actually sees on the bar.
10
+ // [LAW:one-source-of-truth] THE help corpus, as data. `--help` and the bar's
11
+ // own `(?)` disclosures are two RENDERINGS of these arrays, never two copies of
12
+ // the sentences: a `(?)` segment's template IS one of these strings, and the
13
+ // paragraphs below interpolate the same values. A help sentence typed into a
14
+ // segment template — where nothing would ever notice it drifting from the CLI's
15
+ // wording — is the defect this shape exists to make unrepresentable.
16
+ //
17
+ // [LAW:representation] One line is one CELL on the bar, so each is a complete
18
+ // thought that stands alone and each stays short: `(?)` bodies drop below their
19
+ // row, and a body that overflows `term.cols` wraps into more rows than the fact
20
+ // it explains is worth. Every line leads with the glyph it explains, so the
21
+ // reader matches text to affordance by shape rather than by reading order.
22
+ export const EDIT_MODE_HELP = [
23
+ "+ inserts here",
24
+ "- removes the one left of it",
25
+ "↺ undoes edits",
26
+ ] as const;
27
+
28
+ export const PERSIST_HELP = [
29
+ "☐ this session only",
30
+ "☑ default for every session",
31
+ ] as const;
32
+
9
33
  export const HELP_TEXT = `
10
34
  cc-candybar - Beautiful powerline statusline for Claude Code
11
35
 
@@ -27,8 +51,10 @@ Configuration:
27
51
  needed, and writing your own \`root\` cannot delete it. Click
28
52
  ☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
29
53
  of clickable theme/look/style/wrap/padding controls. The \`persist?\`
30
- checkbox there chooses where a change lands: unchecked it applies to this
31
- session only, checked it becomes the default every session opens with.
54
+ checkbox there chooses where a change lands: ${PERSIST_HELP.join(", ")}.
55
+
56
+ Anywhere the bar shows ${HELP_GLYPH_CLOSED}, clicking it reveals these same instructions
57
+ in place. In edit mode: ${EDIT_MODE_HELP.join(", ")}.
32
58
 
33
59
  Subcommands:
34
60
  install One-shot setup: stages the runtime (native render