@promptctl/cc-candybar 1.41.0 → 1.41.2
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 +1 -1
- package/dist/index.mjs +90 -90
- package/package.json +6 -6
- package/src/config/dsl-types.ts +4 -4
- package/src/config/loader/helpers.ts +1 -1
- package/src/daemon/acquire.ts +7 -4
- package/src/daemon/limits.ts +62 -5
- package/src/daemon/log.ts +43 -31
- package/src/daemon/server.ts +39 -5
- package/src/dsl/render.ts +30 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.41.
|
|
3
|
+
"version": "1.41.2",
|
|
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",
|
|
@@ -85,15 +85,15 @@
|
|
|
85
85
|
"tsdown": "^0.21.4",
|
|
86
86
|
"tsx": "^4.21.0",
|
|
87
87
|
"typescript": "^5.0.0",
|
|
88
|
-
"@promptctl/go-template-js": "^0.
|
|
88
|
+
"@promptctl/go-template-js": "^0.8.0",
|
|
89
89
|
"@promptctl/rich-js": "^0.7.0",
|
|
90
90
|
"json5": "^2.2.3",
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.41.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.41.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.41.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.41.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.41.2",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.41.2",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.41.2",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.41.2"
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/config/dsl-types.ts
CHANGED
|
@@ -181,9 +181,9 @@ export interface RawDslConfig {
|
|
|
181
181
|
// per session exactly like theme/style (session key `look`).
|
|
182
182
|
readonly looks?: Readonly<Record<string, ThemeKey>>;
|
|
183
183
|
// [LAW:single-enforcer] Config-level shared helper templates: name → Go-template
|
|
184
|
-
// body. Each compiles to one `{{ define "name" }}body{{ end }}`
|
|
185
|
-
// whole set into
|
|
186
|
-
//
|
|
184
|
+
// body. Each compiles to one `{{ define "name" }}body{{ end }}` unit, and the
|
|
185
|
+
// whole set into one shared define set every template this config parses
|
|
186
|
+
// inherits — so a formatter (`{{ template "formatCost" .x }}`) is
|
|
187
187
|
// defined ONCE and callable from any segment/predicate, never re-inlined per
|
|
188
188
|
// segment. Absent ≡ no helpers; merges by-name (user overrides a helper).
|
|
189
189
|
readonly helpers?: Readonly<Record<string, string>>;
|
|
@@ -240,7 +240,7 @@ export interface DslConfig {
|
|
|
240
240
|
// [LAW:dataflow-not-control-flow].
|
|
241
241
|
readonly editGlobals: Partial<Globals>;
|
|
242
242
|
// [LAW:single-enforcer] The effective helper set: a name → template-body map
|
|
243
|
-
// compiled to
|
|
243
|
+
// compiled to one shared define set at registerDslConfig. Empty when no config
|
|
244
244
|
// declares helpers — an absent `helpers` key merges to `{}` (same cascade as
|
|
245
245
|
// actions). The single definition site for each formatter/transform a template
|
|
246
246
|
// calls via `{{ template "name" .arg }}`.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// record of name → template-body STRING. Each value is a Go-template source the
|
|
3
3
|
// renderer compiles into a `{{ define }}` block; whether the body PARSES (and
|
|
4
4
|
// whether a `{{ template "name" }}` reference resolves) is a render-time concern
|
|
5
|
-
// (registerDslConfig parses
|
|
5
|
+
// (registerDslConfig parses each helper and throws a per-helper diagnostic).
|
|
6
6
|
// This file changes only if the helper authoring shape changes.
|
|
7
7
|
|
|
8
8
|
import { findKeyLine } from "./diagnostics.js";
|
package/src/daemon/acquire.ts
CHANGED
|
@@ -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
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
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: [
|
|
680
|
+
args: [`--max-old-space-size=${heapCapMb(process.env)}`, script, "daemon"],
|
|
678
681
|
category: "daemon-spawn",
|
|
679
682
|
});
|
|
680
683
|
return result.ok;
|
package/src/daemon/limits.ts
CHANGED
|
@@ -8,10 +8,67 @@ 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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 each spawner,
|
|
25
|
+
// 512 MB RSS here), and the 2026-09-03 outage found the gap: a daemon holding
|
|
26
|
+
// twenty configs' worth of duplicated helper-template ASTs (since fixed in
|
|
27
|
+
// src/dsl/render.ts compileHelpers) blew the heap in seconds, aborted
|
|
28
|
+
// silently, and crash-looped on every render tick while the backstop — a 60 s
|
|
29
|
+
// poll — never got a turn. Raising the env override raises BOTH, because both
|
|
30
|
+
// spawners derive the cap through heapCapMb below. The Rust client mirrors
|
|
31
|
+
// RSS_LIMIT_ENV, DEFAULT_RSS_LIMIT_MB, and HEAP_CAP_OVER_RSS as literals
|
|
32
|
+
// (rust-client/src/launch.rs); scripts/check-protocol.mjs fails the build on
|
|
33
|
+
// drift.
|
|
34
|
+
export const RSS_LIMIT_ENV = "CC_CANDYBAR_RSS_LIMIT_MB";
|
|
35
|
+
export const DEFAULT_RSS_LIMIT_MB = 512;
|
|
36
|
+
export const HEAP_CAP_OVER_RSS = 2;
|
|
37
|
+
|
|
38
|
+
// [LAW:parse-dont-validate] Absent → default; a positive integer → that; present
|
|
39
|
+
// but malformed → throw. Only an operator ever sets this variable, so garbage
|
|
40
|
+
// is an operator error, and `|| default` would silently run at a budget they
|
|
41
|
+
// did not ask for. [LAW:no-silent-failure]
|
|
42
|
+
//
|
|
43
|
+
// [LAW:one-source-of-truth] The grammar is ONE rule both runtimes apply
|
|
44
|
+
// verbatim — ASCII digits only, > 0, within the safe-integer range —
|
|
45
|
+
// so the spawner and the daemon it spawns accept and reject the same values
|
|
46
|
+
// (rust-client/src/launch.rs heap_cap_mb). A grammar that differed by so much
|
|
47
|
+
// as a leading `+` would let a client spawn a daemon that refuses to boot.
|
|
48
|
+
export function rssLimitMb(env: NodeJS.ProcessEnv): number {
|
|
49
|
+
const raw = env[RSS_LIMIT_ENV];
|
|
50
|
+
if (raw === undefined) return DEFAULT_RSS_LIMIT_MB;
|
|
51
|
+
const mb = /^\d+$/.test(raw) ? Number(raw) : NaN;
|
|
52
|
+
if (!Number.isSafeInteger(mb) || mb <= 0) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${RSS_LIMIT_ENV} must be a positive integer (MB), got ${JSON.stringify(raw)}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return mb;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The `--max-old-space-size` value a spawner hands node for the daemon.
|
|
61
|
+
export function heapCapMb(env: NodeJS.ProcessEnv): number {
|
|
62
|
+
return rssLimitMb(env) * HEAP_CAP_OVER_RSS;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
66
|
+
|
|
67
|
+
// The budget in the unit `process.memoryUsage().rss` reports.
|
|
68
|
+
export function rssLimitBytes(env: NodeJS.ProcessEnv): number {
|
|
69
|
+
return rssLimitMb(env) * BYTES_PER_MB;
|
|
70
|
+
}
|
|
71
|
+
|
|
15
72
|
const DEFAULT_CHECK_INTERVAL = 60 * 1000;
|
|
16
73
|
const HEAP_SNAPSHOT_KEEP = 3;
|
|
17
74
|
|
|
@@ -42,7 +99,7 @@ export interface LimitsHandle {
|
|
|
42
99
|
}
|
|
43
100
|
|
|
44
101
|
export function makeLimits(deps: LimitsDeps): LimitsHandle {
|
|
45
|
-
const rssLimit = deps.rssLimitBytes ??
|
|
102
|
+
const rssLimit = deps.rssLimitBytes ?? DEFAULT_RSS_LIMIT_MB * BYTES_PER_MB;
|
|
46
103
|
const keep = deps.snapshotsKeep ?? HEAP_SNAPSHOT_KEEP;
|
|
47
104
|
let triggered = false;
|
|
48
105
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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:
|
|
53
|
+
export function dlog(level: LogLevel, msg: string): void {
|
|
56
54
|
const line = `${new Date().toISOString()} [${level}] ${msg}\n`;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
}
|
package/src/daemon/server.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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/dsl/render.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { RichText, Palette, ThemeKey } from "@promptctl/rich-js";
|
|
15
15
|
import { ColorSpec, Style, lighten, IDENTITY } from "@promptctl/rich-js";
|
|
16
|
-
import type
|
|
16
|
+
import { Defines, type Engine, type Template } from "@promptctl/go-template-js";
|
|
17
17
|
import type {
|
|
18
18
|
ValidatedConfig,
|
|
19
19
|
VariableDecl,
|
|
@@ -213,38 +213,40 @@ function declareOne(
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
-
// ───
|
|
216
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
217
217
|
|
|
218
218
|
// [LAW:single-enforcer] Compile the config's shared helper templates into ONE
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
|
|
219
|
+
// define set: each name→body is parsed as its own `{{ define "name" }}body{{ end }}`
|
|
220
|
+
// unit, chained onto the previous helpers' set, so the result is one `Defines`
|
|
221
|
+
// every template this config parses inherits (`engine.parse(src, helpers)`).
|
|
222
|
+
// [LAW:one-source-of-truth] Inherited by link, never by copy. The previous
|
|
223
|
+
// shape — the defines' SOURCE prepended to every parse — re-parsed the whole
|
|
224
|
+
// helper block into every template: ~100 KB of AST per parse for the 2 KB
|
|
225
|
+
// stdlib block, ~287 parses per config, ~30 MB per config, and a daemon
|
|
226
|
+
// holding twenty configs sat at 600 MB of nothing but duplicated helper ASTs
|
|
227
|
+
// (the 2026-09-02 RSS-breach snapshot and the 2026-09-03 crash-loop).
|
|
228
|
+
// [LAW:no-silent-fallbacks] Each body is parsed in ISOLATION, so a malformed
|
|
229
|
+
// helper surfaces a per-helper diagnostic rather than a confusing error blamed
|
|
230
|
+
// on the first segment that happens to call it; the redefinition check the
|
|
231
|
+
// parser applies against the inherited set is what makes helper names unique.
|
|
232
|
+
function compileHelpers(
|
|
231
233
|
engine: Engine<RichText>,
|
|
232
234
|
helpers: Readonly<Record<string, string>>,
|
|
233
|
-
):
|
|
234
|
-
let
|
|
235
|
+
): Defines {
|
|
236
|
+
let defines = Defines.EMPTY;
|
|
235
237
|
for (const [name, body] of Object.entries(helpers)) {
|
|
236
|
-
const define = `{{ define "${name}" }}${body}{{ end }}`;
|
|
237
238
|
try {
|
|
238
|
-
engine
|
|
239
|
+
defines = engine
|
|
240
|
+
.parse(`{{ define "${name}" }}${body}{{ end }}`, defines)
|
|
241
|
+
.defines();
|
|
239
242
|
} catch (e) {
|
|
240
243
|
throw new Error(
|
|
241
244
|
`Template parse error in helpers.${name}: ${(e as Error).message}`,
|
|
242
245
|
{ cause: e },
|
|
243
246
|
);
|
|
244
247
|
}
|
|
245
|
-
preamble += define;
|
|
246
248
|
}
|
|
247
|
-
return
|
|
249
|
+
return defines;
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
// ─── registerDslConfig ────────────────────────────────────────────────────────
|
|
@@ -379,16 +381,14 @@ export function registerDslConfig(
|
|
|
379
381
|
},
|
|
380
382
|
opts?.clock,
|
|
381
383
|
);
|
|
382
|
-
// [LAW:single-enforcer] THE one parse path for this config:
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
// shared
|
|
386
|
-
// there is exactly one boundary where helpers come into scope (and one place
|
|
387
|
-
// helper could fail to be visible). The
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
const parse = (src: string): Template<RichText> =>
|
|
391
|
-
engine.parse(helperPreamble + src);
|
|
384
|
+
// [LAW:single-enforcer] THE one parse path for this config: every template —
|
|
385
|
+
// segment template/when/bg/fg, node `when`, and action copy/open — inherits
|
|
386
|
+
// the same helper define set, so `{{ template "name" }}` resolves against one
|
|
387
|
+
// shared AST. One closure, not raw engine.parse scattered across sites, so
|
|
388
|
+
// there is exactly one boundary where helpers come into scope (and one place
|
|
389
|
+
// a helper could fail to be visible). The helpers are parsed ONCE here.
|
|
390
|
+
const helpers = compileHelpers(engine, config.helpers);
|
|
391
|
+
const parse = (src: string): Template<RichText> => engine.parse(src, helpers);
|
|
392
392
|
// [LAW:one-source-of-truth] Map each SessionState key → the variable that
|
|
393
393
|
// reads it, so an option picker marks its current selection by reading the
|
|
394
394
|
// SAME value the templates read — independent of whether the config named the
|