@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,120 @@
|
|
|
1
|
+
// [LAW:single-enforcer] The `vars` / `segments` / `config` CLIs are ONE behavior
|
|
2
|
+
// — fetch a debug snapshot from the running daemon and render it — parameterized
|
|
3
|
+
// by `what`, not three commands. The daemon's `debug` protocol message is the
|
|
4
|
+
// single introspection authority (src/daemon/debug.ts produces DebugSnapshot);
|
|
5
|
+
// this is its client binding, the mirror of client-stats.ts. Like daemon-stats,
|
|
6
|
+
// it does NOT spawn a daemon: introspecting a dead daemon is meaningless.
|
|
7
|
+
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import { describeFailure, requestOutcome } from "./client-transport";
|
|
10
|
+
import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
|
|
11
|
+
import type {
|
|
12
|
+
DebugSnapshot,
|
|
13
|
+
DebugWhat,
|
|
14
|
+
SegmentSnapshot,
|
|
15
|
+
VarSnapshot,
|
|
16
|
+
} from "./debug-types";
|
|
17
|
+
import type { DslConfig } from "../config/dsl-types";
|
|
18
|
+
|
|
19
|
+
// Operator-driven introspection path: legitimately slower budgets than the
|
|
20
|
+
// render hot path, carried as this caller's values through the shared
|
|
21
|
+
// round-trip in ./client-transport. [LAW:dataflow-not-control-flow]
|
|
22
|
+
const BUDGETS: RoundTripBudgets = { connectMs: 200, budgetMs: 500 };
|
|
23
|
+
|
|
24
|
+
// `cc-candybar <vars|segments|config> [--json]` — `what` selects the projection.
|
|
25
|
+
export async function runDebug(
|
|
26
|
+
what: DebugWhat,
|
|
27
|
+
args: readonly string[],
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const wantJson = args.includes("--json");
|
|
30
|
+
|
|
31
|
+
const outcome = await fetchDebug(what);
|
|
32
|
+
if (outcome.kind !== "ok") {
|
|
33
|
+
process.stderr.write(`${what}: ${describeFailure(outcome)}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (wantJson) {
|
|
38
|
+
process.stdout.write(JSON.stringify(outcome.value, null, 2) + "\n");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.stdout.write(formatDebug(outcome.value));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fetchDebug(what: DebugWhat): Promise<RoundTripOutcome<DebugSnapshot>> {
|
|
46
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary. The response is
|
|
47
|
+
// an unchecked cast from socket JSON; the presence check is the explicit
|
|
48
|
+
// narrowing at the wire edge (an ok response without `debug` classifies as
|
|
49
|
+
// permanent/malformed_response in the transport).
|
|
50
|
+
return requestOutcome({ kind: "debug", what }, BUDGETS, (resp) =>
|
|
51
|
+
"debug" in resp ? resp.debug : undefined,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// [LAW:types-are-the-program] One total fold over the DebugSnapshot union; each
|
|
56
|
+
// arm renders its own shape. The switch is exhaustive (the `never` default makes
|
|
57
|
+
// a new `what` a compile error here), so the renderer can never fall out of
|
|
58
|
+
// lockstep with the protocol's `what` set — the projection is residue of the
|
|
59
|
+
// union, not a hand-maintained dispatch.
|
|
60
|
+
export function formatDebug(s: DebugSnapshot): string {
|
|
61
|
+
switch (s.what) {
|
|
62
|
+
case "vars":
|
|
63
|
+
return formatVars(s.vars);
|
|
64
|
+
case "segments":
|
|
65
|
+
return formatSegments(s.segments);
|
|
66
|
+
case "config":
|
|
67
|
+
return formatConfig(s.config);
|
|
68
|
+
default: {
|
|
69
|
+
const _exhaustive: never = s;
|
|
70
|
+
return _exhaustive;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formatVars(vars: readonly VarSnapshot[]): string {
|
|
76
|
+
if (vars.length === 0) return "no variables (DSL not active)\n";
|
|
77
|
+
const lines: string[] = [`variables (${vars.length})`, ``];
|
|
78
|
+
const nameW = vars.reduce((w, v) => Math.max(w, v.name.length), 4);
|
|
79
|
+
const srcW = vars.reduce((w, v) => Math.max(w, (v.source ?? "—").length), 6);
|
|
80
|
+
for (const v of vars) {
|
|
81
|
+
const age = v.ageMs === null ? "" : ` ${fmtAge(v.ageMs)}`;
|
|
82
|
+
const err = v.lastError ? ` ✗ ${v.lastError.message}` : "";
|
|
83
|
+
lines.push(
|
|
84
|
+
` ${v.name.padEnd(nameW)} ${(v.source ?? "—").padEnd(srcW)} ${v.type.padEnd(7)} ${fmtValue(v.value)}${age}${err}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return lines.join("\n") + "\n";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatSegments(segments: readonly SegmentSnapshot[]): string {
|
|
91
|
+
if (segments.length === 0) return "no segments (DSL not active)\n";
|
|
92
|
+
const lines: string[] = [`segments (${segments.length})`, ``];
|
|
93
|
+
for (const seg of segments) {
|
|
94
|
+
lines.push(` ${seg.name}`);
|
|
95
|
+
lines.push(` template ${seg.template}`);
|
|
96
|
+
if (seg.referencedVars.length > 0) {
|
|
97
|
+
lines.push(` vars ${seg.referencedVars.join(", ")}`);
|
|
98
|
+
}
|
|
99
|
+
if (seg.lastRender !== null) {
|
|
100
|
+
lines.push(` last ${seg.lastRender}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n") + "\n";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function formatConfig(config: DslConfig | null): string {
|
|
107
|
+
if (config === null) return "config: DSL not active\n";
|
|
108
|
+
return JSON.stringify(config, null, 2) + "\n";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fmtValue(v: unknown): string {
|
|
112
|
+
const s = typeof v === "string" ? v : JSON.stringify(v);
|
|
113
|
+
return s.length > 60 ? s.slice(0, 57) + "…" : s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fmtAge(ms: number): string {
|
|
117
|
+
if (ms < 1000) return `${ms}ms`;
|
|
118
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
119
|
+
return `${Math.floor(ms / 60_000)}m`;
|
|
120
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { describeFailure, requestOutcome } from "./client-transport";
|
|
3
|
+
import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
|
|
4
|
+
import type { StatsSnapshot } from "./stats";
|
|
5
|
+
|
|
6
|
+
// Operator-driven introspection path: legitimately slower budgets than the
|
|
7
|
+
// render hot path, carried as this caller's values through the shared
|
|
8
|
+
// round-trip in ./client-transport. [LAW:dataflow-not-control-flow]
|
|
9
|
+
const BUDGETS: RoundTripBudgets = { connectMs: 200, budgetMs: 500 };
|
|
10
|
+
|
|
11
|
+
// Query the running daemon for stats. Does NOT spawn a daemon — stats on a
|
|
12
|
+
// dead daemon is meaningless. Exits non-zero on failure with a clear message.
|
|
13
|
+
export async function runDaemonStats(args: readonly string[]): Promise<void> {
|
|
14
|
+
const wantJson = args.includes("--json");
|
|
15
|
+
|
|
16
|
+
const outcome = await fetchStats();
|
|
17
|
+
if (outcome.kind !== "ok") {
|
|
18
|
+
process.stderr.write(`daemon-stats: ${describeFailure(outcome)}\n`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (wantJson) {
|
|
23
|
+
process.stdout.write(JSON.stringify(outcome.value, null, 2) + "\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
process.stdout.write(formatStats(outcome.value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fetchStats(): Promise<RoundTripOutcome<StatsSnapshot>> {
|
|
31
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary. The response is
|
|
32
|
+
// an unchecked cast from socket JSON; the presence check is the explicit
|
|
33
|
+
// narrowing at the wire edge (an ok response without `stats` classifies as
|
|
34
|
+
// permanent/malformed_response in the transport).
|
|
35
|
+
return requestOutcome({ kind: "stats" }, BUDGETS, (resp) =>
|
|
36
|
+
"stats" in resp ? resp.stats : undefined,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtBytes(n: number): string {
|
|
41
|
+
if (n < 1024) return `${n}B`;
|
|
42
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
43
|
+
return `${(n / 1024 / 1024).toFixed(1)}MB`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtRate(hits: number, misses: number): string {
|
|
47
|
+
const total = hits + misses;
|
|
48
|
+
if (total === 0) return "n/a";
|
|
49
|
+
return `${((hits / total) * 100).toFixed(1)}%`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fmtUptime(sec: number): string {
|
|
53
|
+
if (sec < 60) return `${sec}s`;
|
|
54
|
+
if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
|
|
55
|
+
const h = Math.floor(sec / 3600);
|
|
56
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
57
|
+
return `${h}h${m}m`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatStats(s: StatsSnapshot): string {
|
|
61
|
+
const lines: string[] = [];
|
|
62
|
+
lines.push(`cc-candybar daemon stats`);
|
|
63
|
+
lines.push(``);
|
|
64
|
+
lines.push(`process`);
|
|
65
|
+
lines.push(` pid ${s.pid}`);
|
|
66
|
+
lines.push(` version ${s.version}`);
|
|
67
|
+
lines.push(` startedAt ${s.startedAt}`);
|
|
68
|
+
lines.push(` uptime ${fmtUptime(s.uptimeSec)}`);
|
|
69
|
+
lines.push(` rss ${fmtBytes(s.rssBytes)}`);
|
|
70
|
+
lines.push(` heapUsed ${fmtBytes(s.heapUsedBytes)}`);
|
|
71
|
+
lines.push(` heapTotal ${fmtBytes(s.heapTotalBytes)}`);
|
|
72
|
+
lines.push(` external ${fmtBytes(s.externalBytes)}`);
|
|
73
|
+
lines.push(` arrayBuffers ${fmtBytes(s.arrayBuffersBytes)}`);
|
|
74
|
+
lines.push(``);
|
|
75
|
+
lines.push(`requests`);
|
|
76
|
+
lines.push(` total ${s.requests.total}`);
|
|
77
|
+
lines.push(` errored ${s.requests.errored}`);
|
|
78
|
+
lines.push(` timedOut ${s.requests.timedOut}`);
|
|
79
|
+
lines.push(` inFlight ${s.requests.inFlight}`);
|
|
80
|
+
lines.push(``);
|
|
81
|
+
lines.push(`gitCache`);
|
|
82
|
+
lines.push(` size ${s.gitCache.size}`);
|
|
83
|
+
lines.push(
|
|
84
|
+
` hit rate ${fmtRate(s.gitCache.hits, s.gitCache.misses)} (${s.gitCache.hits} / ${s.gitCache.hits + s.gitCache.misses})`,
|
|
85
|
+
);
|
|
86
|
+
lines.push(` invalidations ${s.gitCache.invalidations}`);
|
|
87
|
+
lines.push(``);
|
|
88
|
+
lines.push(`usageCache`);
|
|
89
|
+
lines.push(` size ${s.usageCache.size}`);
|
|
90
|
+
lines.push(
|
|
91
|
+
` hit rate ${fmtRate(s.usageCache.hits, s.usageCache.misses)} (${s.usageCache.hits} / ${s.usageCache.hits + s.usageCache.misses})`,
|
|
92
|
+
);
|
|
93
|
+
lines.push(` sweeps ${s.usageCache.sweeps}`);
|
|
94
|
+
lines.push(``);
|
|
95
|
+
lines.push(`renderCache`);
|
|
96
|
+
lines.push(` size ${s.renderCache.size}`);
|
|
97
|
+
lines.push(`watchers`);
|
|
98
|
+
lines.push(` active ${s.watchers.active}`);
|
|
99
|
+
lines.push(` opened ${s.watchers.opened}`);
|
|
100
|
+
lines.push(` closed ${s.watchers.closed}`);
|
|
101
|
+
lines.push(` evicted ${s.watchers.evicted}`);
|
|
102
|
+
lines.push(``);
|
|
103
|
+
lines.push(`subprocesses`);
|
|
104
|
+
lines.push(` total ${s.subprocesses.total}`);
|
|
105
|
+
lines.push(` inFlight ${s.subprocesses.inFlight}`);
|
|
106
|
+
lines.push(` lastMinute ${s.subprocesses.lastMinute}`);
|
|
107
|
+
// Snapshot already includes only executed categories (stats.ts:
|
|
108
|
+
// snapshotSubprocesses keeps byCategory and the histograms symmetric).
|
|
109
|
+
const activeCats = Object.entries(s.subprocesses.byCategory);
|
|
110
|
+
// [LAW:dataflow-not-control-flow] Column width is a function of the data,
|
|
111
|
+
// not a hardcoded constant that drifts when new categories are added. The
|
|
112
|
+
// padEnd(13) baseline matches the " pid " etc. columns above so
|
|
113
|
+
// short categories still line up; longer ones expand the column.
|
|
114
|
+
const colWidth = activeCats.reduce((w, [cat]) => Math.max(w, cat.length), 13);
|
|
115
|
+
for (const [cat, n] of activeCats) {
|
|
116
|
+
const p50 = s.subprocesses.p50DurationMs[cat];
|
|
117
|
+
const p99 = s.subprocesses.p99DurationMs[cat];
|
|
118
|
+
const timing =
|
|
119
|
+
p50 !== undefined && p99 !== undefined
|
|
120
|
+
? ` (p50 ${p50}ms · p99 ${p99}ms)`
|
|
121
|
+
: "";
|
|
122
|
+
lines.push(` ${cat.padEnd(colWidth)} ${n}${timing}`);
|
|
123
|
+
}
|
|
124
|
+
if (s.nextRestartReason) {
|
|
125
|
+
lines.push(``);
|
|
126
|
+
lines.push(`nextRestart ${s.nextRestartReason}`);
|
|
127
|
+
}
|
|
128
|
+
return lines.join("\n") + "\n";
|
|
129
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// The single client-side daemon socket round-trip: connect with a timeout,
|
|
2
|
+
// send one framed request, await one framed response, always destroy the
|
|
3
|
+
// socket, and classify every failure into the transient/permanent split.
|
|
4
|
+
//
|
|
5
|
+
// [LAW:one-type-per-behavior] This round-trip used to be implemented three
|
|
6
|
+
// times (render/click in client.ts, stats in client-stats.ts, debug in
|
|
7
|
+
// client-debug.ts), differing only in timeout values and which payload field
|
|
8
|
+
// the caller wanted — configuration, not behavior. Callers now pass their
|
|
9
|
+
// budgets and payload projector as VALUES through this one boundary; the
|
|
10
|
+
// stats/debug paths inherited classification fixes (post-2l6) they had
|
|
11
|
+
// silently drifted away from. [LAW:dataflow-not-control-flow] There is no
|
|
12
|
+
// caller-identity flag here — a new caller is a new argument set, not a new
|
|
13
|
+
// branch.
|
|
14
|
+
|
|
15
|
+
import net from "node:net";
|
|
16
|
+
import { socketPath } from "./paths";
|
|
17
|
+
import { PROTOCOL_VERSION, ProtocolError, sendOne } from "./protocol";
|
|
18
|
+
import type { Request, Response } from "./protocol";
|
|
19
|
+
|
|
20
|
+
// Per-caller timeout policy, carried as data. The render hot path runs tight
|
|
21
|
+
// budgets (a statusline refresh must not stall the host); operator-driven
|
|
22
|
+
// CLIs (stats/debug) legitimately afford slower ones.
|
|
23
|
+
export interface RoundTripBudgets {
|
|
24
|
+
readonly connectMs: number;
|
|
25
|
+
readonly budgetMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// [LAW:types-are-the-program] The outcome carries its own recovery
|
|
29
|
+
// semantics. `transient` failures mean the daemon was unavailable/slow —
|
|
30
|
+
// kicking a fresh daemon is the right response. `permanent` failures mean
|
|
31
|
+
// the daemon refused our request semantically — kicking does NOT help
|
|
32
|
+
// because the next daemon will refuse the same request the same way. The
|
|
33
|
+
// caller's branch is no longer uniform: it matches on `kind` and routes
|
|
34
|
+
// kick vs. show-error off the type, not off a stringified `reason`. This
|
|
35
|
+
// asymmetry was missing in the previous shape and is the root of the
|
|
36
|
+
// 452-corpse spiral (kz8.5).
|
|
37
|
+
export type RoundTripOutcome<T> = { kind: "ok"; value: T } | FailureOutcome;
|
|
38
|
+
|
|
39
|
+
export type FailureOutcome = TransientOutcome | PermanentOutcome;
|
|
40
|
+
|
|
41
|
+
export interface TransientOutcome {
|
|
42
|
+
kind: "transient";
|
|
43
|
+
cause: "unreachable" | "timeout" | "io_error";
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type PermanentOutcome =
|
|
48
|
+
| {
|
|
49
|
+
kind: "permanent";
|
|
50
|
+
cause: "version_mismatch";
|
|
51
|
+
clientV: number;
|
|
52
|
+
daemonV: number;
|
|
53
|
+
}
|
|
54
|
+
| { kind: "permanent"; cause: "bad_request"; message: string }
|
|
55
|
+
| { kind: "permanent"; cause: "render_failed"; message: string }
|
|
56
|
+
| { kind: "permanent"; cause: "malformed_response"; message: string };
|
|
57
|
+
|
|
58
|
+
// [LAW:single-enforcer] The protocol version is stamped here, on every
|
|
59
|
+
// outbound request — a caller cannot mis-stamp or omit it.
|
|
60
|
+
type Unversioned<R> = R extends { v: number } ? Omit<R, "v"> : never;
|
|
61
|
+
export type UnversionedRequest = Unversioned<Request>;
|
|
62
|
+
|
|
63
|
+
type OkResponse = Extract<Response, { ok: true }>;
|
|
64
|
+
|
|
65
|
+
// One round-trip, classified. `project` extracts the caller's payload from
|
|
66
|
+
// an ok response and returns undefined when the response, though ok, does
|
|
67
|
+
// not carry the expected shape (the daemon is up, it just answered the
|
|
68
|
+
// wrong question) — that maps to permanent/malformed_response, not a kick.
|
|
69
|
+
// The payload types themselves never enter this module [LAW:one-way-deps].
|
|
70
|
+
export async function requestOutcome<T>(
|
|
71
|
+
req: UnversionedRequest,
|
|
72
|
+
budgets: RoundTripBudgets,
|
|
73
|
+
project: (resp: OkResponse) => T | undefined,
|
|
74
|
+
): Promise<RoundTripOutcome<T>> {
|
|
75
|
+
let sock: net.Socket | null = null;
|
|
76
|
+
try {
|
|
77
|
+
sock = await connectWithTimeout(socketPath(), budgets.connectMs);
|
|
78
|
+
const resp: Response = await sendOne(
|
|
79
|
+
sock,
|
|
80
|
+
{ v: PROTOCOL_VERSION, ...req },
|
|
81
|
+
budgets.budgetMs,
|
|
82
|
+
);
|
|
83
|
+
return interpretResponse(req.kind, resp, project);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return interpretException(e);
|
|
86
|
+
} finally {
|
|
87
|
+
if (sock) sock.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// [LAW:types-are-the-program] One place that turns a wire-level Response
|
|
92
|
+
// into a typed Outcome. Every daemon client goes through this, so the
|
|
93
|
+
// kick-vs-show-error decision has a single source of truth.
|
|
94
|
+
//
|
|
95
|
+
// [LAW:no-defensive-null-guards] This function sits AT the trust boundary —
|
|
96
|
+
// `resp` is `frame as Response`, an unchecked cast from socket JSON. The
|
|
97
|
+
// per-field type-narrowings and the default branch below are not defensive
|
|
98
|
+
// guards against an internal bug; they are the explicit handling at the
|
|
99
|
+
// wire edge for fields whose runtime types the JSON cast cannot enforce.
|
|
100
|
+
// Every untrusted access flows through asString/asProtocolVersion so the
|
|
101
|
+
// downstream Outcome shape carries values of the declared types only.
|
|
102
|
+
|
|
103
|
+
// [LAW:single-enforcer] Narrowing primitives used everywhere we read a
|
|
104
|
+
// field off the cast `resp`. Centralised so a future "validate the whole
|
|
105
|
+
// frame" approach has one place to evolve from. Each helper expresses an
|
|
106
|
+
// exact type predicate; mixing semantics (e.g. "any finite number" with
|
|
107
|
+
// "non-negative integer") would weaken the type [LAW:types-are-the-program].
|
|
108
|
+
function asString(v: unknown, fallback: string): string {
|
|
109
|
+
return typeof v === "string" ? v : fallback;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// [LAW:one-type-per-behavior] Mirrors the Rust client's `as_u64()` semantics:
|
|
113
|
+
// the only valid daemonV values are non-negative integers. Negatives and
|
|
114
|
+
// fractional values fall back to 0 so both runtimes derive the same
|
|
115
|
+
// PermanentOutcome from the same wire payload.
|
|
116
|
+
function asProtocolVersion(v: unknown): number {
|
|
117
|
+
return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function interpretResponse<T>(
|
|
121
|
+
kind: Request["kind"],
|
|
122
|
+
resp: Response,
|
|
123
|
+
project: (resp: OkResponse) => T | undefined,
|
|
124
|
+
): RoundTripOutcome<T> {
|
|
125
|
+
// Treat the cast `resp` as a bag of unknowns; each access is narrowed
|
|
126
|
+
// explicitly. The typed parameter still documents the *expected* shape
|
|
127
|
+
// for readers, but the runtime trusts only what it can verify per field.
|
|
128
|
+
const raw = resp as {
|
|
129
|
+
ok?: unknown;
|
|
130
|
+
error?: unknown;
|
|
131
|
+
code?: unknown;
|
|
132
|
+
daemonV?: unknown;
|
|
133
|
+
};
|
|
134
|
+
if (raw.ok === true) {
|
|
135
|
+
const value = project(resp as OkResponse);
|
|
136
|
+
if (value !== undefined) {
|
|
137
|
+
return { kind: "ok", value };
|
|
138
|
+
}
|
|
139
|
+
// Ok response without the payload this request asked for — not our
|
|
140
|
+
// shape. Treat as a permanent malformed-response so the caller does NOT
|
|
141
|
+
// kick (the daemon is up, just answered the wrong question).
|
|
142
|
+
return {
|
|
143
|
+
kind: "permanent",
|
|
144
|
+
cause: "malformed_response",
|
|
145
|
+
message: `ok response without payload for "${kind}" request`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const errorMessage = asString(raw.error, "(no error message)");
|
|
149
|
+
switch (raw.code) {
|
|
150
|
+
case "VERSION_MISMATCH":
|
|
151
|
+
return {
|
|
152
|
+
kind: "permanent",
|
|
153
|
+
cause: "version_mismatch",
|
|
154
|
+
clientV: PROTOCOL_VERSION,
|
|
155
|
+
// Older daemons may not echo daemonV; non-number values from a
|
|
156
|
+
// misbehaving stub also fall back to 0 (the renderer maps 0 to
|
|
157
|
+
// "unknown" in the visible glyph).
|
|
158
|
+
daemonV: asProtocolVersion(raw.daemonV),
|
|
159
|
+
};
|
|
160
|
+
case "TIMEOUT":
|
|
161
|
+
// Daemon is alive but didn't answer in time — same recovery as
|
|
162
|
+
// unreachable: kick and emit stale/blank. This is the *only* non-ok
|
|
163
|
+
// wire code that maps to transient.
|
|
164
|
+
return { kind: "transient", cause: "timeout", message: errorMessage };
|
|
165
|
+
case "BAD_REQUEST":
|
|
166
|
+
return { kind: "permanent", cause: "bad_request", message: errorMessage };
|
|
167
|
+
case "RENDER_FAILED":
|
|
168
|
+
return {
|
|
169
|
+
kind: "permanent",
|
|
170
|
+
cause: "render_failed",
|
|
171
|
+
message: errorMessage,
|
|
172
|
+
};
|
|
173
|
+
default:
|
|
174
|
+
// Unknown wire code — mirrors rust-client's `_ => MalformedResponse(...)`
|
|
175
|
+
// so both runtimes converge on the same observable behavior for any
|
|
176
|
+
// code the client doesn't recognize. String() handles missing /
|
|
177
|
+
// non-string values without crashing.
|
|
178
|
+
return {
|
|
179
|
+
kind: "permanent",
|
|
180
|
+
cause: "malformed_response",
|
|
181
|
+
message: `unknown error code: ${String(raw.code)}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Exceptions reaching this function come from two distinct classes:
|
|
187
|
+
// - Connect/IO/timeout failures — transient. A respawn or retry has a
|
|
188
|
+
// real chance of recovering (daemon dead, socket vanished, slow link).
|
|
189
|
+
// - Protocol violations from sendOne's reject path — permanent. The
|
|
190
|
+
// daemon is alive but produced garbage (oversized frame, JSON parse
|
|
191
|
+
// failure). Respawning would hit the same response identically; this
|
|
192
|
+
// is the same recovery class as a wire-level VERSION_MISMATCH, so we
|
|
193
|
+
// route through PermanentCause::MalformedResponse and the user sees
|
|
194
|
+
// a glyph naming the failure rather than a blank line plus a kick.
|
|
195
|
+
//
|
|
196
|
+
// [LAW:types-are-the-program] The protocol-violation discriminator is
|
|
197
|
+
// carried structurally by `ProtocolError` (exported from protocol.ts) —
|
|
198
|
+
// not by substring-matching the error message, which would silently drift
|
|
199
|
+
// if Node's JSON.parse wording changes across versions/locales. The
|
|
200
|
+
// `e instanceof SyntaxError` fallback covers a residual case where a
|
|
201
|
+
// JSON.parse happened to escape `makeFrameReader`'s wrapping (defense in
|
|
202
|
+
// depth, not the primary path).
|
|
203
|
+
//
|
|
204
|
+
// [LAW:one-type-per-behavior] Mirrors rust-client's classify_io_error —
|
|
205
|
+
// InvalidData/InvalidInput map to Permanent(MalformedResponse), everything
|
|
206
|
+
// else stays transient. The two runtimes agree on the recovery class for
|
|
207
|
+
// every observable wire failure.
|
|
208
|
+
function interpretException(e: unknown): FailureOutcome {
|
|
209
|
+
if (e instanceof ProtocolError || e instanceof SyntaxError) {
|
|
210
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
211
|
+
return { kind: "permanent", cause: "malformed_response", message };
|
|
212
|
+
}
|
|
213
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
214
|
+
if (message === "CONNECT_TIMEOUT" || message === "TIMEOUT") {
|
|
215
|
+
return { kind: "transient", cause: "timeout", message };
|
|
216
|
+
}
|
|
217
|
+
if (
|
|
218
|
+
message.includes("ECONNREFUSED") ||
|
|
219
|
+
message.includes("ENOENT") ||
|
|
220
|
+
message.includes("ENOTSOCK")
|
|
221
|
+
) {
|
|
222
|
+
return { kind: "transient", cause: "unreachable", message };
|
|
223
|
+
}
|
|
224
|
+
return { kind: "transient", cause: "io_error", message };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function connectWithTimeout(
|
|
228
|
+
path: string,
|
|
229
|
+
timeoutMs: number,
|
|
230
|
+
): Promise<net.Socket> {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const sock = net.createConnection({ path });
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
sock.destroy();
|
|
235
|
+
reject(new Error("CONNECT_TIMEOUT"));
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
sock.once("connect", () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
resolve(sock);
|
|
240
|
+
});
|
|
241
|
+
sock.once("error", (err) => {
|
|
242
|
+
clearTimeout(timer);
|
|
243
|
+
reject(err);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// [LAW:single-enforcer] One plain-text rendering of a failed outcome for
|
|
249
|
+
// operator-facing CLIs (daemon-stats, vars/segments/config). The statusline
|
|
250
|
+
// glyph (src/render/error-glyph.ts) and the url-handle formatter
|
|
251
|
+
// (src/install/index.ts) are deliberately separate presentations with their
|
|
252
|
+
// own contracts (ANSI styling and Rust-parity truncation; per-cause click
|
|
253
|
+
// diagnostics). The spawn hint appears only on transient failures — on a
|
|
254
|
+
// permanent failure the daemon is demonstrably running, and suggesting a
|
|
255
|
+
// respawn would send the operator down the wrong path.
|
|
256
|
+
export function describeFailure(outcome: FailureOutcome): string {
|
|
257
|
+
if (outcome.kind === "transient") {
|
|
258
|
+
return (
|
|
259
|
+
`daemon unavailable (${outcome.cause}: ${outcome.message})\n` +
|
|
260
|
+
"Hint: daemon may not be running. Run `cc-candybar` once to spawn it."
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
switch (outcome.cause) {
|
|
264
|
+
case "version_mismatch": {
|
|
265
|
+
const daemon = outcome.daemonV === 0 ? "unknown" : `v${outcome.daemonV}`;
|
|
266
|
+
return `daemon protocol mismatch (client v${outcome.clientV} ≠ daemon ${daemon})`;
|
|
267
|
+
}
|
|
268
|
+
case "bad_request":
|
|
269
|
+
case "render_failed":
|
|
270
|
+
case "malformed_response":
|
|
271
|
+
return `daemon error (${outcome.cause}): ${outcome.message}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The render/click client — the statusline hot path. The socket round-trip
|
|
2
|
+
// and failure classification live in ./client-transport (the single
|
|
3
|
+
// implementation shared with the stats/debug CLIs); this module contributes
|
|
4
|
+
// only the render path's own data: tight timeout budgets and the
|
|
5
|
+
// output-string payload projection. [LAW:dataflow-not-control-flow]
|
|
6
|
+
//
|
|
7
|
+
// [LAW:one-source-of-truth] These budget consts are mirrored by the Rust
|
|
8
|
+
// client (rust-client/src/main.rs) and diffed by scripts/check-protocol.mjs,
|
|
9
|
+
// which anchors on the declarations below — keep them named consts in this
|
|
10
|
+
// file, or repoint the CHECKS rows in the same commit.
|
|
11
|
+
|
|
12
|
+
import type { ClaudeHookData } from "../utils/claude";
|
|
13
|
+
import { requestOutcome } from "./client-transport";
|
|
14
|
+
import type { RoundTripBudgets, RoundTripOutcome } from "./client-transport";
|
|
15
|
+
import type { Response } from "./protocol";
|
|
16
|
+
|
|
17
|
+
const CONNECT_TIMEOUT_MS = 50;
|
|
18
|
+
const TOTAL_BUDGET_MS = 150;
|
|
19
|
+
const CLICK_BUDGET_MS = 200;
|
|
20
|
+
|
|
21
|
+
const RENDER_BUDGETS: RoundTripBudgets = {
|
|
22
|
+
connectMs: CONNECT_TIMEOUT_MS,
|
|
23
|
+
budgetMs: TOTAL_BUDGET_MS,
|
|
24
|
+
};
|
|
25
|
+
const CLICK_BUDGETS: RoundTripBudgets = {
|
|
26
|
+
connectMs: CONNECT_TIMEOUT_MS,
|
|
27
|
+
budgetMs: CLICK_BUDGET_MS,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// The render/click outcome vocabulary, mirrored by the Rust client's outcome
|
|
31
|
+
// enum. The ok payload is the rendered line (or click acknowledgement) to
|
|
32
|
+
// print. See client-transport.ts for the transient/permanent semantics.
|
|
33
|
+
export type ClientOutcome = RoundTripOutcome<string>;
|
|
34
|
+
|
|
35
|
+
// [LAW:no-defensive-null-guards] exception: trust boundary. The ok response
|
|
36
|
+
// is an unchecked cast from socket JSON; the typeof check is the explicit
|
|
37
|
+
// narrowing at the wire edge, and its failure means "ok response without our
|
|
38
|
+
// payload" (classified permanent/malformed_response by the transport).
|
|
39
|
+
function projectOutput(
|
|
40
|
+
resp: Extract<Response, { ok: true }>,
|
|
41
|
+
): string | undefined {
|
|
42
|
+
const output = (resp as { output?: unknown }).output;
|
|
43
|
+
return typeof output === "string" ? output : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Try to render via the daemon. Returns a typed outcome — see ClientOutcome.
|
|
47
|
+
// There is no inline render path; see src/index.ts. The caller is responsible
|
|
48
|
+
// for branching on outcome.kind and deciding whether to kick, display an
|
|
49
|
+
// error glyph, or print the rendered output.
|
|
50
|
+
export function tryRenderViaDaemon(
|
|
51
|
+
hookData: ClaudeHookData,
|
|
52
|
+
args: string[],
|
|
53
|
+
cwd: string,
|
|
54
|
+
termCols?: number,
|
|
55
|
+
): Promise<ClientOutcome> {
|
|
56
|
+
return requestOutcome(
|
|
57
|
+
{ kind: "render", hookData, args, cwd, termCols },
|
|
58
|
+
RENDER_BUDGETS,
|
|
59
|
+
projectOutput,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// [LAW:single-enforcer] Same outcome translator for click as for render —
|
|
64
|
+
// click failures decompose into the same transient/permanent split, so the
|
|
65
|
+
// caller's "ok? done : kick + fallback" logic gets the same typed input.
|
|
66
|
+
export function tryClickViaDaemon(
|
|
67
|
+
verb: string,
|
|
68
|
+
value: string,
|
|
69
|
+
): Promise<ClientOutcome> {
|
|
70
|
+
return requestOutcome(
|
|
71
|
+
{ kind: "click", verb, value },
|
|
72
|
+
CLICK_BUDGETS,
|
|
73
|
+
projectOutput,
|
|
74
|
+
);
|
|
75
|
+
}
|