@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,1103 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import {
|
|
7
|
+
daemonDir,
|
|
8
|
+
ensureSocketParentSafe,
|
|
9
|
+
pidPath,
|
|
10
|
+
socketPath,
|
|
11
|
+
sessionStatePath,
|
|
12
|
+
} from "./paths";
|
|
13
|
+
import { dlog, closeLog } from "./log";
|
|
14
|
+
import {
|
|
15
|
+
PROTOCOL_VERSION,
|
|
16
|
+
encodeFrame,
|
|
17
|
+
makeFrameReader,
|
|
18
|
+
sanitizeTermCols,
|
|
19
|
+
} from "./protocol";
|
|
20
|
+
import type { Request, Response } from "./protocol";
|
|
21
|
+
import { GitDataProvider } from "./cache/git";
|
|
22
|
+
import { SessionUsageStore } from "./cache/session-usage-store";
|
|
23
|
+
import { RenderCache } from "./cache/render";
|
|
24
|
+
import { WatcherRegistry } from "./cache/watchers";
|
|
25
|
+
import { RuntimeStats } from "./stats";
|
|
26
|
+
import { makeLimits, realLimitsDeps, type LimitsHandle } from "./limits";
|
|
27
|
+
import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
|
|
28
|
+
import { SessionState } from "./session-state";
|
|
29
|
+
import { FileSessionStorage } from "./session-state-file";
|
|
30
|
+
import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
|
|
31
|
+
import {
|
|
32
|
+
effectsUrl,
|
|
33
|
+
VERB_SHOW_CONFIG_ERROR,
|
|
34
|
+
VERB_SHOW_CONFIG_WARNING,
|
|
35
|
+
} from "../click/wire.js";
|
|
36
|
+
import { validateHookData } from "../utils/schema-validator.js";
|
|
37
|
+
import { setLaunchStats } from "../proc/launch";
|
|
38
|
+
import { buildDebugSnapshot } from "./debug";
|
|
39
|
+
import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
|
|
40
|
+
import { expandHome } from "../config/dsl-loader.js";
|
|
41
|
+
import { renderDsl } from "../dsl/render.js";
|
|
42
|
+
import { effectiveThemeName, resolverForThemeName } from "../themes/index.js";
|
|
43
|
+
import {
|
|
44
|
+
renderStripCells,
|
|
45
|
+
DEFAULT_TERMINAL_WIDTH,
|
|
46
|
+
type BuildLineOptions,
|
|
47
|
+
} from "../render/strip.js";
|
|
48
|
+
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
49
|
+
import type { RichText } from "@promptctl/rich-js";
|
|
50
|
+
import { buildRenderPayload } from "./render-payload.js";
|
|
51
|
+
import { ContextProvider } from "../segments/context.js";
|
|
52
|
+
import { MetricsProvider } from "../segments/metrics.js";
|
|
53
|
+
import { TmuxService } from "../segments/tmux.js";
|
|
54
|
+
import { sanitizeAndTruncate } from "../render/diagnostic-text.js";
|
|
55
|
+
import {
|
|
56
|
+
ANSI_RESET,
|
|
57
|
+
DIAGNOSTIC_ERROR_BG,
|
|
58
|
+
DIAGNOSTIC_ERROR_FG,
|
|
59
|
+
DIAGNOSTIC_WARNING_BG,
|
|
60
|
+
DIAGNOSTIC_WARNING_FG,
|
|
61
|
+
} from "../render/diagnostic-style.js";
|
|
62
|
+
|
|
63
|
+
// [LAW:one-source-of-truth] one cache instance per daemon process — multiple
|
|
64
|
+
// instances would defeat the share-across-sessions invariant.
|
|
65
|
+
const stats = new RuntimeStats();
|
|
66
|
+
// [LAW:single-enforcer] Route all child_process spawns through src/proc/launch.
|
|
67
|
+
// Installing the metering handle here makes subprocess counts visible in
|
|
68
|
+
// daemon-stats.
|
|
69
|
+
setLaunchStats(stats.launchStats);
|
|
70
|
+
// [LAW:single-enforcer] The daemon injects `dlog` into both registries so
|
|
71
|
+
// cache + watcher lifecycle events land in daemon.log at the right level.
|
|
72
|
+
// Non-daemon consumers (var-system tests, future library use) take the
|
|
73
|
+
// default debug-routed loggers and never write to daemon log files.
|
|
74
|
+
const watcherRegistry = new WatcherRegistry({
|
|
75
|
+
counters: stats,
|
|
76
|
+
logger: dlog,
|
|
77
|
+
});
|
|
78
|
+
const gitService = new GitDataProvider({
|
|
79
|
+
watchers: watcherRegistry,
|
|
80
|
+
logger: dlog,
|
|
81
|
+
});
|
|
82
|
+
const usageStore = new SessionUsageStore();
|
|
83
|
+
// [LAW:locality-or-seam] Constructed ephemeral so importing this module (CLI
|
|
84
|
+
// relay, subcommands) does no disk I/O. The daemon binds the file-backed
|
|
85
|
+
// storage in runDaemon(), making it the sole reader/writer of the state file.
|
|
86
|
+
const sessionState = new SessionState();
|
|
87
|
+
// [LAW:one-source-of-truth] One provider per data shape, shared across every
|
|
88
|
+
// render in this daemon. The render cache owns DSL-state-per-config; these
|
|
89
|
+
// providers serve the augmented payload that flows through every render.
|
|
90
|
+
const contextProvider = new ContextProvider();
|
|
91
|
+
const metricsProvider = new MetricsProvider();
|
|
92
|
+
const tmuxService = new TmuxService();
|
|
93
|
+
const renderCache = new RenderCache({
|
|
94
|
+
gitService,
|
|
95
|
+
sessionState,
|
|
96
|
+
watchers: watcherRegistry,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const REQUEST_TIMEOUT_MS = 200;
|
|
100
|
+
const BIN_CHECK_INTERVAL_MS = 60 * 1000;
|
|
101
|
+
|
|
102
|
+
// Daemon entry point. Tries to bind the Unix socket — atomic bind() is the
|
|
103
|
+
// single-instance enforcer (two daemons cannot both bind the same path; the
|
|
104
|
+
// kernel makes duplicate-daemon unrepresentable). Listens for one request per
|
|
105
|
+
// connection. Any uncaught error exits non-zero; the next client obtains a
|
|
106
|
+
// fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
|
|
107
|
+
// obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
|
|
108
|
+
export function runDaemon(): void {
|
|
109
|
+
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
110
|
+
// [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
|
|
111
|
+
// not a symlink before we bind. Without this check, a same-host attacker
|
|
112
|
+
// could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
|
|
113
|
+
// squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
|
|
114
|
+
// location — every bind path goes through the same trust precondition.
|
|
115
|
+
// No symmetric client-side check: the daemon is the sole creator, so a
|
|
116
|
+
// successful bind already proves the parent is trusted. Failure here surfaces
|
|
117
|
+
// as a daemon exit; the client falls back to the last cached render.
|
|
118
|
+
ensureSocketParentSafe(socketPath());
|
|
119
|
+
|
|
120
|
+
// Bind disk persistence now that we know we are the daemon process — load
|
|
121
|
+
// prior session state and become the sole writer of the state file.
|
|
122
|
+
sessionState.useStorage(
|
|
123
|
+
new FileSessionStorage(sessionStatePath(), 500, dlog),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Catch-alls log + exit so the supervisor (the next client) can restart us.
|
|
127
|
+
// [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
|
|
128
|
+
// catching all of unknown space, not skipping known optional values.
|
|
129
|
+
process.on("uncaughtException", (err) => {
|
|
130
|
+
dlog("error", `uncaughtException: ${err.stack || err.message}`);
|
|
131
|
+
shutdown(1);
|
|
132
|
+
});
|
|
133
|
+
process.on("unhandledRejection", (reason) => {
|
|
134
|
+
dlog("error", `unhandledRejection: ${String(reason)}`);
|
|
135
|
+
shutdown(1);
|
|
136
|
+
});
|
|
137
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
138
|
+
process.on(sig, () => {
|
|
139
|
+
dlog("info", `received ${sig}, shutting down`);
|
|
140
|
+
shutdown(0);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
|
|
145
|
+
// the watchdog calls shutdown(0), it never exits on its own. A production
|
|
146
|
+
// daemon has no spawner to outlive (env unset) and arms an inert handle; only
|
|
147
|
+
// a test-spawned daemon is anchored, so this is invisible to the real daemon.
|
|
148
|
+
armParentWatchdog({
|
|
149
|
+
anchor: anchorFromEnv(process.env),
|
|
150
|
+
isAlive: pidAlive,
|
|
151
|
+
onOrphaned: (reason) => {
|
|
152
|
+
dlog("info", `parent watchdog: ${reason}; shutting down`);
|
|
153
|
+
shutdown(0);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const server = net.createServer({ allowHalfOpen: false }, (sock) => {
|
|
158
|
+
handleConnection(sock);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// [LAW:single-enforcer] The atomic bind() is the daemon-singleton enforcer.
|
|
162
|
+
// Two daemons cannot both bind the same Unix socket path; the kernel makes
|
|
163
|
+
// duplicate-daemon unrepresentable. The pidfile is diagnostic only — never
|
|
164
|
+
// load-bearing for exclusion.
|
|
165
|
+
bindOrAttachAndExit(server, socketPath(), /* retried */ false);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// [LAW:dataflow-not-control-flow] One operation ("bring this server up or
|
|
169
|
+
// discover an existing one"). The bind result is the data that decides the
|
|
170
|
+
// next step; callers do not get to choose whether to spawn.
|
|
171
|
+
function bindOrAttachAndExit(
|
|
172
|
+
server: net.Server,
|
|
173
|
+
sockPath: string,
|
|
174
|
+
retried: boolean,
|
|
175
|
+
): void {
|
|
176
|
+
server.removeAllListeners("error");
|
|
177
|
+
server.once("error", (err) => {
|
|
178
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
179
|
+
if (code !== "EADDRINUSE") {
|
|
180
|
+
dlog("error", `server error: ${err.message}`);
|
|
181
|
+
shutdown(1);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (retried) {
|
|
185
|
+
// Lost a rebind race with another duplicate. The kernel arbitrated; we
|
|
186
|
+
// are the loser. Exit cleanly so the winner serves.
|
|
187
|
+
dlog("info", "lost rebind race; another daemon is alive — exiting");
|
|
188
|
+
process.exit(0);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
void handleAddressInUse(server, sockPath);
|
|
192
|
+
});
|
|
193
|
+
server.listen(sockPath, () => onListening(sockPath));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function handleAddressInUse(
|
|
197
|
+
server: net.Server,
|
|
198
|
+
sockPath: string,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
// EADDRINUSE: either a live daemon (we are a duplicate — exit), or a stale
|
|
201
|
+
// socket file from a crashed prior daemon (unlink + rebind).
|
|
202
|
+
const alive = await isSocketAlive(sockPath);
|
|
203
|
+
if (alive) {
|
|
204
|
+
dlog("info", "another daemon is listening on socket — exiting");
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
// Race-window guard: between our first `isSocketAlive` returning false and
|
|
208
|
+
// our `unlinkSync` running, another concurrent recoverer could unlink+bind
|
|
209
|
+
// the path. Without re-checking, our unlink would remove their *live*
|
|
210
|
+
// socket, leaving two daemons (one orphaned-but-listening, one freshly
|
|
211
|
+
// bound). Re-check immediately before unlink. If a live listener appeared
|
|
212
|
+
// between checks, exit instead of stomping on it.
|
|
213
|
+
if (await isSocketAlive(sockPath)) {
|
|
214
|
+
dlog(
|
|
215
|
+
"info",
|
|
216
|
+
"race: another daemon claimed the socket during recovery — exiting",
|
|
217
|
+
);
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
dlog("warn", "stale socket from crashed daemon — unlinking and rebinding");
|
|
221
|
+
// [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
|
|
222
|
+
// FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
|
|
223
|
+
// in the worst state: no daemon + stale socket blocking future starts.
|
|
224
|
+
// Surface unrecoverable failures loudly. ENOENT is fine — the goal was
|
|
225
|
+
// "make the path bindable" and a missing path already satisfies that.
|
|
226
|
+
try {
|
|
227
|
+
fs.unlinkSync(sockPath);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
230
|
+
dlog(
|
|
231
|
+
"error",
|
|
232
|
+
`cannot unlink stale socket ${sockPath}: ${(e as Error).message}`,
|
|
233
|
+
);
|
|
234
|
+
shutdown(1);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
bindOrAttachAndExit(server, sockPath, /* retried */ true);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// [LAW:no-defensive-null-guards] Three-state outcome distinguishes
|
|
242
|
+
// "definitely no listener" from "probably alive but slow." Callers that
|
|
243
|
+
// might destroy state on "no listener" (the stale-socket unlink path) must
|
|
244
|
+
// treat "unknown" as alive to avoid stomping on a slow live daemon.
|
|
245
|
+
type SocketAliveness = "alive" | "dead" | "unknown";
|
|
246
|
+
|
|
247
|
+
function probeSocket(sockPath: string): Promise<SocketAliveness> {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
const sock = net.connect(sockPath);
|
|
250
|
+
let settled = false;
|
|
251
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
252
|
+
const done = (result: SocketAliveness): void => {
|
|
253
|
+
if (settled) return;
|
|
254
|
+
settled = true;
|
|
255
|
+
if (timer) clearTimeout(timer);
|
|
256
|
+
sock.removeAllListeners();
|
|
257
|
+
sock.destroy();
|
|
258
|
+
resolve(result);
|
|
259
|
+
};
|
|
260
|
+
sock.once("connect", () => done("alive"));
|
|
261
|
+
sock.once("error", (err) => {
|
|
262
|
+
// Only these error codes definitively mean "no listener at this path":
|
|
263
|
+
// ECONNREFUSED — socket file present, kernel rejected connect
|
|
264
|
+
// ENOENT — socket file absent
|
|
265
|
+
// ENOTSOCK — path exists but isn't a socket
|
|
266
|
+
// Anything else (EPERM, EACCES, EAGAIN, …) is ambiguous — assume the
|
|
267
|
+
// daemon is alive to avoid destructive false negatives.
|
|
268
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
269
|
+
const dead =
|
|
270
|
+
code === "ECONNREFUSED" || code === "ENOENT" || code === "ENOTSOCK";
|
|
271
|
+
done(dead ? "dead" : "unknown");
|
|
272
|
+
});
|
|
273
|
+
// 50ms is generous; localhost AF_UNIX connect is sub-ms when a listener
|
|
274
|
+
// exists. Timeout means "we couldn't tell" — treat as unknown so the
|
|
275
|
+
// stale-socket recovery path doesn't unlink a live (but slow) daemon's
|
|
276
|
+
// socket.
|
|
277
|
+
timer = setTimeout(() => done("unknown"), 50);
|
|
278
|
+
timer.unref();
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Convenience: callers that just want "is something listening" treat
|
|
283
|
+
// "unknown" as alive (conservative — used by the EADDRINUSE attach branch).
|
|
284
|
+
async function isSocketAlive(sockPath: string): Promise<boolean> {
|
|
285
|
+
const result = await probeSocket(sockPath);
|
|
286
|
+
return result !== "dead";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function onListening(sockPath: string): void {
|
|
290
|
+
try {
|
|
291
|
+
fs.chmodSync(sockPath, 0o600);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
dlog("warn", `chmod socket failed: ${(e as Error).message}`);
|
|
294
|
+
}
|
|
295
|
+
writePidfileDiagnostic();
|
|
296
|
+
dlog(
|
|
297
|
+
"info",
|
|
298
|
+
`daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath}`,
|
|
299
|
+
);
|
|
300
|
+
armBinaryWatch();
|
|
301
|
+
armLimits();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- binary-mtime self-restart ---
|
|
305
|
+
//
|
|
306
|
+
// If the daemon's compiled output changes on disk (rebuild, upgrade, edit),
|
|
307
|
+
// exit at the next sample so the next client respawns from the fresh code.
|
|
308
|
+
// Cheap (one statSync/min) and avoids the user having to manually kill the
|
|
309
|
+
// daemon during development. unref() so this timer doesn't hold the process alive.
|
|
310
|
+
function armBinaryWatch(): void {
|
|
311
|
+
// Watch the resolved entry point, not the bin shim — npm run build updates
|
|
312
|
+
// dist/index.mjs but the bin/cc-candybar shim never changes.
|
|
313
|
+
const entryUrl = import.meta.url;
|
|
314
|
+
const targets: string[] = [];
|
|
315
|
+
if (entryUrl.startsWith("file://")) {
|
|
316
|
+
targets.push(fileURLToPath(entryUrl));
|
|
317
|
+
}
|
|
318
|
+
// Also watch argv[1] as fallback (covers global installs, symlinks, etc.)
|
|
319
|
+
if (process.argv[1]) targets.push(process.argv[1]!);
|
|
320
|
+
|
|
321
|
+
const originalMtimes = new Map<string, number>();
|
|
322
|
+
for (const t of targets) {
|
|
323
|
+
try {
|
|
324
|
+
originalMtimes.set(t, fs.statSync(t).mtimeMs);
|
|
325
|
+
} catch {
|
|
326
|
+
// File may not exist yet — skip it.
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (originalMtimes.size === 0) return;
|
|
330
|
+
|
|
331
|
+
const timer = setInterval(() => {
|
|
332
|
+
for (const [t, originalMtime] of originalMtimes) {
|
|
333
|
+
try {
|
|
334
|
+
const nowMtime = fs.statSync(t).mtimeMs;
|
|
335
|
+
if (nowMtime !== originalMtime) {
|
|
336
|
+
dlog("info", `binary mtime changed (${t}); shutting down`);
|
|
337
|
+
clearInterval(timer);
|
|
338
|
+
shutdown(0);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
} catch (e) {
|
|
342
|
+
dlog("warn", `bin stat failed: ${(e as Error).message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}, BIN_CHECK_INTERVAL_MS);
|
|
346
|
+
timer.unref();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- self-shutdown on RSS / age ---
|
|
350
|
+
let limits: LimitsHandle | null = null;
|
|
351
|
+
function armLimits(): void {
|
|
352
|
+
limits = makeLimits(
|
|
353
|
+
realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code)),
|
|
354
|
+
);
|
|
355
|
+
limits.arm();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// --- diagnostic pidfile ---
|
|
359
|
+
//
|
|
360
|
+
// [LAW:one-source-of-truth] The pidfile is *diagnostic only*. It records who
|
|
361
|
+
// the running daemon is so `daemon-stats` can report it; it plays no role in
|
|
362
|
+
// exclusion. Exclusion is the atomic bind() in bindOrAttachAndExit().
|
|
363
|
+
//
|
|
364
|
+
// Overwrite-on-write (no EEXIST check). If a stale pidfile exists from a
|
|
365
|
+
// crashed prior daemon, we replace it. The bind() above already proved no
|
|
366
|
+
// other daemon is alive.
|
|
367
|
+
|
|
368
|
+
function writePidfileDiagnostic(): void {
|
|
369
|
+
const payload = JSON.stringify({
|
|
370
|
+
pid: process.pid,
|
|
371
|
+
version: PROTOCOL_VERSION,
|
|
372
|
+
binPath: process.argv[1],
|
|
373
|
+
startedAt: new Date().toISOString(),
|
|
374
|
+
});
|
|
375
|
+
try {
|
|
376
|
+
fs.writeFileSync(pidPath(), payload, { mode: 0o600 });
|
|
377
|
+
// [LAW:single-enforcer] writeFileSync's `mode` only applies when the file
|
|
378
|
+
// is created. If a stale pidfile from a prior run was left with broader
|
|
379
|
+
// permissions, the write above won't tighten them — chmod explicitly so
|
|
380
|
+
// 0600 is the invariant regardless of prior state.
|
|
381
|
+
fs.chmodSync(pidPath(), 0o600);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
// Diagnostic only — failure does not block the daemon from serving.
|
|
384
|
+
dlog("warn", `pidfile write failed: ${(e as Error).message}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function removePidfileDiagnostic(): void {
|
|
389
|
+
try {
|
|
390
|
+
fs.unlinkSync(pidPath());
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let inFlight = 0;
|
|
395
|
+
|
|
396
|
+
// --- shutdown ---
|
|
397
|
+
|
|
398
|
+
let shuttingDown = false;
|
|
399
|
+
function shutdown(code: number): void {
|
|
400
|
+
if (shuttingDown) return;
|
|
401
|
+
shuttingDown = true;
|
|
402
|
+
// [LAW:single-enforcer] Arm the SIGKILL backstop FIRST, before any cleanup.
|
|
403
|
+
// The 452-daemon incident: shut-down daemons logged "shutting down" but
|
|
404
|
+
// held the bound socket FD 42 minutes later — process.exit() reached the
|
|
405
|
+
// call site but never completed because some active handle kept libuv's
|
|
406
|
+
// event loop alive past exit's teardown. The prior shape had `.unref()`
|
|
407
|
+
// on the SIGKILL timer, so the timer itself did NOT keep the loop alive
|
|
408
|
+
// — leaving the loop's only remaining live handles to win the race.
|
|
409
|
+
//
|
|
410
|
+
// What this timer guarantees: as long as the event loop can still run
|
|
411
|
+
// (handles that won't drop, async cleanup that schedules but never
|
|
412
|
+
// completes — the realistic failure modes for the incident class), the
|
|
413
|
+
// setTimeout callback fires within 500ms and SIGKILL terminates the
|
|
414
|
+
// process from outside the loop's bookkeeping. Critically the timer is
|
|
415
|
+
// NOT unref'd, so it is itself an active handle that keeps the loop
|
|
416
|
+
// alive long enough for itself to fire.
|
|
417
|
+
//
|
|
418
|
+
// What this timer cannot do: rescue a truly synchronous thread block
|
|
419
|
+
// (a C++ binding that never returns to JS, an infinite sync loop). No
|
|
420
|
+
// JS timer can fire while the main thread is blocked; only an external
|
|
421
|
+
// signal recovers that case. The realistic 452-corpse mode was async-
|
|
422
|
+
// handle retention, not a synchronous block, so the backstop is
|
|
423
|
+
// load-bearing for the observed failure pattern.
|
|
424
|
+
setTimeout(() => process.kill(process.pid, "SIGKILL"), 500);
|
|
425
|
+
// [LAW:single-enforcer] The atomic bind() on the unix socket path is the
|
|
426
|
+
// ONLY mutex preventing duplicate daemons. The previous shape unlinked
|
|
427
|
+
// the socket file FIRST, then spent O(100ms) closing watchers, flushing
|
|
428
|
+
// session state, and tearing down log streams before process.exit().
|
|
429
|
+
// The unlink frees the path the instant it runs; the listening FD stays
|
|
430
|
+
// held only until process.exit. In between, Claude Code's next render
|
|
431
|
+
// tick can spawn a fresh daemon that bind()s the same path and starts
|
|
432
|
+
// serving while we are still finishing cleanup. Under OOM cycles the
|
|
433
|
+
// overlap compounds — 12 daemons stacked up in the wild was the
|
|
434
|
+
// observed symptom. Do NOT unlink here. The kernel releases the FD on
|
|
435
|
+
// process.exit; the stale path that remains is recovered by the
|
|
436
|
+
// existing handleAddressInUse logic on the next daemon's startup
|
|
437
|
+
// (probe → dead → unlink + rebind, ~50ms one-shot cost).
|
|
438
|
+
try {
|
|
439
|
+
gitService.close();
|
|
440
|
+
} catch (e) {
|
|
441
|
+
dlog("warn", `gitService close failed: ${(e as Error).message}`);
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
usageStore.close();
|
|
445
|
+
} catch (e) {
|
|
446
|
+
dlog("warn", `usageStore close failed: ${(e as Error).message}`);
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
watcherRegistry.closeAll();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
dlog("warn", `watcherRegistry close failed: ${(e as Error).message}`);
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
sessionState.flush();
|
|
455
|
+
} catch (e) {
|
|
456
|
+
dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
|
|
457
|
+
}
|
|
458
|
+
removePidfileDiagnostic();
|
|
459
|
+
closeLog();
|
|
460
|
+
process.exit(code);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// --- per-connection handler ---
|
|
464
|
+
|
|
465
|
+
function handleConnection(sock: net.Socket): void {
|
|
466
|
+
inFlight++;
|
|
467
|
+
stats.inFlight = inFlight;
|
|
468
|
+
let responded = false;
|
|
469
|
+
|
|
470
|
+
// [LAW:no-ambient-temporal-coupling] respond owns the response→exit
|
|
471
|
+
// ordering. exitAfterFlush (an exit code; null = stay up) is performed
|
|
472
|
+
// by sock.end's completion callback, which Node invokes on 'finish' OR
|
|
473
|
+
// 'error' — a total signal. A peer that vanished mid-flush still settles,
|
|
474
|
+
// so the exit wish can never be stranded on a dead socket, and a live
|
|
475
|
+
// peer always has the frame in the kernel buffer before process.exit
|
|
476
|
+
// (unix-socket data survives writer exit). No fixed sleep stands between
|
|
477
|
+
// respond and exit; the SIGKILL backstop inside shutdown() is the
|
|
478
|
+
// unrelated last-resort safety.
|
|
479
|
+
const respond = (resp: Response, exitAfterFlush: number | null): void => {
|
|
480
|
+
if (responded) {
|
|
481
|
+
// First responder owns the flush. Reaching here with an exit wish is
|
|
482
|
+
// unreachable today (both exit-carrying arms resolve synchronously,
|
|
483
|
+
// far inside the request timeout) — but if it ever happens, say so
|
|
484
|
+
// instead of silently leaving a daemon up that was told to exit.
|
|
485
|
+
// [LAW:no-silent-failure]
|
|
486
|
+
if (exitAfterFlush !== null) {
|
|
487
|
+
dlog(
|
|
488
|
+
"warn",
|
|
489
|
+
"exit-after-flush dropped: an earlier responder settled this socket",
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
responded = true;
|
|
495
|
+
const settle =
|
|
496
|
+
exitAfterFlush === null
|
|
497
|
+
? undefined
|
|
498
|
+
: (): void => shutdown(exitAfterFlush);
|
|
499
|
+
try {
|
|
500
|
+
sock.end(encodeFrame(resp), settle);
|
|
501
|
+
} catch (e) {
|
|
502
|
+
// [LAW:no-silent-failure] The response is lost (socket already torn
|
|
503
|
+
// down), but the exit wish must not be.
|
|
504
|
+
dlog("warn", `response write failed: ${(e as Error).message}`);
|
|
505
|
+
settle?.();
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
// Per-request timeout protects the daemon from a single slow request
|
|
510
|
+
// (e.g. a hung git call) blocking subsequent connections. It abandons the
|
|
511
|
+
// RESPONSE, not the work — the handler promise keeps running.
|
|
512
|
+
//
|
|
513
|
+
// [LAW:one-source-of-truth] That is safe for the transcript-fs path because
|
|
514
|
+
// the work is bounded + shared, not orphaned: the today aggregate and
|
|
515
|
+
// per-session usage compute behind a SingleFlight (src/utils/single-flight.ts),
|
|
516
|
+
// so a timed-out render that abandoned its await leaves behind the ONE
|
|
517
|
+
// canonical in-flight scan, which the next render coalesces onto rather than
|
|
518
|
+
// duplicating. A timeout therefore adds zero new fs work — there is never
|
|
519
|
+
// more than one scan per key to orphan. Cancellation would be both messier
|
|
520
|
+
// and wasteful here (the in-flight scan is exactly what the next tick needs).
|
|
521
|
+
const timer = setTimeout(() => {
|
|
522
|
+
stats.requestsTimedOut++;
|
|
523
|
+
respond(
|
|
524
|
+
{
|
|
525
|
+
ok: false,
|
|
526
|
+
error: "request exceeded 200ms",
|
|
527
|
+
code: "TIMEOUT",
|
|
528
|
+
daemonV: PROTOCOL_VERSION,
|
|
529
|
+
},
|
|
530
|
+
null,
|
|
531
|
+
);
|
|
532
|
+
}, REQUEST_TIMEOUT_MS);
|
|
533
|
+
|
|
534
|
+
const reader = makeFrameReader(
|
|
535
|
+
(frame) => {
|
|
536
|
+
void handleRequest(frame as Request)
|
|
537
|
+
.then((r) => respond(r.resp, r.exitAfterFlush))
|
|
538
|
+
.catch((err) => {
|
|
539
|
+
dlog("error", `handler threw: ${err?.stack || err}`);
|
|
540
|
+
respond(
|
|
541
|
+
{
|
|
542
|
+
ok: false,
|
|
543
|
+
error: String(err?.message || err),
|
|
544
|
+
code: "RENDER_FAILED",
|
|
545
|
+
daemonV: PROTOCOL_VERSION,
|
|
546
|
+
},
|
|
547
|
+
null,
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
},
|
|
551
|
+
(err) => {
|
|
552
|
+
dlog("warn", `frame parse failed: ${err.message}`);
|
|
553
|
+
respond(
|
|
554
|
+
{
|
|
555
|
+
ok: false,
|
|
556
|
+
error: err.message,
|
|
557
|
+
code: "BAD_REQUEST",
|
|
558
|
+
daemonV: PROTOCOL_VERSION,
|
|
559
|
+
},
|
|
560
|
+
null,
|
|
561
|
+
);
|
|
562
|
+
},
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
sock.on("data", reader);
|
|
566
|
+
sock.on("error", (err) => {
|
|
567
|
+
dlog("warn", `socket error: ${err.message}`);
|
|
568
|
+
});
|
|
569
|
+
sock.on("close", () => {
|
|
570
|
+
clearTimeout(timer);
|
|
571
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
572
|
+
stats.inFlight = inFlight;
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// [LAW:no-ambient-temporal-coupling] A request whose semantics include "then
|
|
577
|
+
// exit" (the shutdown verb, the stale-binary version mismatch) must not exit
|
|
578
|
+
// until its response has flushed — but handleRequest cannot see the socket.
|
|
579
|
+
// So the exit is returned as DATA (the exit code; null = stay up) and the
|
|
580
|
+
// connection boundary, which owns the flush, sequences shutdown on the write
|
|
581
|
+
// completion. No timer stands between respond and exit.
|
|
582
|
+
// [LAW:effects-at-boundaries] handleRequest computes the description; the
|
|
583
|
+
// socket boundary performs it.
|
|
584
|
+
interface HandledRequest {
|
|
585
|
+
resp: Response;
|
|
586
|
+
exitAfterFlush: number | null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const stay = (resp: Response): HandledRequest => ({
|
|
590
|
+
resp,
|
|
591
|
+
exitAfterFlush: null,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
595
|
+
if (
|
|
596
|
+
!req ||
|
|
597
|
+
typeof req !== "object" ||
|
|
598
|
+
typeof (req as Request).v !== "number"
|
|
599
|
+
) {
|
|
600
|
+
return stay({
|
|
601
|
+
ok: false,
|
|
602
|
+
error: "malformed request",
|
|
603
|
+
code: "BAD_REQUEST",
|
|
604
|
+
daemonV: PROTOCOL_VERSION,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (req.v !== PROTOCOL_VERSION) {
|
|
609
|
+
// [LAW:types-are-the-program] The asymmetry is data, not control flow.
|
|
610
|
+
// client > daemon: the *binary* probably upgraded under us. Exit so the
|
|
611
|
+
// next client respawns from the current artifact.
|
|
612
|
+
// client < daemon: the *client* is stale. Respawning daemon does not
|
|
613
|
+
// help (the new daemon will have the same version). Stay up and
|
|
614
|
+
// return VERSION_MISMATCH — the client is responsible for surfacing
|
|
615
|
+
// the diagnostic and refusing to kick. Shutting down here was the
|
|
616
|
+
// load-bearing half of the 452-corpse spiral (kz8.5).
|
|
617
|
+
if (req.v > PROTOCOL_VERSION) {
|
|
618
|
+
dlog(
|
|
619
|
+
"info",
|
|
620
|
+
`version mismatch: client=${req.v} > daemon=${PROTOCOL_VERSION}; binary likely upgraded — exiting after the response flushes`,
|
|
621
|
+
);
|
|
622
|
+
} else {
|
|
623
|
+
dlog(
|
|
624
|
+
"info",
|
|
625
|
+
`version mismatch: client=${req.v} < daemon=${PROTOCOL_VERSION}; client is stale — staying up`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
resp: {
|
|
630
|
+
ok: false,
|
|
631
|
+
error: `protocol v${req.v} not supported (daemon at v${PROTOCOL_VERSION})`,
|
|
632
|
+
code: "VERSION_MISMATCH",
|
|
633
|
+
daemonV: PROTOCOL_VERSION,
|
|
634
|
+
},
|
|
635
|
+
// [LAW:dataflow-not-control-flow] The asymmetry above is this value.
|
|
636
|
+
// Exit is sequenced on the response flush, so the client always sees
|
|
637
|
+
// the VERSION_MISMATCH diagnostic — never a dead socket.
|
|
638
|
+
exitAfterFlush: req.v > PROTOCOL_VERSION ? 0 : null,
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (req.kind === "shutdown") {
|
|
643
|
+
return { resp: { ok: true, output: "" }, exitAfterFlush: 0 };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (req.kind === "stats") {
|
|
647
|
+
// [LAW:single-enforcer] Stats requests do NOT bump request counters —
|
|
648
|
+
// observability shouldn't pollute the metric being observed.
|
|
649
|
+
return stay({
|
|
650
|
+
ok: true,
|
|
651
|
+
stats: stats.snapshot({
|
|
652
|
+
gitCache: gitService.getStats(),
|
|
653
|
+
usageCache: usageStore.getStats(),
|
|
654
|
+
renderCacheSize: renderCache.size,
|
|
655
|
+
watchersActive: watcherRegistry.size(),
|
|
656
|
+
nextRestartReason: limits?.describeNextRestart() ?? null,
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
if (req.kind === "render") {
|
|
662
|
+
stats.requestsTotal++;
|
|
663
|
+
const t0 = Date.now();
|
|
664
|
+
try {
|
|
665
|
+
// [LAW:single-enforcer] One trust-boundary check for incoming hookData.
|
|
666
|
+
// The validator reports missing/wrong-typed required fields and unknown
|
|
667
|
+
// top-level keys. Required-field problems are *protocol* failures
|
|
668
|
+
// (Claude Code's schema guarantees these — their absence means the
|
|
669
|
+
// sender is broken or malicious); unknown fields are advisory (Anthropic
|
|
670
|
+
// may have added something).
|
|
671
|
+
const { report } = validateHookData(req.hookData as unknown);
|
|
672
|
+
for (const field of report.unknownTopLevelFields) {
|
|
673
|
+
dlog(
|
|
674
|
+
"info",
|
|
675
|
+
`schema: unknown field '${field}' — Anthropic may have added it`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
// [LAW:no-silent-fallbacks][LAW:types-are-the-program] Gate hard on
|
|
679
|
+
// schema violations. Continuing with `workspace?.project_dir` would
|
|
680
|
+
// collapse "absent" into an empty-string cache key — silently sharing
|
|
681
|
+
// one entry across every malformed request — and downstream code would
|
|
682
|
+
// have to defend against an empty projectDir forever. Reject here so
|
|
683
|
+
// the types downstream carry the strongest true theorem: by the time
|
|
684
|
+
// a cache entry is built, projectDir/cwd are real non-empty strings.
|
|
685
|
+
const wireProblems: string[] = [];
|
|
686
|
+
for (const path of report.missingRequired) {
|
|
687
|
+
wireProblems.push(`missing required field '${path}'`);
|
|
688
|
+
}
|
|
689
|
+
for (const { path, expected, got } of report.typeMismatches) {
|
|
690
|
+
wireProblems.push(`field '${path}' expected ${expected}, got ${got}`);
|
|
691
|
+
}
|
|
692
|
+
if (req.cwd === "") {
|
|
693
|
+
wireProblems.push("request 'cwd' is empty");
|
|
694
|
+
}
|
|
695
|
+
if (wireProblems.length > 0) {
|
|
696
|
+
stats.requestsErrored++;
|
|
697
|
+
dlog("warn", `BAD_REQUEST: ${wireProblems.join("; ")}`);
|
|
698
|
+
return stay({
|
|
699
|
+
ok: false,
|
|
700
|
+
error: `malformed hookData: ${wireProblems.join("; ")}`,
|
|
701
|
+
code: "BAD_REQUEST",
|
|
702
|
+
daemonV: PROTOCOL_VERSION,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const projectDir = req.hookData.workspace.project_dir;
|
|
706
|
+
// [LAW:dataflow-not-control-flow] thread the *request's* cwd, not the
|
|
707
|
+
// daemon's process.cwd(), so config resolution depends only on request
|
|
708
|
+
// data — the daemon's own working directory must not influence output.
|
|
709
|
+
const { configFile, unknownFlagsError } = parseRenderArgs(req.args);
|
|
710
|
+
// [LAW:effects-at-boundaries] The load-config verb writes per-session
|
|
711
|
+
// config overrides into SessionState; this is the one read point.
|
|
712
|
+
const sessionId = req.hookData.session_id;
|
|
713
|
+
const sessionConfigFile =
|
|
714
|
+
sessionState.get(sessionId, SESSION_CONFIG_OVERRIDE_KEY) ?? configFile;
|
|
715
|
+
const entry = renderCache.getOrCreate(
|
|
716
|
+
projectDir,
|
|
717
|
+
req.cwd,
|
|
718
|
+
sessionConfigFile,
|
|
719
|
+
);
|
|
720
|
+
// [LAW:single-enforcer] Width capture lives at the wire boundary.
|
|
721
|
+
// The client (Rust + TTY) is the only process that can see the real
|
|
722
|
+
// terminal; the daemon is detached. We do NOT consult getTerminalWidth's
|
|
723
|
+
// env/stderr fallbacks here — they would let the daemon's stale
|
|
724
|
+
// launch-time COLUMNS env shape rendering for a different terminal,
|
|
725
|
+
// which is exactly the wrong source.
|
|
726
|
+
// [LAW:one-source-of-truth] Both branches feed raw cols through
|
|
727
|
+
// applyClaudeCodeReserve, so `width` always means "usable cells
|
|
728
|
+
// post-reserve" with no semantic split between wire-supplied and
|
|
729
|
+
// fallback values.
|
|
730
|
+
const termCols = sanitizeTermCols(req.termCols);
|
|
731
|
+
const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
|
|
732
|
+
const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
|
|
733
|
+
// [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
|
|
734
|
+
// body = state ? renderDsl(state) : "" ; output = body + icon
|
|
735
|
+
// No special-case branches — same composition every render.
|
|
736
|
+
let body = "";
|
|
737
|
+
if (entry.state !== null) {
|
|
738
|
+
const payload = await buildRenderPayload(
|
|
739
|
+
req.hookData,
|
|
740
|
+
payloadDeps,
|
|
741
|
+
req.cwd,
|
|
742
|
+
entry.state.neededInputPaths,
|
|
743
|
+
);
|
|
744
|
+
// [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
|
|
745
|
+
// is derived per render from the effective theme — the session's chosen
|
|
746
|
+
// theme (SessionState) over the config default — so a theme click
|
|
747
|
+
// recolors the whole bar on the next render. Not frozen on the cache
|
|
748
|
+
// entry (one entry serves many sessions). resolverForThemeName memoizes,
|
|
749
|
+
// so the per-render cost is one Map lookup once the theme is warm.
|
|
750
|
+
const basePalette = resolverForThemeName(
|
|
751
|
+
effectiveThemeName(
|
|
752
|
+
sessionState.get(req.hookData.session_id, "theme"),
|
|
753
|
+
entry.state.config.globals.palette,
|
|
754
|
+
),
|
|
755
|
+
);
|
|
756
|
+
// [LAW:single-enforcer] renderDsl internally calls
|
|
757
|
+
// `registry.applyInput(payload)` as its first step (see step 1 in
|
|
758
|
+
// src/dsl/render.ts). The daemon must not pre-apply — doing so
|
|
759
|
+
// would run the MobX action twice per render and clear last_error
|
|
760
|
+
// diagnostics on the round trip.
|
|
761
|
+
body = renderDsl(
|
|
762
|
+
entry.state.config,
|
|
763
|
+
entry.state.compiled,
|
|
764
|
+
entry.state.store,
|
|
765
|
+
entry.state.registry,
|
|
766
|
+
payload,
|
|
767
|
+
basePalette,
|
|
768
|
+
renderOpts,
|
|
769
|
+
// [LAW:single-enforcer] The per-segment StripCell sink for the
|
|
770
|
+
// `debug segments` projection. Its identity stays stable for the
|
|
771
|
+
// cache entry's lifetime; renderDsl clears + repopulates it
|
|
772
|
+
// in place. Cells are cheap (already computed during the render);
|
|
773
|
+
// the per-segment ANSI serialization happens lazily inside the
|
|
774
|
+
// debug handler so normal renders pay no extra serializer cost.
|
|
775
|
+
entry.state.lastRenderCellsBySegment,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
// [LAW:one-source-of-truth] Consume the transient click error written by
|
|
779
|
+
// dispatch on partial/total effect failure, then clear it so it shows
|
|
780
|
+
// exactly once. Only called when non-null to avoid a no-op persist+MobX
|
|
781
|
+
// tick on every render.
|
|
782
|
+
const clickError = sessionState.get(
|
|
783
|
+
req.hookData.session_id,
|
|
784
|
+
"click.error",
|
|
785
|
+
);
|
|
786
|
+
if (clickError)
|
|
787
|
+
sessionState.clear(req.hookData.session_id, "click.error");
|
|
788
|
+
const combinedError =
|
|
789
|
+
[unknownFlagsError, entry.lastError, clickError]
|
|
790
|
+
.filter(Boolean)
|
|
791
|
+
.join("\n") || null;
|
|
792
|
+
const output = composeWithDiagnostics(
|
|
793
|
+
body,
|
|
794
|
+
combinedError,
|
|
795
|
+
entry.lastWarning,
|
|
796
|
+
);
|
|
797
|
+
const ms = Date.now() - t0;
|
|
798
|
+
const g = gitService.getStats();
|
|
799
|
+
const u = usageStore.getStats();
|
|
800
|
+
dlog(
|
|
801
|
+
"info",
|
|
802
|
+
`render sid=${req.hookData.session_id ?? "?"} took=${ms}ms termCols=${termCols ?? "?"} width=${width} git=${g.size}/${g.hits}h/${g.misses}m usage=${u.size}/${u.hits}h/${u.misses}m err=${entry.lastError ? "Y" : "N"} warn=${entry.lastWarning ? "Y" : "N"}`,
|
|
803
|
+
);
|
|
804
|
+
return stay({ ok: true, output: output + "\n" });
|
|
805
|
+
} catch (e) {
|
|
806
|
+
stats.requestsErrored++;
|
|
807
|
+
throw e;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (req.kind === "click") {
|
|
812
|
+
return stay(await handleClick(req.verb, req.value));
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (req.kind === "debug") {
|
|
816
|
+
// [LAW:single-enforcer] One trust-boundary check at the wire edge —
|
|
817
|
+
// `what` is untrusted JSON. isDebugWhat narrows it to the discriminated
|
|
818
|
+
// union the introspector consumes; an invalid value short-circuits
|
|
819
|
+
// here, not deep inside buildDebugSnapshot.
|
|
820
|
+
if (!isDebugWhat(req.what)) {
|
|
821
|
+
return stay({
|
|
822
|
+
ok: false,
|
|
823
|
+
// [LAW:errors-context-in-errors] Include the allowed values so a
|
|
824
|
+
// CLI consumer (or operator) sees what is supported without
|
|
825
|
+
// grep — same pattern as the set-state verb's unknown-key error
|
|
826
|
+
// in src/daemon/verbs/state-validators.ts.
|
|
827
|
+
error: `unknown debug 'what': ${String(req.what)} (have: ${DEBUG_WHATS.join(", ")})`,
|
|
828
|
+
code: "BAD_REQUEST",
|
|
829
|
+
daemonV: PROTOCOL_VERSION,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
// [LAW:dataflow-not-control-flow] The debug projection samples whatever
|
|
833
|
+
// DSL state the cache currently holds. With cache keys scoped on
|
|
834
|
+
// (projectDir, cwd) and the debug request carrying neither, we sample
|
|
835
|
+
// the first populated existing entry — sufficient for `debug vars`,
|
|
836
|
+
// `debug segments`, `debug config` against the active workload.
|
|
837
|
+
// firstPopulatedState iterates existing entries only; it does NOT
|
|
838
|
+
// create a fresh one, so debug introspection never has the side effect
|
|
839
|
+
// of standing up a new (projectDir=undefined) cache entry tied to the
|
|
840
|
+
// daemon's own process.cwd(). A future debug-target selector would
|
|
841
|
+
// thread (projectDir, cwd) through the wire.
|
|
842
|
+
const dbgEntry = renderCache.firstPopulatedState();
|
|
843
|
+
// [LAW:dataflow-not-control-flow] Lazy per-segment serialization: the
|
|
844
|
+
// cache stores StripCell arrays (cheap, written by renderDsl).
|
|
845
|
+
// The debug projection needs strings, so serialize only for the
|
|
846
|
+
// `segments` projection (`vars` and `config` don't need it) and only
|
|
847
|
+
// when this request actually fires. Normal renders pay no per-segment
|
|
848
|
+
// serializer cost — that work shifts to debug-request time, which is
|
|
849
|
+
// operator-driven and rare.
|
|
850
|
+
const dbgState =
|
|
851
|
+
dbgEntry === null
|
|
852
|
+
? null
|
|
853
|
+
: {
|
|
854
|
+
store: dbgEntry.store,
|
|
855
|
+
registry: dbgEntry.registry,
|
|
856
|
+
config: dbgEntry.config,
|
|
857
|
+
compiled: dbgEntry.compiled,
|
|
858
|
+
lastRenderBySegment:
|
|
859
|
+
req.what === "segments"
|
|
860
|
+
? serializeSegmentCells(dbgEntry.lastRenderCellsBySegment)
|
|
861
|
+
: EMPTY_RENDER_MAP,
|
|
862
|
+
};
|
|
863
|
+
return stay({ ok: true, debug: buildDebugSnapshot(req.what, dbgState) });
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return stay({
|
|
867
|
+
ok: false,
|
|
868
|
+
error: "unknown kind",
|
|
869
|
+
code: "BAD_REQUEST",
|
|
870
|
+
daemonV: PROTOCOL_VERSION,
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// --- diagnostics composition ---
|
|
875
|
+
//
|
|
876
|
+
// [LAW:no-silent-fallbacks] Bad config can't quietly degrade output. The
|
|
877
|
+
// render pipeline carries two independent diagnostic channels:
|
|
878
|
+
// error — load-fatal: parse/validation failed; bar is last-known-good
|
|
879
|
+
// or empty. Rendered red.
|
|
880
|
+
// warning — advisory: load succeeded but something needs attention (e.g.
|
|
881
|
+
// same-location .json5 + .json collision). Rendered amber.
|
|
882
|
+
// Either way the failure is visible at the point of impact, and each
|
|
883
|
+
// channel has its own click verb (show-config-error / show-config-warning)
|
|
884
|
+
// so the operator can copy the message to clipboard for inspection.
|
|
885
|
+
//
|
|
886
|
+
// [LAW:one-type-per-behavior] Two severities → two channels. The
|
|
887
|
+
// composer's signature carries both; severity is encoded in WHICH
|
|
888
|
+
// argument is non-null, not in a string prefix or a tag inside the
|
|
889
|
+
// message. The two icons render independently — both can show at once.
|
|
890
|
+
//
|
|
891
|
+
// [LAW:types-are-the-program] The diagnostic's visible text IS (a
|
|
892
|
+
// projection of) the underlying message — not a constant label that hides
|
|
893
|
+
// the content behind a click. The leading ⚠ + background color carry
|
|
894
|
+
// severity; the rest of the cell is the actual error/warning, sanitized
|
|
895
|
+
// and clipped to a single-line budget. A label divorced from the message
|
|
896
|
+
// would be the type lying about what's in the channel.
|
|
897
|
+
// [LAW:one-source-of-truth] Style constants come from the shared leaf
|
|
898
|
+
// (src/render/diagnostic-style.ts) — the same visual identity the client's
|
|
899
|
+
// permanent glyph uses. Only the OSC-8 link plumbing is local here.
|
|
900
|
+
const OSC8_OPEN = "\x1b]8;;";
|
|
901
|
+
const OSC8_CLOSE = "\x1b]8;;\x1b\\";
|
|
902
|
+
const ST = "\x1b\\";
|
|
903
|
+
|
|
904
|
+
// [LAW:single-enforcer][LAW:no-silent-fallbacks] Parse render-path args with
|
|
905
|
+
// the standard util at the trust boundary. `--config <path>` is the sole
|
|
906
|
+
// valid render flag; every other flag is surfaced as a render-time
|
|
907
|
+
// diagnostic icon (caller composes it alongside config errors). The
|
|
908
|
+
// `--config` value is `~`-expanded here, so every consumer downstream
|
|
909
|
+
// receives a literal path — no caller has to remember to expand it.
|
|
910
|
+
//
|
|
911
|
+
// `tokens: true, strict: false, allowPositionals: true` together let the
|
|
912
|
+
// parser emit a token entry for every flag (known or unknown) without
|
|
913
|
+
// throwing on unknown ones, and without mis-classifying their values as
|
|
914
|
+
// positionals.
|
|
915
|
+
function parseRenderArgs(args: string[]): {
|
|
916
|
+
configFile: string | undefined;
|
|
917
|
+
unknownFlagsError: string | null;
|
|
918
|
+
} {
|
|
919
|
+
const { values, tokens } = parseArgs({
|
|
920
|
+
args: args.slice(1), // skip binary path
|
|
921
|
+
options: { config: { type: "string" } },
|
|
922
|
+
strict: false,
|
|
923
|
+
tokens: true,
|
|
924
|
+
allowPositionals: true,
|
|
925
|
+
});
|
|
926
|
+
const unknown = [
|
|
927
|
+
...new Set(
|
|
928
|
+
(tokens ?? [])
|
|
929
|
+
.filter(
|
|
930
|
+
(t): t is Extract<typeof t, { kind: "option" }> =>
|
|
931
|
+
t.kind === "option" && t.name !== "config",
|
|
932
|
+
)
|
|
933
|
+
.map((t) => `--${t.name}`),
|
|
934
|
+
),
|
|
935
|
+
];
|
|
936
|
+
const rawConfig = values.config as string | undefined;
|
|
937
|
+
return {
|
|
938
|
+
configFile: rawConfig === undefined ? undefined : expandHome(rawConfig),
|
|
939
|
+
unknownFlagsError:
|
|
940
|
+
unknown.length > 0 ? `Unknown flags: ${unknown.join(", ")}` : null,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Per-line visible budget and max rows for multi-line diagnostic blocks.
|
|
945
|
+
// Messages from the config validator (formatIssues) are already structured
|
|
946
|
+
// as one line per issue, so splitting there is the natural unit of display.
|
|
947
|
+
// Deliberately decoupled from DEFAULT_TERMINAL_WIDTH: that constant means
|
|
948
|
+
// "raw terminal cols we assume" and is reserved-against before reaching the
|
|
949
|
+
// renderer; this one is a direct visible-char cap on already-rendered
|
|
950
|
+
// diagnostic text. They happen to share the value 120 today but have
|
|
951
|
+
// different semantic intents.
|
|
952
|
+
const MAX_DIAGNOSTIC_LINE_LEN = 120;
|
|
953
|
+
const MAX_DIAGNOSTIC_LINES = 8;
|
|
954
|
+
|
|
955
|
+
function makeDiagnosticLink(
|
|
956
|
+
verb: typeof VERB_SHOW_CONFIG_ERROR | typeof VERB_SHOW_CONFIG_WARNING,
|
|
957
|
+
message: string,
|
|
958
|
+
bg: string,
|
|
959
|
+
fg: string,
|
|
960
|
+
): string {
|
|
961
|
+
// Full message in the OSC-8 URL (clipboard-copy on click) — truncation
|
|
962
|
+
// only affects what is visible, never what is accessible. [LAW:single-enforcer]
|
|
963
|
+
// The click URL is born through effectsUrl like every other click — one
|
|
964
|
+
// single-effect dispatch list, no second URL-format in the codebase.
|
|
965
|
+
const url = effectsUrl([{ verb, args: [message] }]);
|
|
966
|
+
// [LAW:dataflow-not-control-flow] Split on natural line boundaries from
|
|
967
|
+
// the source message (config validator emits one issue per line), sanitize
|
|
968
|
+
// each line individually, then render each as a separate styled row.
|
|
969
|
+
// This preserves structured multi-line output instead of collapsing N
|
|
970
|
+
// issues into a single truncated string the user cannot read.
|
|
971
|
+
const lines = message
|
|
972
|
+
.split(/\r\n|\r|\n/)
|
|
973
|
+
.map((l) => sanitizeAndTruncate(l, MAX_DIAGNOSTIC_LINE_LEN))
|
|
974
|
+
.filter(Boolean)
|
|
975
|
+
.slice(0, MAX_DIAGNOSTIC_LINES);
|
|
976
|
+
if (lines.length === 0) return "";
|
|
977
|
+
const first = `${OSC8_OPEN}${url}${ST}${bg}${fg} ⚠ ${lines[0]} ${ANSI_RESET}${OSC8_CLOSE}`;
|
|
978
|
+
const rest = lines
|
|
979
|
+
.slice(1)
|
|
980
|
+
.map(
|
|
981
|
+
(l) =>
|
|
982
|
+
`${OSC8_OPEN}${url}${ST}${bg}${fg} ${l} ${ANSI_RESET}${OSC8_CLOSE}`,
|
|
983
|
+
);
|
|
984
|
+
return [first, ...rest].join("\n");
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function composeWithDiagnostics(
|
|
988
|
+
body: string,
|
|
989
|
+
error: string | null,
|
|
990
|
+
warning: string | null,
|
|
991
|
+
): string {
|
|
992
|
+
// [LAW:dataflow-not-control-flow] Diagnostics list is data; the
|
|
993
|
+
// composer walks it. Each non-null channel contributes one or more prefix
|
|
994
|
+
// rows (makeDiagnosticLink returns a \n-joined multi-line block when the
|
|
995
|
+
// message has natural line breaks). Order is error-first (more severe),
|
|
996
|
+
// then warning, then body.
|
|
997
|
+
const prefixes: string[] = [];
|
|
998
|
+
if (error) {
|
|
999
|
+
prefixes.push(
|
|
1000
|
+
makeDiagnosticLink(
|
|
1001
|
+
VERB_SHOW_CONFIG_ERROR,
|
|
1002
|
+
error,
|
|
1003
|
+
DIAGNOSTIC_ERROR_BG,
|
|
1004
|
+
DIAGNOSTIC_ERROR_FG,
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
if (warning) {
|
|
1009
|
+
prefixes.push(
|
|
1010
|
+
makeDiagnosticLink(
|
|
1011
|
+
VERB_SHOW_CONFIG_WARNING,
|
|
1012
|
+
warning,
|
|
1013
|
+
DIAGNOSTIC_WARNING_BG,
|
|
1014
|
+
DIAGNOSTIC_WARNING_FG,
|
|
1015
|
+
),
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
if (prefixes.length === 0) return body;
|
|
1019
|
+
// No body → emit the diagnostic strip alone (startup-error case). Body
|
|
1020
|
+
// present → prepend on its own line so it's visible regardless of bar
|
|
1021
|
+
// width. Multiple diagnostics stack on their own lines.
|
|
1022
|
+
const strip = prefixes.join("\n");
|
|
1023
|
+
return body ? `${strip}\n${body}` : strip;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// --- click verb dispatch ---
|
|
1027
|
+
// [LAW:dataflow-not-control-flow] The dispatcher is a table lookup. The verb
|
|
1028
|
+
// table (src/daemon/verbs/index.ts) is the single canonical list of supported
|
|
1029
|
+
// verbs — handlers live there, the dispatcher only routes.
|
|
1030
|
+
//
|
|
1031
|
+
// [LAW:types-are-the-program] The error class on the throw determines the
|
|
1032
|
+
// response code: BadVerbArgs (invalid input shape) becomes BAD_REQUEST; any
|
|
1033
|
+
// other Error (operational failure) becomes RENDER_FAILED. No string matching.
|
|
1034
|
+
|
|
1035
|
+
const verbCtx = { sessionState, dlog };
|
|
1036
|
+
|
|
1037
|
+
// [LAW:single-enforcer] Style + color compatibility shared by the render
|
|
1038
|
+
// path and the lazy debug-side per-segment serializer. Per-request `width`
|
|
1039
|
+
// is composed on top at the wire boundary (handleRequest("render")) and
|
|
1040
|
+
// passed through as renderOpts. Debug serialization composes its own
|
|
1041
|
+
// per-segment opts with width: Number.POSITIVE_INFINITY since each segment
|
|
1042
|
+
// is rendered standalone (wrap doesn't apply to a one-segment projection).
|
|
1043
|
+
const RENDER_OPTS_BASE = {
|
|
1044
|
+
style: "powerline" as const,
|
|
1045
|
+
colorCompatibility: "truecolor" as const,
|
|
1046
|
+
};
|
|
1047
|
+
const DEBUG_RENDER_OPTS: BuildLineOptions = {
|
|
1048
|
+
...RENDER_OPTS_BASE,
|
|
1049
|
+
width: Number.POSITIVE_INFINITY,
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
// [LAW:no-defensive-null-guards] Reused empty map for the `vars` /
|
|
1053
|
+
// `config` debug projections — they don't read lastRenderBySegment but
|
|
1054
|
+
// the DaemonDslState type requires the field.
|
|
1055
|
+
const EMPTY_RENDER_MAP = new Map<string, string>();
|
|
1056
|
+
|
|
1057
|
+
function serializeSegmentCells(
|
|
1058
|
+
cells: ReadonlyMap<string, readonly RichText[]>,
|
|
1059
|
+
): Map<string, string> {
|
|
1060
|
+
const out = new Map<string, string>();
|
|
1061
|
+
for (const [name, segCells] of cells) {
|
|
1062
|
+
out.set(name, renderStripCells(segCells, DEBUG_RENDER_OPTS));
|
|
1063
|
+
}
|
|
1064
|
+
return out;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// [LAW:single-enforcer] The payload-builder dependency bundle. One value
|
|
1068
|
+
// passed through every render — the data the daemon brings to each tick.
|
|
1069
|
+
const payloadDeps = {
|
|
1070
|
+
gitProvider: gitService,
|
|
1071
|
+
usageStore,
|
|
1072
|
+
contextProvider,
|
|
1073
|
+
metricsProvider,
|
|
1074
|
+
tmuxService,
|
|
1075
|
+
sessionState,
|
|
1076
|
+
// [LAW:single-enforcer] buildRenderPayload is the one log site for the
|
|
1077
|
+
// outcome-carrying provider lanes (git, cache).
|
|
1078
|
+
log: dlog,
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
function handleClick(verb: string, value: string): Response {
|
|
1082
|
+
const handler = VERBS.get(verb);
|
|
1083
|
+
if (!handler) {
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
error: `unknown click verb: ${verb}`,
|
|
1087
|
+
code: "BAD_REQUEST",
|
|
1088
|
+
daemonV: PROTOCOL_VERSION,
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
try {
|
|
1092
|
+
handler(value, verbCtx);
|
|
1093
|
+
return { ok: true, output: "" };
|
|
1094
|
+
} catch (e) {
|
|
1095
|
+
const code = e instanceof BadVerbArgs ? "BAD_REQUEST" : "RENDER_FAILED";
|
|
1096
|
+
return {
|
|
1097
|
+
ok: false,
|
|
1098
|
+
error: String(e instanceof Error ? e.message : e),
|
|
1099
|
+
code,
|
|
1100
|
+
daemonV: PROTOCOL_VERSION,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
}
|