@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import tty from "node:tty";
|
|
3
|
+
|
|
4
|
+
export function getColorSupport(): "none" | "ansi" | "ansi256" | "truecolor" {
|
|
5
|
+
const { env } = process;
|
|
6
|
+
|
|
7
|
+
let colorEnabled = true;
|
|
8
|
+
|
|
9
|
+
if (env.NO_COLOR && env.NO_COLOR !== "") {
|
|
10
|
+
colorEnabled = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const forceColor = env.FORCE_COLOR;
|
|
14
|
+
if (forceColor && forceColor !== "") {
|
|
15
|
+
if (forceColor === "false" || forceColor === "0") {
|
|
16
|
+
return "none";
|
|
17
|
+
}
|
|
18
|
+
if (forceColor === "true" || forceColor === "1") {
|
|
19
|
+
return "ansi";
|
|
20
|
+
}
|
|
21
|
+
if (forceColor === "2") {
|
|
22
|
+
return "ansi256";
|
|
23
|
+
}
|
|
24
|
+
if (forceColor === "3") {
|
|
25
|
+
return "truecolor";
|
|
26
|
+
}
|
|
27
|
+
return "ansi";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!colorEnabled) {
|
|
31
|
+
return "none";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (env.TERM === "dumb") {
|
|
35
|
+
return "none";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (env.CI) {
|
|
39
|
+
if (
|
|
40
|
+
["GITHUB_ACTIONS", "GITEA_ACTIONS", "CIRCLECI"].some((key) => key in env)
|
|
41
|
+
) {
|
|
42
|
+
return "truecolor";
|
|
43
|
+
}
|
|
44
|
+
return "ansi";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (env.COLORTERM === "truecolor") {
|
|
48
|
+
return "truecolor";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const truecolorTerminals = [
|
|
52
|
+
"xterm-kitty",
|
|
53
|
+
"xterm-ghostty",
|
|
54
|
+
"wezterm",
|
|
55
|
+
"alacritty",
|
|
56
|
+
"foot",
|
|
57
|
+
"contour",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
if (truecolorTerminals.includes(env.TERM || "")) {
|
|
61
|
+
return "truecolor";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (env.TERM_PROGRAM) {
|
|
65
|
+
switch (env.TERM_PROGRAM) {
|
|
66
|
+
case "iTerm.app":
|
|
67
|
+
return "truecolor";
|
|
68
|
+
case "Apple_Terminal":
|
|
69
|
+
return "ansi256";
|
|
70
|
+
case "vscode":
|
|
71
|
+
return "truecolor";
|
|
72
|
+
case "Tabby":
|
|
73
|
+
return "truecolor";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/-256(color)?$/i.test(env.TERM || "")) {
|
|
78
|
+
return "ansi256";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (/-truecolor$/i.test(env.TERM || "")) {
|
|
82
|
+
return "truecolor";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(
|
|
87
|
+
env.TERM || "",
|
|
88
|
+
)
|
|
89
|
+
) {
|
|
90
|
+
return "ansi";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (env.COLORTERM) {
|
|
94
|
+
return "ansi";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (tty?.WriteStream?.prototype?.hasColors) {
|
|
98
|
+
try {
|
|
99
|
+
const colors = tty.WriteStream.prototype.hasColors();
|
|
100
|
+
if (!colors) {
|
|
101
|
+
return "none";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const has256Colors = tty.WriteStream.prototype.hasColors(256);
|
|
105
|
+
const has16mColors = tty.WriteStream.prototype.hasColors(16777216);
|
|
106
|
+
|
|
107
|
+
if (has16mColors) {
|
|
108
|
+
return "truecolor";
|
|
109
|
+
} else if (has256Colors) {
|
|
110
|
+
return "ansi256";
|
|
111
|
+
} else {
|
|
112
|
+
return "ansi";
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return "ansi";
|
|
118
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const CLAUDE_MODEL_PATTERN =
|
|
2
|
+
/^(?:(?:global|apac|au|eu|us|us-east-\d|us-west-\d|eu-west-\d|eu-central-\d)\.)?(?:anthropic\.|azure_ai\/|bedrock\/|vertex_ai\/)?claude-(?:(?<family>opus|sonnet|haiku)-(?<newMajor>\d+)(?:-(?<newMinor>\d))?|(?<oldMajor>\d+)(?:-(?<oldMinor>\d))?-(?<oldFamily>opus|sonnet|haiku))(?:[-@]\d{8})?(?:-v\d+:\d+)?(?:-latest)?$/i;
|
|
3
|
+
|
|
4
|
+
const FRIENDLY_MODEL_PATTERN =
|
|
5
|
+
/^(?<family>opus|sonnet|haiku)\s+(?<major>\d+)(?:\.(?<minor>\d))?$/i;
|
|
6
|
+
|
|
7
|
+
export function formatModelName(rawName: string): string {
|
|
8
|
+
if (!rawName) {
|
|
9
|
+
return "Claude";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// [LAW:one-source-of-truth] strip variant decorations (e.g. " (1M context)",
|
|
13
|
+
// "[1m]") so all callers see canonical "Family X.Y" output regardless of
|
|
14
|
+
// whether the input came from model.id or model.display_name.
|
|
15
|
+
const stripped = rawName
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/\s*\([^)]*\)\s*$/, "")
|
|
18
|
+
.replace(/\s*\[[^\]]*\]\s*$/, "")
|
|
19
|
+
.trim();
|
|
20
|
+
|
|
21
|
+
const match = stripped.match(CLAUDE_MODEL_PATTERN);
|
|
22
|
+
if (match?.groups) {
|
|
23
|
+
const { family, newMajor, newMinor, oldMajor, oldMinor, oldFamily } =
|
|
24
|
+
match.groups;
|
|
25
|
+
|
|
26
|
+
const modelFamily = family || oldFamily;
|
|
27
|
+
const major = newMajor || oldMajor;
|
|
28
|
+
const minor = newMinor || oldMinor;
|
|
29
|
+
|
|
30
|
+
if (modelFamily && major) {
|
|
31
|
+
const capitalizedFamily =
|
|
32
|
+
modelFamily.charAt(0).toUpperCase() +
|
|
33
|
+
modelFamily.slice(1).toLowerCase();
|
|
34
|
+
const version = minor ? `${major}.${minor}` : major;
|
|
35
|
+
return `${capitalizedFamily} ${version}`;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const friendly = stripped.match(FRIENDLY_MODEL_PATTERN);
|
|
40
|
+
if (friendly?.groups) {
|
|
41
|
+
const family = friendly.groups.family!;
|
|
42
|
+
const major = friendly.groups.major!;
|
|
43
|
+
const minor = friendly.groups.minor;
|
|
44
|
+
const capitalizedFamily =
|
|
45
|
+
family.charAt(0).toUpperCase() + family.slice(1).toLowerCase();
|
|
46
|
+
const version = minor ? `${major}.${minor}` : major;
|
|
47
|
+
return `${capitalizedFamily} ${version}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return stripped || rawName;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function shortenModelName(formatted: string): string {
|
|
54
|
+
// [LAW:one-type-per-behavior] same parser, different rendering — operates on
|
|
55
|
+
// the canonical output of formatModelName so callers don't reparse raw IDs.
|
|
56
|
+
const match = formatted.match(FRIENDLY_MODEL_PATTERN);
|
|
57
|
+
if (!match?.groups) return formatted;
|
|
58
|
+
const family = match.groups.family!;
|
|
59
|
+
const major = match.groups.major!;
|
|
60
|
+
const minor = match.groups.minor;
|
|
61
|
+
const initial = family.charAt(0).toUpperCase();
|
|
62
|
+
const version = minor ? `${major}.${minor}` : major;
|
|
63
|
+
return `${initial}${version}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// [LAW:one-source-of-truth] Locale-grouped integer rendering. Callers that
|
|
67
|
+
// want "50,000" instead of "50000" go through this rather than calling
|
|
68
|
+
// toLocaleString() ad-hoc — the legacy context segment used the latter
|
|
69
|
+
// pattern inline, and the DSL formatter (template-engine/funcs.ts)
|
|
70
|
+
// delegates here so the two producers agree by construction.
|
|
71
|
+
//
|
|
72
|
+
// No locale argument: the default-locale behaviour is exactly what the
|
|
73
|
+
// legacy renderer did (`n.toLocaleString()`), so byte-parity holds with
|
|
74
|
+
// whatever locale the host process picks at startup.
|
|
75
|
+
export function formatInteger(n: number): string {
|
|
76
|
+
return n.toLocaleString();
|
|
77
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The provider-outcome vocabulary: a data fetch
|
|
2
|
+
// either produced a value, found that the domain genuinely has none (no
|
|
3
|
+
// upstream configured, no transcript yet), or failed to get an answer at all
|
|
4
|
+
// (timeout, spawn error, unreadable file). Before this type, the third state
|
|
5
|
+
// was unrepresentable — failure had to wear a real value's clothes (0, "",
|
|
6
|
+
// a basename) and the lie flowed all the way to the rendered bar.
|
|
7
|
+
//
|
|
8
|
+
// [LAW:dataflow-not-control-flow] Failure is a value that flows to a
|
|
9
|
+
// boundary, not a swallowed branch. Producers classify once, where the
|
|
10
|
+
// command semantics are known; consumers fold once, at the edge where the
|
|
11
|
+
// log effect and the payload mapping live.
|
|
12
|
+
export type Outcome<T> =
|
|
13
|
+
| { readonly kind: "ok"; readonly value: T }
|
|
14
|
+
| { readonly kind: "absent" }
|
|
15
|
+
| { readonly kind: "failed"; readonly reason: string };
|
|
16
|
+
|
|
17
|
+
export function ok<T>(value: T): Outcome<T> {
|
|
18
|
+
return { kind: "ok", value };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ABSENT: Outcome<never> = { kind: "absent" };
|
|
22
|
+
|
|
23
|
+
export function failed(reason: string): Outcome<never> {
|
|
24
|
+
return { kind: "failed", reason };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// [LAW:dataflow-not-control-flow] Total fold for consumers that only need a
|
|
28
|
+
// value-or-fallback view (e.g. var-system's typed projection). Both non-ok
|
|
29
|
+
// arms collapse to the fallback; consumers that must distinguish absent from
|
|
30
|
+
// failed (the logging boundaries) match on `kind` instead.
|
|
31
|
+
export function orElse<T>(outcome: Outcome<T> | undefined, fallback: T): T {
|
|
32
|
+
return outcome?.kind === "ok" ? outcome.value : fallback;
|
|
33
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// [LAW:single-enforcer] All hookData schema validation flows through
|
|
2
|
+
// validateHookData. One trust boundary, one check.
|
|
3
|
+
//
|
|
4
|
+
// [LAW:dataflow-not-control-flow] Every check runs unconditionally. Results
|
|
5
|
+
// accumulate into a ValidationReport — callers decide what to do with them.
|
|
6
|
+
// No early exits, no control-flow branches that skip checks.
|
|
7
|
+
|
|
8
|
+
import type { ClaudeHookData } from "./claude";
|
|
9
|
+
|
|
10
|
+
export interface ValidationReport {
|
|
11
|
+
// Required fields that were absent or had wrong types.
|
|
12
|
+
missingRequired: string[];
|
|
13
|
+
typeMismatches: Array<{ path: string; expected: string; got: string }>;
|
|
14
|
+
// Top-level keys not in the known schema — Anthropic may have added new fields.
|
|
15
|
+
unknownTopLevelFields: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Top-level keys Anthropic sends (plus hook_event_name, which cc-candybar adds).
|
|
19
|
+
// Adding a new Anthropic field here suppresses the "unknown field" log for it.
|
|
20
|
+
const KNOWN_TOP_LEVEL = new Set([
|
|
21
|
+
"hook_event_name", // cc-candybar internal
|
|
22
|
+
"session_id",
|
|
23
|
+
"session_name",
|
|
24
|
+
"transcript_path",
|
|
25
|
+
"cwd",
|
|
26
|
+
"model",
|
|
27
|
+
"workspace",
|
|
28
|
+
"version",
|
|
29
|
+
"output_style",
|
|
30
|
+
"cost",
|
|
31
|
+
"context_window",
|
|
32
|
+
"exceeds_200k_tokens",
|
|
33
|
+
"effort",
|
|
34
|
+
"thinking",
|
|
35
|
+
"rate_limits",
|
|
36
|
+
"vim",
|
|
37
|
+
"agent",
|
|
38
|
+
"worktree",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// Required fields: [dot-separated path, expected typeof result]
|
|
42
|
+
// "object" means non-null, non-array object. Checked in declaration order.
|
|
43
|
+
const REQUIRED_FIELDS: Array<
|
|
44
|
+
[string, "string" | "number" | "boolean" | "object"]
|
|
45
|
+
> = [
|
|
46
|
+
["session_id", "string"],
|
|
47
|
+
["transcript_path", "string"],
|
|
48
|
+
["cwd", "string"],
|
|
49
|
+
["model", "object"],
|
|
50
|
+
["model.id", "string"],
|
|
51
|
+
["model.display_name", "string"],
|
|
52
|
+
["workspace", "object"],
|
|
53
|
+
["workspace.current_dir", "string"],
|
|
54
|
+
["workspace.project_dir", "string"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate raw hookData received over the wire against the known Anthropic schema.
|
|
59
|
+
*
|
|
60
|
+
* Returns the data typed as ClaudeHookData alongside a ValidationReport.
|
|
61
|
+
* Never throws — divergences are reported, not thrown. The daemon decides
|
|
62
|
+
* how to surface them (dlog warn/info).
|
|
63
|
+
*
|
|
64
|
+
* [LAW:no-defensive-null-guards] Validation at the trust boundary is correct.
|
|
65
|
+
* Everywhere else in the codebase, hookData fields are used without guards
|
|
66
|
+
* because this boundary guarantees their presence.
|
|
67
|
+
*/
|
|
68
|
+
export function validateHookData(raw: unknown): {
|
|
69
|
+
data: ClaudeHookData;
|
|
70
|
+
report: ValidationReport;
|
|
71
|
+
} {
|
|
72
|
+
const report: ValidationReport = {
|
|
73
|
+
missingRequired: [],
|
|
74
|
+
typeMismatches: [],
|
|
75
|
+
unknownTopLevelFields: [],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const obj: Record<string, unknown> =
|
|
79
|
+
raw !== null && typeof raw === "object" && !Array.isArray(raw)
|
|
80
|
+
? (raw as Record<string, unknown>)
|
|
81
|
+
: {};
|
|
82
|
+
|
|
83
|
+
for (const [path, expectedType] of REQUIRED_FIELDS) {
|
|
84
|
+
const value = resolvePath(obj, path);
|
|
85
|
+
if (value === undefined || value === null) {
|
|
86
|
+
report.missingRequired.push(path);
|
|
87
|
+
} else {
|
|
88
|
+
const actualType = Array.isArray(value) ? "array" : typeof value;
|
|
89
|
+
const mismatch =
|
|
90
|
+
expectedType === "object"
|
|
91
|
+
? actualType !== "object"
|
|
92
|
+
: actualType !== expectedType;
|
|
93
|
+
if (mismatch) {
|
|
94
|
+
report.typeMismatches.push({
|
|
95
|
+
path,
|
|
96
|
+
expected: expectedType,
|
|
97
|
+
got: actualType,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const key of Object.keys(obj)) {
|
|
104
|
+
if (!KNOWN_TOP_LEVEL.has(key)) {
|
|
105
|
+
report.unknownTopLevelFields.push(key);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { data: raw as ClaudeHookData, report };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolvePath(obj: Record<string, unknown>, dotPath: string): unknown {
|
|
113
|
+
let cur: unknown = obj;
|
|
114
|
+
for (const key of dotPath.split(".")) {
|
|
115
|
+
if (
|
|
116
|
+
cur === null ||
|
|
117
|
+
cur === undefined ||
|
|
118
|
+
typeof cur !== "object" ||
|
|
119
|
+
Array.isArray(cur)
|
|
120
|
+
) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
cur = (cur as Record<string, unknown>)[key];
|
|
124
|
+
}
|
|
125
|
+
return cur;
|
|
126
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] A keyed in-flight coalescer: while a computation
|
|
2
|
+
// for a given key is running, every concurrent caller for that same key shares
|
|
3
|
+
// the ONE in-flight promise instead of starting its own. The fs gate
|
|
4
|
+
// (transcript-fs.ts) bounds how many fs ops run at once; it does NOT dedupe the
|
|
5
|
+
// work — K concurrent renders each still launch their own (now-bounded)
|
|
6
|
+
// whole-tree scan. This is the missing piece: K renders trigger ONE scan, not
|
|
7
|
+
// K. The gate makes that one scan's cost bounded; this makes there be one.
|
|
8
|
+
//
|
|
9
|
+
// [LAW:types-are-the-program] Coalescing is expressed by ROUTING through one
|
|
10
|
+
// owner of "is this key already computing", not by a guard scattered at each
|
|
11
|
+
// callsite. The selection is dataflow — `inflight.get(key) ?? start(...)` —
|
|
12
|
+
// both arms yield a `Promise<T>`, so the operation (return the shared promise)
|
|
13
|
+
// always runs; only the value varies. There is no `if (alreadyRunning) return`
|
|
14
|
+
// branch that skips work.
|
|
15
|
+
//
|
|
16
|
+
// Scope of sharing is exactly the in-flight WINDOW: the entry is removed when
|
|
17
|
+
// the promise settles (success OR failure), so this is a coalescer, never a
|
|
18
|
+
// cache. A fresh call after completion starts a new computation — staleness is
|
|
19
|
+
// impossible because nothing is retained past settle. Result caching, when
|
|
20
|
+
// wanted, is a separate concern owned by the caller (the disk/LRU caches).
|
|
21
|
+
//
|
|
22
|
+
// This also dissolves the render-timeout orphaning problem
|
|
23
|
+
// (brandon-daemon-memory-leak-gn4.3): a render that abandons its await (the
|
|
24
|
+
// daemon's 200ms response timeout fires) does not cancel or duplicate the
|
|
25
|
+
// shared computation — there is only ever one scan in flight per key, so the
|
|
26
|
+
// timed-out render leaves behind the single canonical computation that the next
|
|
27
|
+
// render coalesces onto. A timeout therefore adds zero new fs work.
|
|
28
|
+
export class SingleFlight {
|
|
29
|
+
private readonly inflight = new Map<string, Promise<unknown>>();
|
|
30
|
+
|
|
31
|
+
run<T>(key: string, factory: () => Promise<T>): Promise<T> {
|
|
32
|
+
return (
|
|
33
|
+
(this.inflight.get(key) as Promise<T> | undefined) ??
|
|
34
|
+
this.start(key, factory)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private start<T>(key: string, factory: () => Promise<T>): Promise<T> {
|
|
39
|
+
const promise = factory();
|
|
40
|
+
this.inflight.set(key, promise);
|
|
41
|
+
// Deregister on settle. The identity check guards the (impossible-by-key
|
|
42
|
+
// but cheap-to-prove) case where a newer promise has replaced this one:
|
|
43
|
+
// only the promise that registered itself clears itself.
|
|
44
|
+
const deregister = (): void => {
|
|
45
|
+
if (this.inflight.get(key) === promise) this.inflight.delete(key);
|
|
46
|
+
};
|
|
47
|
+
promise.then(deregister, deregister);
|
|
48
|
+
return promise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Number of computations currently in flight — a read-only observability
|
|
52
|
+
// surface for tests asserting the coalescing contract (one in-flight entry
|
|
53
|
+
// per key). Never read as control flow.
|
|
54
|
+
get size(): number {
|
|
55
|
+
return this.inflight.size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// [LAW:single-enforcer] Terminal width has exactly one authoritative source:
|
|
2
|
+
// the live client's shell context (env / ioctl), captured at the wire boundary
|
|
3
|
+
// and threaded through as request data. This module is a pure resolver from
|
|
4
|
+
// (caller-supplied hint, ambient env, stderr TTY) to "width with reserve
|
|
5
|
+
// applied, or null." Subprocess-based fallbacks belong at the wire boundary,
|
|
6
|
+
// not here. stderr (not stdout) is the TTY-side fallback: when this resolver
|
|
7
|
+
// runs in a hook context, stdout is the captured statusline pipe while stderr
|
|
8
|
+
// stays attached to the parent terminal.
|
|
9
|
+
//
|
|
10
|
+
// [LAW:dataflow-not-control-flow] The function always runs the same code path.
|
|
11
|
+
// Variability lives in the inputs (hint set or not, env set or not, stderr a
|
|
12
|
+
// TTY or not), never in whether work runs.
|
|
13
|
+
|
|
14
|
+
// @info Reserves characters for Claude Code's right-side UI messages
|
|
15
|
+
// (e.g., "Current: 2.1.78 · latest: 2.1.78", "Thinking off")
|
|
16
|
+
const RESERVED_CHARS = 45;
|
|
17
|
+
|
|
18
|
+
// [LAW:single-enforcer] The canonical raw-cols → usable-cols transform.
|
|
19
|
+
// Every consumer that needs to honor Claude Code's overlay routes through
|
|
20
|
+
// here; there is no parallel `cols - 45` math anywhere. Exposed so callers
|
|
21
|
+
// that already have a raw width (e.g. the daemon's wire-fallback path,
|
|
22
|
+
// the demo reading process.stdout.columns) can apply the reserve without
|
|
23
|
+
// re-entering the env/stderr resolution chain in getTerminalWidth.
|
|
24
|
+
export function applyClaudeCodeReserve(rawCols: number): number {
|
|
25
|
+
return Math.max(1, rawCols - RESERVED_CHARS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getTerminalWidth(termColsHint?: number): number | null {
|
|
29
|
+
if (termColsHint && termColsHint > 0)
|
|
30
|
+
return applyClaudeCodeReserve(termColsHint);
|
|
31
|
+
|
|
32
|
+
const envColumns = process.env.COLUMNS;
|
|
33
|
+
if (envColumns) {
|
|
34
|
+
const parsed = parseInt(envColumns, 10);
|
|
35
|
+
if (!isNaN(parsed) && parsed > 0) return applyClaudeCodeReserve(parsed);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (process.stderr.columns && process.stderr.columns > 0) {
|
|
39
|
+
return applyClaudeCodeReserve(process.stderr.columns);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const ESC = String.fromCharCode(27);
|
|
2
|
+
const ANSI_REGEX = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
|
|
3
|
+
export const ANSI_SPLIT = new RegExp(`(${ESC}\\[[0-9;]*m)`);
|
|
4
|
+
|
|
5
|
+
export function stripAnsi(str: string): string {
|
|
6
|
+
return str.replace(ANSI_REGEX, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function visibleLength(str: string): number {
|
|
10
|
+
return stripAnsi(str).length;
|
|
11
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
open as fsOpen,
|
|
3
|
+
readdir as fsReaddir,
|
|
4
|
+
readFile as fsReadFile,
|
|
5
|
+
stat as fsStat,
|
|
6
|
+
} from "node:fs/promises";
|
|
7
|
+
import type { FileHandle } from "node:fs/promises";
|
|
8
|
+
|
|
9
|
+
import { ABSENT, failed, ok, type Outcome } from "./outcome";
|
|
10
|
+
|
|
11
|
+
// [LAW:single-enforcer] One owner of the transcript-scanning in-flight-I/O
|
|
12
|
+
// budget. Every readdir/stat/readFile over the ~/.claude/projects tree passes
|
|
13
|
+
// through this module's limiter, so the number of concurrent libuv fs requests
|
|
14
|
+
// is bounded by a constant no matter how many renders fan out at once. The OOM
|
|
15
|
+
// heap proved the illegal state this forbids: ~3046 FSReqPromise pending at
|
|
16
|
+
// once, each pinning a parked await-stack.
|
|
17
|
+
//
|
|
18
|
+
// [LAW:types-are-the-program] The bound is enforced by ROUTING, not by a
|
|
19
|
+
// post-hoc guard: the transcript path imports these gated primitives instead of
|
|
20
|
+
// node:fs/promises, so "thousands of stats/reads in flight" is unrepresentable
|
|
21
|
+
// rather than merely checked. There is no `if (tooMany)` anywhere — the same
|
|
22
|
+
// fan-out runs every render; the limiter only decides *when* each op dispatches.
|
|
23
|
+
|
|
24
|
+
// 8 = 2× the libuv default UV_THREADPOOL_SIZE (4). Two dispatched ops per worker
|
|
25
|
+
// keeps every threadpool thread fed without a queue-drain stall between syscalls,
|
|
26
|
+
// while peak in-flight memory stays O(threadpool) rather than O(transcript count).
|
|
27
|
+
const TRANSCRIPT_FS_CONCURRENCY = 8;
|
|
28
|
+
|
|
29
|
+
interface Waiter {
|
|
30
|
+
readonly wake: () => void;
|
|
31
|
+
next: Waiter | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A counting semaphore that runs at most `max` thunks concurrently. Slots are
|
|
35
|
+
// handed off directly to the next waiter on release (never incremented while a
|
|
36
|
+
// waiter is parked), so admission can never exceed `max` — the over-admission
|
|
37
|
+
// race of an increment-then-wake design is structurally absent.
|
|
38
|
+
class Limiter {
|
|
39
|
+
private slots: number;
|
|
40
|
+
// FIFO wait queue as a singly-linked list: enqueue at `tail`, dequeue at
|
|
41
|
+
// `head`, both O(1) with no array reindexing (Array.shift would be O(n), so a
|
|
42
|
+
// drain of the thousands-of-queued-ops burst this limiter exists to absorb
|
|
43
|
+
// would be O(n²) on the render hot path). A dequeued node is immediately
|
|
44
|
+
// unreferenced, so a continuously-saturated queue retains only the waiters
|
|
45
|
+
// currently parked — no consumed-prefix accumulates, unlike a head-index
|
|
46
|
+
// array that only reclaims on full drain.
|
|
47
|
+
private head: Waiter | null = null;
|
|
48
|
+
private tail: Waiter | null = null;
|
|
49
|
+
|
|
50
|
+
constructor(max: number) {
|
|
51
|
+
this.slots = max;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async run<T>(task: () => Promise<T>): Promise<T> {
|
|
55
|
+
await this.acquire();
|
|
56
|
+
try {
|
|
57
|
+
return await task();
|
|
58
|
+
} finally {
|
|
59
|
+
this.release();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async acquire(): Promise<void> {
|
|
64
|
+
if (this.slots > 0) {
|
|
65
|
+
this.slots--;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// No slot free — park until release() hands one to us. The slot is
|
|
69
|
+
// transferred directly, so we must NOT decrement again on resume.
|
|
70
|
+
await new Promise<void>((wake) => {
|
|
71
|
+
const node: Waiter = { wake, next: null };
|
|
72
|
+
if (this.tail) this.tail.next = node;
|
|
73
|
+
else this.head = node;
|
|
74
|
+
this.tail = node;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private release(): void {
|
|
79
|
+
const node = this.head;
|
|
80
|
+
if (!node) {
|
|
81
|
+
// No one waiting — return the slot to the pool.
|
|
82
|
+
this.slots++;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
// Hand the slot directly to the next waiter; the dequeued node is dropped.
|
|
86
|
+
this.head = node.next;
|
|
87
|
+
if (!this.head) this.tail = null;
|
|
88
|
+
node.wake();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const gate = new Limiter(TRANSCRIPT_FS_CONCURRENCY);
|
|
93
|
+
|
|
94
|
+
// Wrap an async fs primitive so every call flows through the shared gate. The
|
|
95
|
+
// `(...args: never[])` bound is the maximally-permissive function constraint
|
|
96
|
+
// (parameters are contravariant, so `never[]` accepts any arg list) — it admits
|
|
97
|
+
// every fs/promises overload, not rejects them. Callers see the original
|
|
98
|
+
// overloaded type `F`; the cast is needed because TS can't prove a generic
|
|
99
|
+
// wrapper preserves an overload set, but each fs overload is a valid `fn(...)`
|
|
100
|
+
// call, so the wrap is sound (verified: `tsc --noEmit` passes against the
|
|
101
|
+
// multi-overload call sites in claude.ts/cache.ts).
|
|
102
|
+
function gated<F extends (...args: never[]) => Promise<unknown>>(fn: F): F {
|
|
103
|
+
return ((...args: Parameters<F>) =>
|
|
104
|
+
gate.run(() => fn(...args))) as unknown as F;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const readdir = gated(fsReaddir);
|
|
108
|
+
export const readFile = gated(fsReadFile);
|
|
109
|
+
export const stat = gated(fsStat);
|
|
110
|
+
|
|
111
|
+
// [LAW:single-enforcer] A bounded tail read through the SAME gate: the last
|
|
112
|
+
// `maxBytes` of a file (the whole file when smaller), plus whether that window
|
|
113
|
+
// reaches the file start. The open→stat→read→close runs under one gate slot, so
|
|
114
|
+
// a tail read counts as one in-flight op exactly like a readFile — a transcript
|
|
115
|
+
// scanner that grows its window backward must use THIS, not raw node:fs, or it
|
|
116
|
+
// reintroduces the unbounded-fs state the gate forbids.
|
|
117
|
+
//
|
|
118
|
+
// [LAW:no-silent-failure] The file not existing is the expected, every-render
|
|
119
|
+
// case for a fresh session (no transcript yet) — `absent`. Any other error
|
|
120
|
+
// (permissions, I/O) is a real read failure — `failed`, carrying its reason
|
|
121
|
+
// to whichever boundary owns the log effect. Folding both into one null made
|
|
122
|
+
// a broken transcript indistinguishable from a missing one.
|
|
123
|
+
export async function readTail(
|
|
124
|
+
path: string,
|
|
125
|
+
maxBytes: number,
|
|
126
|
+
): Promise<Outcome<{ buf: Buffer; fromStart: boolean }>> {
|
|
127
|
+
return gate.run(async () => {
|
|
128
|
+
let fh: FileHandle | null = null;
|
|
129
|
+
try {
|
|
130
|
+
fh = await fsOpen(path, "r");
|
|
131
|
+
const { size } = await fh.stat();
|
|
132
|
+
const start = Math.max(0, size - maxBytes);
|
|
133
|
+
const buf = Buffer.alloc(size - start);
|
|
134
|
+
// [LAW:no-silent-fallbacks] A single read may return short — the scanner
|
|
135
|
+
// would then parse a zero-padded tail and miss cache activity. Loop until
|
|
136
|
+
// the window is filled or EOF; on a short final read (the file shrank
|
|
137
|
+
// under us) return only the bytes actually read, never the zero padding.
|
|
138
|
+
let off = 0;
|
|
139
|
+
while (off < buf.length) {
|
|
140
|
+
const { bytesRead } = await fh.read(
|
|
141
|
+
buf,
|
|
142
|
+
off,
|
|
143
|
+
buf.length - off,
|
|
144
|
+
start + off,
|
|
145
|
+
);
|
|
146
|
+
if (bytesRead === 0) break;
|
|
147
|
+
off += bytesRead;
|
|
148
|
+
}
|
|
149
|
+
return ok({
|
|
150
|
+
buf: off === buf.length ? buf : buf.subarray(0, off),
|
|
151
|
+
fromStart: start === 0,
|
|
152
|
+
});
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return ABSENT;
|
|
155
|
+
return failed(
|
|
156
|
+
`readTail ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
157
|
+
);
|
|
158
|
+
} finally {
|
|
159
|
+
await fh?.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { VariableStore, type VarNode } from "./store";
|
|
2
|
+
export {
|
|
3
|
+
type VarType,
|
|
4
|
+
type VarValue,
|
|
5
|
+
typeOf,
|
|
6
|
+
toString,
|
|
7
|
+
toNumber,
|
|
8
|
+
toBool,
|
|
9
|
+
} from "./types";
|
|
10
|
+
export {
|
|
11
|
+
SourceRegistry,
|
|
12
|
+
parseDuration,
|
|
13
|
+
formatGoTime,
|
|
14
|
+
MIN_SHELL_TTL_MS,
|
|
15
|
+
type CachePolicy,
|
|
16
|
+
type ShellOptions,
|
|
17
|
+
type FileOptions,
|
|
18
|
+
type TemplateOptions,
|
|
19
|
+
type TimeOptions,
|
|
20
|
+
type GitField,
|
|
21
|
+
type GitOptions,
|
|
22
|
+
type StateOptions,
|
|
23
|
+
type LastError,
|
|
24
|
+
} from "./sources";
|