@promptctl/cc-candybar 1.42.1 → 1.43.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/dist/index.mjs +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
package/src/daemon/server.ts
DELETED
|
@@ -1,1330 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import net from "node:net";
|
|
3
|
-
import v8 from "node:v8";
|
|
4
|
-
import process from "node:process";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { parseArgs } from "node:util";
|
|
7
|
-
import {
|
|
8
|
-
daemonDir,
|
|
9
|
-
ensureSocketParentSafe,
|
|
10
|
-
leasePath,
|
|
11
|
-
leasePathFor,
|
|
12
|
-
socketPath,
|
|
13
|
-
sessionStatePath,
|
|
14
|
-
} from "./paths";
|
|
15
|
-
import {
|
|
16
|
-
arbitrateSocket,
|
|
17
|
-
readLease,
|
|
18
|
-
removeLeaseIfOwned,
|
|
19
|
-
writeLease,
|
|
20
|
-
} from "./socket-lease";
|
|
21
|
-
import {
|
|
22
|
-
makeOwnershipWatch,
|
|
23
|
-
readSocketIdentity,
|
|
24
|
-
type SocketIdentity,
|
|
25
|
-
} from "./socket-ownership";
|
|
26
|
-
import {
|
|
27
|
-
readStartTime,
|
|
28
|
-
readOwnStartTime,
|
|
29
|
-
sameLiveProcess,
|
|
30
|
-
} from "./process-fingerprint";
|
|
31
|
-
import {
|
|
32
|
-
admitDaemon,
|
|
33
|
-
realBreakerDeps,
|
|
34
|
-
releaseRegistration,
|
|
35
|
-
readRegistryEntry,
|
|
36
|
-
} from "./fork-bomb-breaker";
|
|
37
|
-
import { dlog } from "./log";
|
|
38
|
-
import {
|
|
39
|
-
PROTOCOL_VERSION,
|
|
40
|
-
encodeFrame,
|
|
41
|
-
makeFrameReader,
|
|
42
|
-
parseClientHints,
|
|
43
|
-
} from "./protocol";
|
|
44
|
-
import type { Request, Response } from "./protocol";
|
|
45
|
-
import { GitDataProvider } from "./cache/git";
|
|
46
|
-
import { SessionUsageStore } from "./cache/session-usage-store";
|
|
47
|
-
import { RenderCache } from "./cache/render";
|
|
48
|
-
import { WatcherRegistry } from "./cache/watchers";
|
|
49
|
-
import { RuntimeStats } from "./stats";
|
|
50
|
-
import {
|
|
51
|
-
makeLimits,
|
|
52
|
-
realLimitsDeps,
|
|
53
|
-
rssLimitBytes,
|
|
54
|
-
type LimitsHandle,
|
|
55
|
-
} from "./limits";
|
|
56
|
-
import { armParentWatchdog, anchorFromEnv, pidAlive } from "./parent-watchdog";
|
|
57
|
-
import { resetSpawnBackoff } from "./acquire";
|
|
58
|
-
import { SessionState } from "./session-state";
|
|
59
|
-
import { FileSessionStorage } from "./session-state-file";
|
|
60
|
-
import { VERBS, BadVerbArgs, SESSION_CONFIG_OVERRIDE_KEY } from "./verbs";
|
|
61
|
-
import {
|
|
62
|
-
effectsUrl,
|
|
63
|
-
VERB_SHOW_CONFIG_ERROR,
|
|
64
|
-
VERB_SHOW_CONFIG_WARNING,
|
|
65
|
-
} from "../click/wire.js";
|
|
66
|
-
import { validateHookData } from "../utils/schema-validator.js";
|
|
67
|
-
import { setLaunchStats } from "../proc/launch";
|
|
68
|
-
import { buildDebugSnapshot } from "./debug";
|
|
69
|
-
import { DEBUG_WHATS, isDebugWhat } from "./debug-types";
|
|
70
|
-
import { expandHome } from "../config/dsl-loader.js";
|
|
71
|
-
import { renderDsl } from "../dsl/render.js";
|
|
72
|
-
import { lookKeyByName, paletteForThemeName } from "../themes/index.js";
|
|
73
|
-
import { presetIsCustomized } from "../config/presets.js";
|
|
74
|
-
import {
|
|
75
|
-
renderStripCells,
|
|
76
|
-
DEFAULT_CHARSET,
|
|
77
|
-
DEFAULT_COLOR_COMPATIBILITY,
|
|
78
|
-
DEFAULT_PADDING,
|
|
79
|
-
DEFAULT_TERMINAL_WIDTH,
|
|
80
|
-
DEFAULT_WRAP,
|
|
81
|
-
type BuildLineOptions,
|
|
82
|
-
type Charset,
|
|
83
|
-
type ColorCompatibility,
|
|
84
|
-
} from "../render/strip.js";
|
|
85
|
-
import { applyClaudeCodeReserve } from "../utils/terminal-width.js";
|
|
86
|
-
import type { RichText } from "@promptctl/rich-js";
|
|
87
|
-
import {
|
|
88
|
-
buildRenderPayload,
|
|
89
|
-
resolveEffectiveGlobals,
|
|
90
|
-
type EffectiveGlobals,
|
|
91
|
-
} from "./render-payload.js";
|
|
92
|
-
import { ContextProvider } from "../segments/context.js";
|
|
93
|
-
import { MetricsProvider } from "../segments/metrics.js";
|
|
94
|
-
import { TmuxService } from "../segments/tmux.js";
|
|
95
|
-
import { sanitizeAndTruncate } from "../render/diagnostic-text.js";
|
|
96
|
-
import {
|
|
97
|
-
ANSI_RESET,
|
|
98
|
-
DIAGNOSTIC_ERROR_BG,
|
|
99
|
-
DIAGNOSTIC_ERROR_FG,
|
|
100
|
-
DIAGNOSTIC_WARNING_BG,
|
|
101
|
-
DIAGNOSTIC_WARNING_FG,
|
|
102
|
-
} from "../render/diagnostic-style.js";
|
|
103
|
-
|
|
104
|
-
// [LAW:one-source-of-truth] one cache instance per daemon process — multiple
|
|
105
|
-
// instances would defeat the share-across-sessions invariant.
|
|
106
|
-
const stats = new RuntimeStats();
|
|
107
|
-
// [LAW:single-enforcer] Route all child_process spawns through src/proc/launch.
|
|
108
|
-
// Installing the metering handle here makes subprocess counts visible in
|
|
109
|
-
// daemon-stats.
|
|
110
|
-
setLaunchStats(stats.launchStats);
|
|
111
|
-
// [LAW:single-enforcer] The daemon injects `dlog` into both registries so
|
|
112
|
-
// cache + watcher lifecycle events land in daemon.log at the right level.
|
|
113
|
-
// Non-daemon consumers (var-system tests, future library use) take the
|
|
114
|
-
// default debug-routed loggers and never write to daemon log files.
|
|
115
|
-
const watcherRegistry = new WatcherRegistry({
|
|
116
|
-
counters: stats,
|
|
117
|
-
logger: dlog,
|
|
118
|
-
});
|
|
119
|
-
const gitService = new GitDataProvider({
|
|
120
|
-
watchers: watcherRegistry,
|
|
121
|
-
logger: dlog,
|
|
122
|
-
});
|
|
123
|
-
const usageStore = new SessionUsageStore();
|
|
124
|
-
// [LAW:locality-or-seam] Constructed ephemeral so importing this module (CLI
|
|
125
|
-
// relay, subcommands) does no disk I/O. The daemon binds the file-backed
|
|
126
|
-
// storage in runDaemon(), making it the sole reader/writer of the state file.
|
|
127
|
-
const sessionState = new SessionState();
|
|
128
|
-
// [LAW:one-source-of-truth] One provider per data shape, shared across every
|
|
129
|
-
// render in this daemon. The render cache owns DSL-state-per-config; these
|
|
130
|
-
// providers serve the augmented payload that flows through every render.
|
|
131
|
-
const contextProvider = new ContextProvider();
|
|
132
|
-
const metricsProvider = new MetricsProvider();
|
|
133
|
-
const tmuxService = new TmuxService();
|
|
134
|
-
const renderCache = new RenderCache(
|
|
135
|
-
{
|
|
136
|
-
gitService,
|
|
137
|
-
sessionState,
|
|
138
|
-
watchers: watcherRegistry,
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
observers: {
|
|
142
|
-
// [LAW:no-silent-failure] Every config (re)load's outcome lands in
|
|
143
|
-
// daemon.log beside the "config change detected" line that preceded it
|
|
144
|
-
// — the operator's only record of whether a save was picked up cleanly,
|
|
145
|
-
// kept rendering last-known-good behind an error, or resolved to a
|
|
146
|
-
// different file. Same info level as the detection line.
|
|
147
|
-
onReload: (entry) =>
|
|
148
|
-
dlog(
|
|
149
|
-
"info",
|
|
150
|
-
`config loaded projectDir=${entry.projectDir} cwd=${entry.cwd} file=${entry.configFilePath ?? "<bundled default>"} error=${entry.lastError === null ? "none" : JSON.stringify(entry.lastError)} warning=${entry.lastWarning === null ? "none" : JSON.stringify(entry.lastWarning)}`,
|
|
151
|
-
),
|
|
152
|
-
},
|
|
153
|
-
},
|
|
154
|
-
);
|
|
155
|
-
|
|
156
|
-
const REQUEST_TIMEOUT_MS = 200;
|
|
157
|
-
const BIN_CHECK_INTERVAL_MS = 60 * 1000;
|
|
158
|
-
|
|
159
|
-
// Daemon entry point. Tries to bind the Unix socket — atomic bind() is the
|
|
160
|
-
// single-instance enforcer (two daemons cannot both bind the same path; the
|
|
161
|
-
// kernel makes duplicate-daemon unrepresentable). Listens for one request per
|
|
162
|
-
// connection. Any uncaught error exits non-zero; the next client obtains a
|
|
163
|
-
// fresh daemon via obtainDaemonKick() (fire-and-forget caller) or
|
|
164
|
-
// obtainDaemon() (caller waits for readiness) in src/daemon/acquire.ts.
|
|
165
|
-
// [LAW:one-source-of-truth] Our own kernel start-time, read once at startup and
|
|
166
|
-
// stamped into our lease so a future daemon's arbitration can prove whether our
|
|
167
|
-
// pid still names THIS process or a recycled ghost (process-fingerprint.ts).
|
|
168
|
-
// null when this host cannot fingerprint (no `ps`) — readers then fall back to
|
|
169
|
-
// kill(pid,0), no worse than before the fingerprint existed.
|
|
170
|
-
let myStartTime: string | null = null;
|
|
171
|
-
|
|
172
|
-
// The registry path this daemon claimed in the fork-bomb breaker's population
|
|
173
|
-
// registry (fork-bomb-breaker.ts), or null when exempt (the canonical
|
|
174
|
-
// production socket) or never reached (refused before claiming one). Released
|
|
175
|
-
// on shutdown so a graceful exit frees its slot immediately rather than
|
|
176
|
-
// waiting for the next boot's stale-sweep.
|
|
177
|
-
let breakerRegistryPath: string | null = null;
|
|
178
|
-
|
|
179
|
-
// The parsed memory budget (bytes); set first thing in runDaemon.
|
|
180
|
-
let budgetBytes = 0;
|
|
181
|
-
|
|
182
|
-
export function runDaemon(): void {
|
|
183
|
-
// Catch-alls log + exit so the supervisor (the next client) can restart us.
|
|
184
|
-
// [LAW:no-defensive-null-guards] These are *trust boundaries* — we are
|
|
185
|
-
// catching all of unknown space, not skipping known optional values.
|
|
186
|
-
// [LAW:single-enforcer] Registered FIRST, before any of the startup calls
|
|
187
|
-
// below that can throw synchronously (admitDaemon's ensureDirSafe/writeEntry,
|
|
188
|
-
// ensureSocketParentSafe) — otherwise an early throw is a raw unhandled
|
|
189
|
-
// exception (stack trace to stderr, bypassing the clean shutdown(1) log +
|
|
190
|
-
// SIGKILL backstop) rather than funneling through the same death path as
|
|
191
|
-
// every other failure mode.
|
|
192
|
-
process.on("uncaughtException", (err) => {
|
|
193
|
-
dlog("error", `uncaughtException: ${err.stack || err.message}`);
|
|
194
|
-
shutdown(1);
|
|
195
|
-
});
|
|
196
|
-
process.on("unhandledRejection", (reason) => {
|
|
197
|
-
dlog("error", `unhandledRejection: ${String(reason)}`);
|
|
198
|
-
shutdown(1);
|
|
199
|
-
});
|
|
200
|
-
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
|
201
|
-
process.on(sig, () => {
|
|
202
|
-
dlog("info", `received ${sig}, shutting down`);
|
|
203
|
-
shutdown(0);
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// [LAW:effects-at-boundaries] The memory budget is parsed here, before any
|
|
208
|
-
// resource is committed — a malformed override is refused before the breaker
|
|
209
|
-
// registers us, before the bind, before the lease, and before a `daemon up`
|
|
210
|
-
// line could claim a boot that is about to die. Parsed once, threaded into
|
|
211
|
-
// armLimits and the boot line.
|
|
212
|
-
// [LAW:single-enforcer] Refused through the same death funnel as every other
|
|
213
|
-
// boot failure. A synchronous throw here is NOT uncaught — it lands in
|
|
214
|
-
// index.ts's catch, whose stderr the detached spawn discards — so the one
|
|
215
|
-
// line that says why the daemon never came up would go nowhere.
|
|
216
|
-
try {
|
|
217
|
-
budgetBytes = rssLimitBytes(process.env);
|
|
218
|
-
} catch (err) {
|
|
219
|
-
dlog("error", `refusing to boot: ${(err as Error).message}`);
|
|
220
|
-
shutdown(1);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// [LAW:single-enforcer] The fork-bomb circuit breaker runs FIRST among the
|
|
225
|
-
// resource-committing steps (no dir created, no socket touched, no session
|
|
226
|
-
// state loaded) — the whole point of a load-independent backstop is that it
|
|
227
|
-
// holds even when everything downstream of it is thrashing. Own start-time
|
|
228
|
-
// must be read first: it is both this check's identity and the lease's
|
|
229
|
-
// fingerprint later, so it is read exactly once and threaded through both
|
|
230
|
-
// (see realBreakerDeps' doc comment).
|
|
231
|
-
myStartTime = readOwnStartTime(process.pid);
|
|
232
|
-
const admission = admitDaemon(realBreakerDeps(myStartTime));
|
|
233
|
-
if (!admission.decision.allow) {
|
|
234
|
-
dlog(
|
|
235
|
-
"warn",
|
|
236
|
-
`fork-bomb breaker: ${admission.decision.reason}; refusing to boot`,
|
|
237
|
-
);
|
|
238
|
-
shutdown(1);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
breakerRegistryPath = admission.registryPath;
|
|
242
|
-
|
|
243
|
-
fs.mkdirSync(daemonDir(), { recursive: true });
|
|
244
|
-
// [LAW:single-enforcer] Verify the socket parent is uid==me + mode 0700 +
|
|
245
|
-
// not a symlink before we bind. Without this check, a same-host attacker
|
|
246
|
-
// could pre-create the predictable `/tmp/cc-candybar-<uid>` directory and
|
|
247
|
-
// squat the socket name. The check applies regardless of CC_CANDYBAR_SOCKET
|
|
248
|
-
// location — every bind path goes through the same trust precondition.
|
|
249
|
-
// No symmetric client-side check: the daemon is the sole creator, so a
|
|
250
|
-
// successful bind already proves the parent is trusted. Failure here surfaces
|
|
251
|
-
// as a daemon exit; the client falls back to the last cached render.
|
|
252
|
-
ensureSocketParentSafe(socketPath());
|
|
253
|
-
|
|
254
|
-
// Bind disk persistence now that we know we are the daemon process — load
|
|
255
|
-
// prior session state and become the sole writer of the state file.
|
|
256
|
-
sessionState.useStorage(
|
|
257
|
-
new FileSessionStorage(sessionStatePath(), 500, dlog),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
// [LAW:single-enforcer] Same death funnel as the signals and the RSS backstop:
|
|
261
|
-
// the watchdog calls shutdown(0), it never exits on its own. A production
|
|
262
|
-
// daemon has no spawner to outlive (env unset) and arms an inert handle; only
|
|
263
|
-
// a test-spawned daemon is anchored, so this is invisible to the real daemon.
|
|
264
|
-
armParentWatchdog({
|
|
265
|
-
anchor: anchorFromEnv(process.env),
|
|
266
|
-
isAlive: pidAlive,
|
|
267
|
-
onOrphaned: (reason) => {
|
|
268
|
-
dlog("info", `parent watchdog: ${reason}; shutting down`);
|
|
269
|
-
shutdown(0);
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
const server = net.createServer({ allowHalfOpen: false }, (sock) => {
|
|
274
|
-
handleConnection(sock);
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
// [LAW:single-enforcer] The atomic bind() is the daemon-singleton enforcer.
|
|
278
|
-
// Two daemons cannot both bind the same Unix socket path; the kernel makes
|
|
279
|
-
// duplicate-daemon unrepresentable. The pidfile is diagnostic only — never
|
|
280
|
-
// load-bearing for exclusion.
|
|
281
|
-
bindOrAttachAndExit(server, socketPath(), /* retried */ false);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// [LAW:dataflow-not-control-flow] One operation ("bring this server up or
|
|
285
|
-
// discover an existing one"). The bind result is the data that decides the
|
|
286
|
-
// next step; callers do not get to choose whether to spawn.
|
|
287
|
-
function bindOrAttachAndExit(
|
|
288
|
-
server: net.Server,
|
|
289
|
-
sockPath: string,
|
|
290
|
-
retried: boolean,
|
|
291
|
-
): void {
|
|
292
|
-
server.removeAllListeners("error");
|
|
293
|
-
// [LAW:no-ambient-temporal-coupling] server.listen(path, cb) registers cb as
|
|
294
|
-
// a ONE-TIME 'listening' listener. A first listen that fails EADDRINUSE never
|
|
295
|
-
// fires 'listening', so its callback stays pending; the reclaim retry adds a
|
|
296
|
-
// second. Without clearing the stale one, a successful rebind fires BOTH and
|
|
297
|
-
// onListening runs twice — double-arming the RSS backstop + watchers. Clear
|
|
298
|
-
// pending 'listening' listeners so exactly one onListening fires per bind.
|
|
299
|
-
server.removeAllListeners("listening");
|
|
300
|
-
server.once("error", (err) => {
|
|
301
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
302
|
-
if (code !== "EADDRINUSE") {
|
|
303
|
-
dlog("error", `server error: ${err.message}`);
|
|
304
|
-
shutdown(1);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (retried) {
|
|
308
|
-
// Lost a rebind race with another duplicate. The kernel arbitrated; we
|
|
309
|
-
// are the loser. Exit cleanly so the winner serves.
|
|
310
|
-
dlog("info", "lost rebind race; another daemon is alive — exiting");
|
|
311
|
-
process.exit(0);
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
handleAddressInUse(server, sockPath);
|
|
315
|
-
});
|
|
316
|
-
server.listen(sockPath, () => onListening(sockPath));
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// [LAW:one-source-of-truth] EADDRINUSE arbitration consults the socket-derived
|
|
320
|
-
// pid lease, NEVER a connect probe. The path already exists (a live daemon, or
|
|
321
|
-
// a stale file from a crashed one); the lease's owner pid + kill(pid,0) decides
|
|
322
|
-
// which. This is fully synchronous — reading the lease and testing liveness are
|
|
323
|
-
// both sync — so there is no await gap for a concurrent recoverer to race
|
|
324
|
-
// through (the old async probe had two such gaps and a hand-rolled re-check).
|
|
325
|
-
//
|
|
326
|
-
// [LAW:effects-at-boundaries] The decision is the pure arbitrateSocket fold
|
|
327
|
-
// over the lease read + injected pidAlive; the kill / unlink / rebind effects
|
|
328
|
-
// are performed here at the edge.
|
|
329
|
-
function handleAddressInUse(server: net.Server, sockPath: string): void {
|
|
330
|
-
// [LAW:one-source-of-truth] Derive the lease from the SAME sockPath threaded
|
|
331
|
-
// through unlink + rebind below, not the re-derived global — one identity
|
|
332
|
-
// source for the whole arbitration.
|
|
333
|
-
const decision = arbitrateSocket(
|
|
334
|
-
readLease(leasePathFor(sockPath)),
|
|
335
|
-
(pid, startTime) =>
|
|
336
|
-
sameLiveProcess(pid, startTime, { readStartTime, pidAlive }),
|
|
337
|
-
);
|
|
338
|
-
if (decision.kind === "attach-and-exit") {
|
|
339
|
-
dlog("info", `EADDRINUSE: ${decision.reason} — exiting`);
|
|
340
|
-
process.exit(0);
|
|
341
|
-
// [LAW:no-ambient-temporal-coupling] process.exit() halts synchronously, so
|
|
342
|
-
// the reclaim below is already unreachable — but the explicit return makes
|
|
343
|
-
// that structural, matching the sibling `retried` branch, so no future
|
|
344
|
-
// refactor of the exit path can accidentally fall through to unlinking a
|
|
345
|
-
// live daemon's socket.
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
dlog(
|
|
349
|
-
"warn",
|
|
350
|
-
`EADDRINUSE: ${decision.reason} — unlinking stale socket and rebinding`,
|
|
351
|
-
);
|
|
352
|
-
// [LAW:no-defensive-null-guards] If unlink fails (permissions, read-only
|
|
353
|
-
// FS), the retry will hit EADDRINUSE again, exit 0, and leave the system
|
|
354
|
-
// in the worst state: no daemon + stale socket blocking future starts.
|
|
355
|
-
// Surface unrecoverable failures loudly. ENOENT is fine — the goal was
|
|
356
|
-
// "make the path bindable" and a missing path already satisfies that.
|
|
357
|
-
try {
|
|
358
|
-
fs.unlinkSync(sockPath);
|
|
359
|
-
} catch (e) {
|
|
360
|
-
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
361
|
-
dlog(
|
|
362
|
-
"error",
|
|
363
|
-
`cannot unlink stale socket ${sockPath}: ${(e as Error).message}`,
|
|
364
|
-
);
|
|
365
|
-
shutdown(1);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
bindOrAttachAndExit(server, sockPath, /* retried */ true);
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function onListening(sockPath: string): void {
|
|
373
|
-
// [LAW:no-ambient-temporal-coupling] Capture the bound socket's kernel
|
|
374
|
-
// identity (dev+ino) as the fingerprint the ownership self-check re-stats
|
|
375
|
-
// against, BEFORE claiming the lease. Captured from the PATH, not the bound
|
|
376
|
-
// FD: for an AF_UNIX listener, fstat(fd) reports the socket's inode in the
|
|
377
|
-
// socket namespace — not the filesystem-entry inode that stat(path) sees — so
|
|
378
|
-
// the FD yields no path-comparable identity. stat(path), taken as close to the
|
|
379
|
-
// bind as possible, is the only path-comparable truth available.
|
|
380
|
-
//
|
|
381
|
-
// [FRAMING:representation] This inode capture still cannot close the capture
|
|
382
|
-
// race on its own: if a second daemon completed its full EADDRINUSE →
|
|
383
|
-
// read-lease → unlink → rebind cycle in the single event-loop tick between
|
|
384
|
-
// bind() and this 'listening' callback, we stat the path and capture the
|
|
385
|
-
// THIEF's inode as "ours". No race-free handle on our own socket's path
|
|
386
|
-
// identity exists (an AF_UNIX listener's fstat is a different namespace's
|
|
387
|
-
// inode), so the captured inode cannot be made trustworthy. But that is no
|
|
388
|
-
// longer the immortal orphan it was (brandon-daemon-lifecycle-2b3.4 RESIDUAL
|
|
389
|
-
// 2): the ownership self-check now ALSO requires the lease to still name us,
|
|
390
|
-
// and a real thief writes its own pid into the lease — so a displaced daemon
|
|
391
|
-
// drains within a bounded number of intervals instead of reading `owned`
|
|
392
|
-
// forever (see checkOwnership). The absent/unreadable case below still exits
|
|
393
|
-
// toward the SAFE direction without writing a lease that would stomp a thief's.
|
|
394
|
-
const boundRead = readSocketIdentity(sockPath);
|
|
395
|
-
if (boundRead.kind !== "present") {
|
|
396
|
-
dlog(
|
|
397
|
-
"warn",
|
|
398
|
-
`no ownable socket at bind callback (${boundRead.kind}); exiting`,
|
|
399
|
-
);
|
|
400
|
-
shutdown(0);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// [LAW:no-ambient-temporal-coupling] Claim ownership FIRST among the post-bind
|
|
405
|
-
// effects — before chmod or any other work — so the window between winning the
|
|
406
|
-
// bind and the lease naming us is as small as possible. A second daemon that
|
|
407
|
-
// binds in that sub-ms gap reads an absent lease and reclaims (displacing us);
|
|
408
|
-
// the ownership self-check below makes that self-healing, but keeping the
|
|
409
|
-
// window minimal keeps it vanishingly rare.
|
|
410
|
-
// [LAW:one-source-of-truth] Derive the lease from the same sockPath we bound,
|
|
411
|
-
// matching handleAddressInUse's read — one identity source across write + read.
|
|
412
|
-
writeLeaseFile(sockPath);
|
|
413
|
-
try {
|
|
414
|
-
fs.chmodSync(sockPath, 0o600);
|
|
415
|
-
} catch (e) {
|
|
416
|
-
dlog("warn", `chmod socket failed: ${(e as Error).message}`);
|
|
417
|
-
}
|
|
418
|
-
dlog(
|
|
419
|
-
"info",
|
|
420
|
-
// [FRAMING:representation] Report the heap cap V8 actually applied (the
|
|
421
|
-
// territory), not the flag the spawner meant to pass (the map) — the one
|
|
422
|
-
// question a silent SIGABRT crash-loop leaves open is "which cap was live".
|
|
423
|
-
`daemon up: pid=${process.pid} v=${PROTOCOL_VERSION} sock=${sockPath} ` +
|
|
424
|
-
`heapCap=${Math.round(v8.getHeapStatistics().heap_size_limit / 1048576)}MB ` +
|
|
425
|
-
`rssLimit=${Math.round(budgetBytes / 1048576)}MB`,
|
|
426
|
-
);
|
|
427
|
-
// [LAW:single-enforcer] This bind is the one process-wide fact that answers
|
|
428
|
-
// "did an outage just end" — see resetSpawnBackoff's doc comment in
|
|
429
|
-
// acquire.ts. Any consecutive-spawn backoff accumulated getting here no
|
|
430
|
-
// longer applies once a daemon is actually serving.
|
|
431
|
-
resetSpawnBackoff();
|
|
432
|
-
armBinaryWatch();
|
|
433
|
-
armLimits();
|
|
434
|
-
armOwnershipWatch(sockPath, boundRead.identity);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// --- socket-ownership self-check ---
|
|
438
|
-
//
|
|
439
|
-
// [LAW:single-enforcer] The sole enforcer of "serving implies owning the socket
|
|
440
|
-
// path over time" (brandon-daemon-lifecycle-2b3.2). Each interval it re-reads
|
|
441
|
-
// BOTH representations a displacer can touch — the path's kernel identity (still
|
|
442
|
-
// the inode we bound?) and the lease (still names our pid?) — and drains through
|
|
443
|
-
// the SAME shutdown funnel as signals, the RSS backstop, and the watchdog on
|
|
444
|
-
// either mismatch. The lease arm closes the capture race (RESIDUAL 2): a thief
|
|
445
|
-
// that stole our socket inside the bind→listening tick, which the inode arm
|
|
446
|
-
// cannot see because we captured the thief's inode, is caught the moment the
|
|
447
|
-
// thief writes its own pid into the lease. No parallel exit path.
|
|
448
|
-
function armOwnershipWatch(sockPath: string, bound: SocketIdentity): void {
|
|
449
|
-
makeOwnershipWatch({
|
|
450
|
-
bound,
|
|
451
|
-
myPid: process.pid,
|
|
452
|
-
readIdentity: () => readSocketIdentity(sockPath),
|
|
453
|
-
readLease: () => readLease(leasePathFor(sockPath)),
|
|
454
|
-
shutdown: (code) => shutdown(code),
|
|
455
|
-
log: dlog,
|
|
456
|
-
}).arm();
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// --- binary-mtime self-restart ---
|
|
460
|
-
//
|
|
461
|
-
// If the daemon's compiled output changes on disk (rebuild, upgrade, edit),
|
|
462
|
-
// exit at the next sample so the next client respawns from the fresh code.
|
|
463
|
-
// Cheap (one statSync/min) and avoids the user having to manually kill the
|
|
464
|
-
// daemon during development. unref() so this timer doesn't hold the process alive.
|
|
465
|
-
function armBinaryWatch(): void {
|
|
466
|
-
// Watch the resolved entry point, not the bin shim — npm run build updates
|
|
467
|
-
// dist/index.mjs but the bin/cc-candybar shim never changes.
|
|
468
|
-
const entryUrl = import.meta.url;
|
|
469
|
-
const targets: string[] = [];
|
|
470
|
-
if (entryUrl.startsWith("file://")) {
|
|
471
|
-
targets.push(fileURLToPath(entryUrl));
|
|
472
|
-
}
|
|
473
|
-
// Also watch argv[1] as fallback (covers global installs, symlinks, etc.)
|
|
474
|
-
if (process.argv[1]) targets.push(process.argv[1]!);
|
|
475
|
-
|
|
476
|
-
const originalMtimes = new Map<string, number>();
|
|
477
|
-
for (const t of targets) {
|
|
478
|
-
try {
|
|
479
|
-
originalMtimes.set(t, fs.statSync(t).mtimeMs);
|
|
480
|
-
} catch {
|
|
481
|
-
// File may not exist yet — skip it.
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
if (originalMtimes.size === 0) return;
|
|
485
|
-
|
|
486
|
-
const timer = setInterval(() => {
|
|
487
|
-
for (const [t, originalMtime] of originalMtimes) {
|
|
488
|
-
try {
|
|
489
|
-
const nowMtime = fs.statSync(t).mtimeMs;
|
|
490
|
-
if (nowMtime !== originalMtime) {
|
|
491
|
-
dlog("info", `binary mtime changed (${t}); shutting down`);
|
|
492
|
-
clearInterval(timer);
|
|
493
|
-
shutdown(0);
|
|
494
|
-
return;
|
|
495
|
-
}
|
|
496
|
-
} catch (e) {
|
|
497
|
-
dlog("warn", `bin stat failed: ${(e as Error).message}`);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}, BIN_CHECK_INTERVAL_MS);
|
|
501
|
-
timer.unref();
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// --- self-shutdown on RSS / age ---
|
|
505
|
-
let limits: LimitsHandle | null = null;
|
|
506
|
-
function armLimits(): void {
|
|
507
|
-
limits = makeLimits(
|
|
508
|
-
realLimitsDeps(stats.startedAt.getTime(), (code) => shutdown(code), {
|
|
509
|
-
rssLimitBytes: budgetBytes,
|
|
510
|
-
}),
|
|
511
|
-
);
|
|
512
|
-
limits.arm();
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// --- socket-ownership lease ---
|
|
516
|
-
//
|
|
517
|
-
// [LAW:one-source-of-truth] The lease is the authority for socket ownership —
|
|
518
|
-
// its owner pid + kill(pid,0) is what the next daemon's EADDRINUSE arbitration
|
|
519
|
-
// consults (handleAddressInUse). Exclusion RIGHT NOW is still the atomic bind()
|
|
520
|
-
// in bindOrAttachAndExit(); the lease answers the separate question "may I
|
|
521
|
-
// destroy this existing path" over time. It also carries the same diagnostic
|
|
522
|
-
// fields the old pidfile did, so one file serves both roles.
|
|
523
|
-
//
|
|
524
|
-
// Overwrite-on-write (no EEXIST check). We only reach onListening after winning
|
|
525
|
-
// the bind, so whatever stale lease a dead owner left is ours to replace. Write
|
|
526
|
-
// failure is non-fatal: the lease is best-effort ownership signalling on top of
|
|
527
|
-
// bind()'s hard exclusion; a missing lease degrades a future arbitration to
|
|
528
|
-
// "reclaim" (unlink + rebind), never to a wrong attach.
|
|
529
|
-
|
|
530
|
-
function writeLeaseFile(sockPath: string): void {
|
|
531
|
-
const reason = writeLease(leasePathFor(sockPath), {
|
|
532
|
-
pid: process.pid,
|
|
533
|
-
version: PROTOCOL_VERSION,
|
|
534
|
-
binPath: process.argv[1],
|
|
535
|
-
startTime: myStartTime,
|
|
536
|
-
});
|
|
537
|
-
if (reason !== null) dlog("warn", `lease write failed: ${reason}`);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
let inFlight = 0;
|
|
541
|
-
|
|
542
|
-
// --- shutdown ---
|
|
543
|
-
|
|
544
|
-
let shuttingDown = false;
|
|
545
|
-
function shutdown(code: number): void {
|
|
546
|
-
if (shuttingDown) return;
|
|
547
|
-
shuttingDown = true;
|
|
548
|
-
// [LAW:single-enforcer] Arm the SIGKILL backstop FIRST, before any cleanup.
|
|
549
|
-
// The 452-daemon incident: shut-down daemons logged "shutting down" but
|
|
550
|
-
// held the bound socket FD 42 minutes later — process.exit() reached the
|
|
551
|
-
// call site but never completed because some active handle kept libuv's
|
|
552
|
-
// event loop alive past exit's teardown. The prior shape had `.unref()`
|
|
553
|
-
// on the SIGKILL timer, so the timer itself did NOT keep the loop alive
|
|
554
|
-
// — leaving the loop's only remaining live handles to win the race.
|
|
555
|
-
//
|
|
556
|
-
// What this timer guarantees: as long as the event loop can still run
|
|
557
|
-
// (handles that won't drop, async cleanup that schedules but never
|
|
558
|
-
// completes — the realistic failure modes for the incident class), the
|
|
559
|
-
// setTimeout callback fires within 500ms and SIGKILL terminates the
|
|
560
|
-
// process from outside the loop's bookkeeping. Critically the timer is
|
|
561
|
-
// NOT unref'd, so it is itself an active handle that keeps the loop
|
|
562
|
-
// alive long enough for itself to fire.
|
|
563
|
-
//
|
|
564
|
-
// What this timer cannot do: rescue a truly synchronous thread block
|
|
565
|
-
// (a C++ binding that never returns to JS, an infinite sync loop). No
|
|
566
|
-
// JS timer can fire while the main thread is blocked; only an external
|
|
567
|
-
// signal recovers that case. The realistic 452-corpse mode was async-
|
|
568
|
-
// handle retention, not a synchronous block, so the backstop is
|
|
569
|
-
// load-bearing for the observed failure pattern.
|
|
570
|
-
setTimeout(() => process.kill(process.pid, "SIGKILL"), 500);
|
|
571
|
-
// [LAW:single-enforcer] The atomic bind() on the unix socket path is the
|
|
572
|
-
// ONLY mutex preventing duplicate daemons. The previous shape unlinked
|
|
573
|
-
// the socket file FIRST, then spent O(100ms) closing watchers, flushing
|
|
574
|
-
// session state, and tearing down log streams before process.exit().
|
|
575
|
-
// The unlink frees the path the instant it runs; the listening FD stays
|
|
576
|
-
// held only until process.exit. In between, Claude Code's next render
|
|
577
|
-
// tick can spawn a fresh daemon that bind()s the same path and starts
|
|
578
|
-
// serving while we are still finishing cleanup. Under OOM cycles the
|
|
579
|
-
// overlap compounds — 12 daemons stacked up in the wild was the
|
|
580
|
-
// observed symptom. Do NOT unlink here. The kernel releases the FD on
|
|
581
|
-
// process.exit; the stale path that remains is recovered by the
|
|
582
|
-
// existing handleAddressInUse logic on the next daemon's startup
|
|
583
|
-
// (probe → dead → unlink + rebind, ~50ms one-shot cost).
|
|
584
|
-
try {
|
|
585
|
-
gitService.close();
|
|
586
|
-
} catch (e) {
|
|
587
|
-
dlog("warn", `gitService close failed: ${(e as Error).message}`);
|
|
588
|
-
}
|
|
589
|
-
try {
|
|
590
|
-
usageStore.close();
|
|
591
|
-
} catch (e) {
|
|
592
|
-
dlog("warn", `usageStore close failed: ${(e as Error).message}`);
|
|
593
|
-
}
|
|
594
|
-
try {
|
|
595
|
-
watcherRegistry.closeAll();
|
|
596
|
-
} catch (e) {
|
|
597
|
-
dlog("warn", `watcherRegistry close failed: ${(e as Error).message}`);
|
|
598
|
-
}
|
|
599
|
-
try {
|
|
600
|
-
sessionState.flush();
|
|
601
|
-
} catch (e) {
|
|
602
|
-
dlog("warn", `sessionState flush failed: ${(e as Error).message}`);
|
|
603
|
-
}
|
|
604
|
-
// [LAW:one-source-of-truth] Remove the lease only if it still names us. A
|
|
605
|
-
// displaced daemon (socket stolen, thief wrote its own lease) must not delete
|
|
606
|
-
// the live owner's lease on its way out, or the next EADDRINUSE would read
|
|
607
|
-
// `absent` and reclaim the thief's live socket — cascading the theft.
|
|
608
|
-
removeLeaseIfOwned(leasePath(), process.pid);
|
|
609
|
-
// [LAW:one-source-of-truth] Same "only if it still names us" guard as the
|
|
610
|
-
// lease above, reused via releaseRegistration — a slot this daemon never
|
|
611
|
-
// claimed (exempt production, or refused before claiming one) is null and
|
|
612
|
-
// skipped.
|
|
613
|
-
if (breakerRegistryPath !== null) {
|
|
614
|
-
releaseRegistration(
|
|
615
|
-
breakerRegistryPath,
|
|
616
|
-
process.pid,
|
|
617
|
-
readRegistryEntry,
|
|
618
|
-
(p) => fs.unlinkSync(p),
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
// Every dlog above was a synchronous append (log.ts), so the death line is
|
|
622
|
-
// already on disk; nothing to flush before exit.
|
|
623
|
-
process.exit(code);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// --- per-connection handler ---
|
|
627
|
-
|
|
628
|
-
function handleConnection(sock: net.Socket): void {
|
|
629
|
-
inFlight++;
|
|
630
|
-
stats.inFlight = inFlight;
|
|
631
|
-
let responded = false;
|
|
632
|
-
|
|
633
|
-
// [LAW:no-ambient-temporal-coupling] respond owns the response→exit
|
|
634
|
-
// ordering. exitAfterFlush (an exit code; null = stay up) is performed
|
|
635
|
-
// by sock.end's completion callback, which Node invokes on 'finish' OR
|
|
636
|
-
// 'error' — a total signal. A peer that vanished mid-flush still settles,
|
|
637
|
-
// so the exit wish can never be stranded on a dead socket, and a live
|
|
638
|
-
// peer always has the frame in the kernel buffer before process.exit
|
|
639
|
-
// (unix-socket data survives writer exit). No fixed sleep stands between
|
|
640
|
-
// respond and exit; the SIGKILL backstop inside shutdown() is the
|
|
641
|
-
// unrelated last-resort safety.
|
|
642
|
-
const respond = (resp: Response, exitAfterFlush: number | null): void => {
|
|
643
|
-
if (responded) {
|
|
644
|
-
// First responder owns the flush. Reaching here with an exit wish is
|
|
645
|
-
// unreachable today (both exit-carrying arms resolve synchronously,
|
|
646
|
-
// far inside the request timeout) — but if it ever happens, say so
|
|
647
|
-
// instead of silently leaving a daemon up that was told to exit.
|
|
648
|
-
// [LAW:no-silent-failure]
|
|
649
|
-
if (exitAfterFlush !== null) {
|
|
650
|
-
dlog(
|
|
651
|
-
"warn",
|
|
652
|
-
"exit-after-flush dropped: an earlier responder settled this socket",
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
responded = true;
|
|
658
|
-
const settle =
|
|
659
|
-
exitAfterFlush === null
|
|
660
|
-
? undefined
|
|
661
|
-
: (): void => shutdown(exitAfterFlush);
|
|
662
|
-
try {
|
|
663
|
-
sock.end(encodeFrame(resp), settle);
|
|
664
|
-
} catch (e) {
|
|
665
|
-
// [LAW:no-silent-failure] The response is lost (socket already torn
|
|
666
|
-
// down), but the exit wish must not be.
|
|
667
|
-
dlog("warn", `response write failed: ${(e as Error).message}`);
|
|
668
|
-
settle?.();
|
|
669
|
-
}
|
|
670
|
-
};
|
|
671
|
-
|
|
672
|
-
// Per-request timeout protects the daemon from a single slow request
|
|
673
|
-
// (e.g. a hung git call) blocking subsequent connections. It abandons the
|
|
674
|
-
// RESPONSE, not the work — the handler promise keeps running.
|
|
675
|
-
//
|
|
676
|
-
// [LAW:one-source-of-truth] That is safe for the transcript-fs path because
|
|
677
|
-
// the work is bounded + shared, not orphaned: the today aggregate and
|
|
678
|
-
// per-session usage compute behind a SingleFlight (src/utils/single-flight.ts),
|
|
679
|
-
// so a timed-out render that abandoned its await leaves behind the ONE
|
|
680
|
-
// canonical in-flight scan, which the next render coalesces onto rather than
|
|
681
|
-
// duplicating. A timeout therefore adds zero new fs work — there is never
|
|
682
|
-
// more than one scan per key to orphan. Cancellation would be both messier
|
|
683
|
-
// and wasteful here (the in-flight scan is exactly what the next tick needs).
|
|
684
|
-
const timer = setTimeout(() => {
|
|
685
|
-
stats.requestsTimedOut++;
|
|
686
|
-
respond(
|
|
687
|
-
{
|
|
688
|
-
ok: false,
|
|
689
|
-
error: "request exceeded 200ms",
|
|
690
|
-
code: "TIMEOUT",
|
|
691
|
-
daemonV: PROTOCOL_VERSION,
|
|
692
|
-
},
|
|
693
|
-
null,
|
|
694
|
-
);
|
|
695
|
-
}, REQUEST_TIMEOUT_MS);
|
|
696
|
-
|
|
697
|
-
const reader = makeFrameReader(
|
|
698
|
-
(frame) => {
|
|
699
|
-
void handleRequest(frame as Request)
|
|
700
|
-
.then((r) => respond(r.resp, r.exitAfterFlush))
|
|
701
|
-
.catch((err) => {
|
|
702
|
-
dlog("error", `handler threw: ${err?.stack || err}`);
|
|
703
|
-
respond(
|
|
704
|
-
{
|
|
705
|
-
ok: false,
|
|
706
|
-
error: String(err?.message || err),
|
|
707
|
-
code: "RENDER_FAILED",
|
|
708
|
-
daemonV: PROTOCOL_VERSION,
|
|
709
|
-
},
|
|
710
|
-
null,
|
|
711
|
-
);
|
|
712
|
-
});
|
|
713
|
-
},
|
|
714
|
-
(err) => {
|
|
715
|
-
dlog("warn", `frame parse failed: ${err.message}`);
|
|
716
|
-
respond(
|
|
717
|
-
{
|
|
718
|
-
ok: false,
|
|
719
|
-
error: err.message,
|
|
720
|
-
code: "BAD_REQUEST",
|
|
721
|
-
daemonV: PROTOCOL_VERSION,
|
|
722
|
-
},
|
|
723
|
-
null,
|
|
724
|
-
);
|
|
725
|
-
},
|
|
726
|
-
);
|
|
727
|
-
|
|
728
|
-
sock.on("data", reader);
|
|
729
|
-
sock.on("error", (err) => {
|
|
730
|
-
dlog("warn", `socket error: ${err.message}`);
|
|
731
|
-
});
|
|
732
|
-
sock.on("close", () => {
|
|
733
|
-
clearTimeout(timer);
|
|
734
|
-
inFlight = Math.max(0, inFlight - 1);
|
|
735
|
-
stats.inFlight = inFlight;
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
// [LAW:no-ambient-temporal-coupling] A request whose semantics include "then
|
|
740
|
-
// exit" (the shutdown verb, the stale-binary version mismatch) must not exit
|
|
741
|
-
// until its response has flushed — but handleRequest cannot see the socket.
|
|
742
|
-
// So the exit is returned as DATA (the exit code; null = stay up) and the
|
|
743
|
-
// connection boundary, which owns the flush, sequences shutdown on the write
|
|
744
|
-
// completion. No timer stands between respond and exit.
|
|
745
|
-
// [LAW:effects-at-boundaries] handleRequest computes the description; the
|
|
746
|
-
// socket boundary performs it.
|
|
747
|
-
interface HandledRequest {
|
|
748
|
-
resp: Response;
|
|
749
|
-
exitAfterFlush: number | null;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
const stay = (resp: Response): HandledRequest => ({
|
|
753
|
-
resp,
|
|
754
|
-
exitAfterFlush: null,
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
758
|
-
if (
|
|
759
|
-
!req ||
|
|
760
|
-
typeof req !== "object" ||
|
|
761
|
-
typeof (req as Request).v !== "number"
|
|
762
|
-
) {
|
|
763
|
-
return stay({
|
|
764
|
-
ok: false,
|
|
765
|
-
error: "malformed request",
|
|
766
|
-
code: "BAD_REQUEST",
|
|
767
|
-
daemonV: PROTOCOL_VERSION,
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (req.v !== PROTOCOL_VERSION) {
|
|
772
|
-
// [LAW:types-are-the-program] The asymmetry is data, not control flow.
|
|
773
|
-
// client > daemon: the *binary* probably upgraded under us. Exit so the
|
|
774
|
-
// next client respawns from the current artifact.
|
|
775
|
-
// client < daemon: the *client* is stale. Respawning daemon does not
|
|
776
|
-
// help (the new daemon will have the same version). Stay up and
|
|
777
|
-
// return VERSION_MISMATCH — the client is responsible for surfacing
|
|
778
|
-
// the diagnostic and refusing to kick. Shutting down here was the
|
|
779
|
-
// load-bearing half of the 452-corpse spiral (kz8.5).
|
|
780
|
-
if (req.v > PROTOCOL_VERSION) {
|
|
781
|
-
dlog(
|
|
782
|
-
"info",
|
|
783
|
-
`version mismatch: client=${req.v} > daemon=${PROTOCOL_VERSION}; binary likely upgraded — exiting after the response flushes`,
|
|
784
|
-
);
|
|
785
|
-
} else {
|
|
786
|
-
dlog(
|
|
787
|
-
"info",
|
|
788
|
-
`version mismatch: client=${req.v} < daemon=${PROTOCOL_VERSION}; client is stale — staying up`,
|
|
789
|
-
);
|
|
790
|
-
}
|
|
791
|
-
return {
|
|
792
|
-
resp: {
|
|
793
|
-
ok: false,
|
|
794
|
-
error: `protocol v${req.v} not supported (daemon at v${PROTOCOL_VERSION})`,
|
|
795
|
-
code: "VERSION_MISMATCH",
|
|
796
|
-
daemonV: PROTOCOL_VERSION,
|
|
797
|
-
},
|
|
798
|
-
// [LAW:dataflow-not-control-flow] The asymmetry above is this value.
|
|
799
|
-
// Exit is sequenced on the response flush, so the client always sees
|
|
800
|
-
// the VERSION_MISMATCH diagnostic — never a dead socket.
|
|
801
|
-
exitAfterFlush: req.v > PROTOCOL_VERSION ? 0 : null,
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
if (req.kind === "shutdown") {
|
|
806
|
-
return { resp: { ok: true, output: "" }, exitAfterFlush: 0 };
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
if (req.kind === "stats") {
|
|
810
|
-
// [LAW:single-enforcer] Stats requests do NOT bump request counters —
|
|
811
|
-
// observability shouldn't pollute the metric being observed.
|
|
812
|
-
return stay({
|
|
813
|
-
ok: true,
|
|
814
|
-
stats: stats.snapshot({
|
|
815
|
-
gitCache: gitService.getStats(),
|
|
816
|
-
usageCache: usageStore.getStats(),
|
|
817
|
-
renderCacheSize: renderCache.size,
|
|
818
|
-
watchersActive: watcherRegistry.size(),
|
|
819
|
-
nextRestartReason: limits?.describeNextRestart() ?? null,
|
|
820
|
-
}),
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
if (req.kind === "render") {
|
|
825
|
-
stats.requestsTotal++;
|
|
826
|
-
const t0 = Date.now();
|
|
827
|
-
try {
|
|
828
|
-
// [LAW:single-enforcer] One trust-boundary check for incoming hookData.
|
|
829
|
-
// The validator reports missing/wrong-typed required fields and unknown
|
|
830
|
-
// top-level keys. Required-field problems are *protocol* failures
|
|
831
|
-
// (Claude Code's schema guarantees these — their absence means the
|
|
832
|
-
// sender is broken or malicious); unknown fields are advisory (Anthropic
|
|
833
|
-
// may have added something).
|
|
834
|
-
const { report } = validateHookData(req.hookData as unknown);
|
|
835
|
-
for (const field of report.unknownTopLevelFields) {
|
|
836
|
-
dlog(
|
|
837
|
-
"info",
|
|
838
|
-
`schema: unknown field '${field}' — Anthropic may have added it`,
|
|
839
|
-
);
|
|
840
|
-
}
|
|
841
|
-
// [LAW:no-silent-fallbacks][LAW:types-are-the-program] Gate hard on
|
|
842
|
-
// schema violations. Continuing with `workspace?.project_dir` would
|
|
843
|
-
// collapse "absent" into an empty-string cache key — silently sharing
|
|
844
|
-
// one entry across every malformed request — and downstream code would
|
|
845
|
-
// have to defend against an empty projectDir forever. Reject here so
|
|
846
|
-
// the types downstream carry the strongest true theorem: by the time
|
|
847
|
-
// a cache entry is built, projectDir/cwd are real non-empty strings.
|
|
848
|
-
const wireProblems: string[] = [];
|
|
849
|
-
for (const path of report.missingRequired) {
|
|
850
|
-
wireProblems.push(`missing required field '${path}'`);
|
|
851
|
-
}
|
|
852
|
-
for (const { path, expected, got } of report.typeMismatches) {
|
|
853
|
-
wireProblems.push(`field '${path}' expected ${expected}, got ${got}`);
|
|
854
|
-
}
|
|
855
|
-
if (req.cwd === "") {
|
|
856
|
-
wireProblems.push("request 'cwd' is empty");
|
|
857
|
-
}
|
|
858
|
-
if (wireProblems.length > 0) {
|
|
859
|
-
stats.requestsErrored++;
|
|
860
|
-
dlog("warn", `BAD_REQUEST: ${wireProblems.join("; ")}`);
|
|
861
|
-
return stay({
|
|
862
|
-
ok: false,
|
|
863
|
-
error: `malformed hookData: ${wireProblems.join("; ")}`,
|
|
864
|
-
code: "BAD_REQUEST",
|
|
865
|
-
daemonV: PROTOCOL_VERSION,
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
const projectDir = req.hookData.workspace.project_dir;
|
|
869
|
-
// [LAW:dataflow-not-control-flow] thread the *request's* cwd, not the
|
|
870
|
-
// daemon's process.cwd(), so config resolution depends only on request
|
|
871
|
-
// data — the daemon's own working directory must not influence output.
|
|
872
|
-
const { configFile, unknownFlagsError } = parseRenderArgs(req.args);
|
|
873
|
-
// [LAW:effects-at-boundaries] The load-config verb writes per-session
|
|
874
|
-
// config overrides into SessionState; this is the one read point.
|
|
875
|
-
const sessionId = req.hookData.session_id;
|
|
876
|
-
const sessionConfigFile =
|
|
877
|
-
sessionState.get(sessionId, SESSION_CONFIG_OVERRIDE_KEY) ?? configFile;
|
|
878
|
-
const entry = renderCache.getOrCreate(
|
|
879
|
-
projectDir,
|
|
880
|
-
req.cwd,
|
|
881
|
-
sessionConfigFile,
|
|
882
|
-
);
|
|
883
|
-
// [LAW:parse-dont-validate] The ONE checkpoint for everything the client
|
|
884
|
-
// observed and the daemon cannot. Raw `req.*` hint fields are not read
|
|
885
|
-
// past this line; `hints` is the stamped type the render path consumes.
|
|
886
|
-
//
|
|
887
|
-
// [LAW:single-enforcer] Every hint is captured client-side because the
|
|
888
|
-
// daemon is detached and shared: its env answers for whichever shell
|
|
889
|
-
// spawned it. We do NOT consult getTerminalWidth's env/stderr fallbacks
|
|
890
|
-
// for width, and we do NOT consult SSH_* for remoteness — both would
|
|
891
|
-
// describe a different session than the one being rendered.
|
|
892
|
-
// [LAW:one-source-of-truth] Both branches feed raw cols through
|
|
893
|
-
// applyClaudeCodeReserve, so `width` always means "usable cells
|
|
894
|
-
// post-reserve" with no semantic split between wire-supplied and
|
|
895
|
-
// fallback values.
|
|
896
|
-
const hints = parseClientHints(req);
|
|
897
|
-
const termCols = hints.termCols;
|
|
898
|
-
const width = applyClaudeCodeReserve(termCols ?? DEFAULT_TERMINAL_WIDTH);
|
|
899
|
-
const renderOpts: BuildLineOptions = { ...RENDER_OPTS_BASE, width };
|
|
900
|
-
// [LAW:dataflow-not-control-flow] Two outcomes fall out of one rule:
|
|
901
|
-
// body = state ? renderDsl(state) : "" ; output = body + icon
|
|
902
|
-
// No special-case branches — same composition every render.
|
|
903
|
-
let body = "";
|
|
904
|
-
if (entry.state !== null) {
|
|
905
|
-
// [LAW:one-source-of-truth] Every globals field resolved ONCE per
|
|
906
|
-
// render, here — before the payload build, so the same struct feeds
|
|
907
|
-
// BOTH the payload's `*.effective` fields (what a trigger label says)
|
|
908
|
-
// AND renderOpts below (what actually renders). One resolution, two
|
|
909
|
-
// readers, so a label can never disagree with the bar. The precedence
|
|
910
|
-
// the resolver applies, and why each rung sits where it does, lives
|
|
911
|
-
// with the chain (resolveEffectiveGlobals, and src/config/presets.ts).
|
|
912
|
-
// Read alongside the config, from the same entry, in one statement —
|
|
913
|
-
// which is exactly what the closure below claims about it.
|
|
914
|
-
const presetRootOps = entry.state.presetRootOps;
|
|
915
|
-
const effective: EffectiveGlobals = resolveEffectiveGlobals(
|
|
916
|
-
entry.state.config,
|
|
917
|
-
(key: string) => sessionState.get(req.hookData.session_id, key),
|
|
918
|
-
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.5 — read from
|
|
919
|
-
// THIS entry's own presetRootOps (the record that fed the SAME
|
|
920
|
-
// reload that produced entry.state.config), never a fresh
|
|
921
|
-
// loadOverrides() here — a second read could race a concurrent
|
|
922
|
-
// write and disagree with the tree that actually rendered. That is
|
|
923
|
-
// why it arrives as a closure over this entry rather than being
|
|
924
|
-
// looked up inside the resolver.
|
|
925
|
-
(preset: string) => presetIsCustomized(presetRootOps, preset),
|
|
926
|
-
);
|
|
927
|
-
const payload = await buildRenderPayload(
|
|
928
|
-
req.hookData,
|
|
929
|
-
payloadDeps,
|
|
930
|
-
req.cwd,
|
|
931
|
-
entry.state.neededInputPaths,
|
|
932
|
-
effective,
|
|
933
|
-
hints,
|
|
934
|
-
);
|
|
935
|
-
// [LAW:one-source-of-truth][LAW:dataflow-not-control-flow] basePalette
|
|
936
|
-
// is derived from the same effective theme resolved above — so a theme
|
|
937
|
-
// click recolors the whole bar on the next render. Not frozen on the
|
|
938
|
-
// cache entry (one entry serves many sessions). paletteForThemeName
|
|
939
|
-
// memoizes, so the per-render cost is one Map lookup once the theme is
|
|
940
|
-
// warm.
|
|
941
|
-
const basePalette = paletteForThemeName(effective.theme);
|
|
942
|
-
// [LAW:one-source-of-truth] Every renderOpts field below reuses the
|
|
943
|
-
// SAME `effective` struct the payload was just built from — no second
|
|
944
|
-
// `?? DEFAULT_X` computation to drift from it.
|
|
945
|
-
renderOpts.style = effective.style;
|
|
946
|
-
// The `plain` joiner's cell separator. Assigned unconditionally like
|
|
947
|
-
// every field around it: `undefined` is a value pickJoiner already
|
|
948
|
-
// reads as "PlainJoiner's own default", not an absence to branch on.
|
|
949
|
-
renderOpts.separator = effective.separator;
|
|
950
|
-
renderOpts.wrap = effective.autoWrap;
|
|
951
|
-
renderOpts.padding = effective.padding;
|
|
952
|
-
renderOpts.charset = effective.charset;
|
|
953
|
-
renderOpts.colorCompatibility = effective.colorCompatibility;
|
|
954
|
-
// [LAW:single-enforcer] renderDsl internally calls
|
|
955
|
-
// `registry.applyInput(payload)` as its first step (see step 1 in
|
|
956
|
-
// src/dsl/render.ts). The daemon must not pre-apply — doing so
|
|
957
|
-
// would run the MobX action twice per render and clear last_error
|
|
958
|
-
// diagnostics on the round trip.
|
|
959
|
-
body = renderDsl(
|
|
960
|
-
entry.state.config,
|
|
961
|
-
entry.state.compiled,
|
|
962
|
-
entry.state.store,
|
|
963
|
-
entry.state.registry,
|
|
964
|
-
payload,
|
|
965
|
-
basePalette,
|
|
966
|
-
renderOpts,
|
|
967
|
-
// [LAW:single-enforcer] The per-segment StripCell sink for the
|
|
968
|
-
// `debug segments` projection. Its identity stays stable for the
|
|
969
|
-
// cache entry's lifetime; renderDsl clears + repopulates it
|
|
970
|
-
// in place. Cells are cheap (already computed during the render);
|
|
971
|
-
// the per-segment ANSI serialization happens lazily inside the
|
|
972
|
-
// debug handler so normal renders pay no extra serializer cost.
|
|
973
|
-
{ perSegmentSink: entry.state.lastRenderCellsBySegment },
|
|
974
|
-
{
|
|
975
|
-
look: lookKeyByName(entry.state.config.looks, effective.look),
|
|
976
|
-
preset: effective.preset,
|
|
977
|
-
},
|
|
978
|
-
);
|
|
979
|
-
}
|
|
980
|
-
// [LAW:one-source-of-truth] Consume the transient click error written by
|
|
981
|
-
// dispatch on partial/total effect failure, then clear it so it shows
|
|
982
|
-
// exactly once. Only called when non-null to avoid a no-op persist+MobX
|
|
983
|
-
// tick on every render.
|
|
984
|
-
const clickError = sessionState.get(
|
|
985
|
-
req.hookData.session_id,
|
|
986
|
-
"click.error",
|
|
987
|
-
);
|
|
988
|
-
if (clickError)
|
|
989
|
-
sessionState.clear(req.hookData.session_id, "click.error");
|
|
990
|
-
const combinedError =
|
|
991
|
-
[unknownFlagsError, entry.lastError, clickError]
|
|
992
|
-
.filter(Boolean)
|
|
993
|
-
.join("\n") || null;
|
|
994
|
-
const output = composeWithDiagnostics(
|
|
995
|
-
body,
|
|
996
|
-
combinedError,
|
|
997
|
-
entry.lastWarning,
|
|
998
|
-
);
|
|
999
|
-
const ms = Date.now() - t0;
|
|
1000
|
-
const g = gitService.getStats();
|
|
1001
|
-
const u = usageStore.getStats();
|
|
1002
|
-
dlog(
|
|
1003
|
-
"info",
|
|
1004
|
-
`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"}`,
|
|
1005
|
-
);
|
|
1006
|
-
return stay({ ok: true, output: output + "\n" });
|
|
1007
|
-
} catch (e) {
|
|
1008
|
-
stats.requestsErrored++;
|
|
1009
|
-
throw e;
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
if (req.kind === "click") {
|
|
1014
|
-
return stay(await handleClick(req.verb, req.value));
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
if (req.kind === "debug") {
|
|
1018
|
-
// [LAW:single-enforcer] One trust-boundary check at the wire edge —
|
|
1019
|
-
// `what` is untrusted JSON. isDebugWhat narrows it to the discriminated
|
|
1020
|
-
// union the introspector consumes; an invalid value short-circuits
|
|
1021
|
-
// here, not deep inside buildDebugSnapshot.
|
|
1022
|
-
if (!isDebugWhat(req.what)) {
|
|
1023
|
-
return stay({
|
|
1024
|
-
ok: false,
|
|
1025
|
-
// [LAW:errors-context-in-errors] Include the allowed values so a
|
|
1026
|
-
// CLI consumer (or operator) sees what is supported without
|
|
1027
|
-
// grep — same pattern as the set-state verb's unknown-key error
|
|
1028
|
-
// in src/daemon/verbs/state-validators.ts.
|
|
1029
|
-
error: `unknown debug 'what': ${String(req.what)} (have: ${DEBUG_WHATS.join(", ")})`,
|
|
1030
|
-
code: "BAD_REQUEST",
|
|
1031
|
-
daemonV: PROTOCOL_VERSION,
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1034
|
-
// [LAW:dataflow-not-control-flow] The debug projection samples whatever
|
|
1035
|
-
// DSL state the cache currently holds. With cache keys scoped on
|
|
1036
|
-
// (projectDir, cwd) and the debug request carrying neither, we sample
|
|
1037
|
-
// the first populated existing entry — sufficient for `debug vars`,
|
|
1038
|
-
// `debug segments`, `debug config` against the active workload.
|
|
1039
|
-
// firstPopulatedState iterates existing entries only; it does NOT
|
|
1040
|
-
// create a fresh one, so debug introspection never has the side effect
|
|
1041
|
-
// of standing up a new (projectDir=undefined) cache entry tied to the
|
|
1042
|
-
// daemon's own process.cwd(). A future debug-target selector would
|
|
1043
|
-
// thread (projectDir, cwd) through the wire.
|
|
1044
|
-
const dbgEntry = renderCache.firstPopulatedState();
|
|
1045
|
-
// [LAW:dataflow-not-control-flow] Lazy per-segment serialization: the
|
|
1046
|
-
// cache stores StripCell arrays (cheap, written by renderDsl).
|
|
1047
|
-
// The debug projection needs strings, so serialize only for the
|
|
1048
|
-
// `segments` projection (`vars` and `config` don't need it) and only
|
|
1049
|
-
// when this request actually fires. Normal renders pay no per-segment
|
|
1050
|
-
// serializer cost — that work shifts to debug-request time, which is
|
|
1051
|
-
// operator-driven and rare.
|
|
1052
|
-
const dbgState =
|
|
1053
|
-
dbgEntry === null
|
|
1054
|
-
? null
|
|
1055
|
-
: {
|
|
1056
|
-
store: dbgEntry.store,
|
|
1057
|
-
registry: dbgEntry.registry,
|
|
1058
|
-
config: dbgEntry.config,
|
|
1059
|
-
compiled: dbgEntry.compiled,
|
|
1060
|
-
lastRenderBySegment:
|
|
1061
|
-
req.what === "segments"
|
|
1062
|
-
? serializeSegmentCells(
|
|
1063
|
-
dbgEntry.lastRenderCellsBySegment,
|
|
1064
|
-
dbgEntry.config.globals.charset ?? DEFAULT_CHARSET,
|
|
1065
|
-
dbgEntry.config.globals.colorCompatibility ??
|
|
1066
|
-
DEFAULT_COLOR_COMPATIBILITY,
|
|
1067
|
-
)
|
|
1068
|
-
: EMPTY_RENDER_MAP,
|
|
1069
|
-
};
|
|
1070
|
-
return stay({ ok: true, debug: buildDebugSnapshot(req.what, dbgState) });
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
return stay({
|
|
1074
|
-
ok: false,
|
|
1075
|
-
error: "unknown kind",
|
|
1076
|
-
code: "BAD_REQUEST",
|
|
1077
|
-
daemonV: PROTOCOL_VERSION,
|
|
1078
|
-
});
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// --- diagnostics composition ---
|
|
1082
|
-
//
|
|
1083
|
-
// [LAW:no-silent-fallbacks] Bad config can't quietly degrade output. The
|
|
1084
|
-
// render pipeline carries two independent diagnostic channels:
|
|
1085
|
-
// error — load-fatal: parse/validation failed; bar is last-known-good
|
|
1086
|
-
// or empty. Rendered red.
|
|
1087
|
-
// warning — advisory: load succeeded but something needs attention (e.g.
|
|
1088
|
-
// same-location .json5 + .json collision). Rendered amber.
|
|
1089
|
-
// Either way the failure is visible at the point of impact, and each
|
|
1090
|
-
// channel has its own click verb (show-config-error / show-config-warning)
|
|
1091
|
-
// so the operator can copy the message to clipboard for inspection.
|
|
1092
|
-
//
|
|
1093
|
-
// [LAW:one-type-per-behavior] Two severities → two channels. The
|
|
1094
|
-
// composer's signature carries both; severity is encoded in WHICH
|
|
1095
|
-
// argument is non-null, not in a string prefix or a tag inside the
|
|
1096
|
-
// message. The two icons render independently — both can show at once.
|
|
1097
|
-
//
|
|
1098
|
-
// [LAW:types-are-the-program] The diagnostic's visible text IS (a
|
|
1099
|
-
// projection of) the underlying message — not a constant label that hides
|
|
1100
|
-
// the content behind a click. The leading ⚠ + background color carry
|
|
1101
|
-
// severity; the rest of the cell is the actual error/warning, sanitized
|
|
1102
|
-
// and clipped to a single-line budget. A label divorced from the message
|
|
1103
|
-
// would be the type lying about what's in the channel.
|
|
1104
|
-
// [LAW:one-source-of-truth] Style constants come from the shared leaf
|
|
1105
|
-
// (src/render/diagnostic-style.ts) — the same visual identity the client's
|
|
1106
|
-
// permanent glyph uses. Only the OSC-8 link plumbing is local here.
|
|
1107
|
-
const OSC8_OPEN = "\x1b]8;;";
|
|
1108
|
-
const OSC8_CLOSE = "\x1b]8;;\x1b\\";
|
|
1109
|
-
const ST = "\x1b\\";
|
|
1110
|
-
|
|
1111
|
-
// [LAW:single-enforcer][LAW:no-silent-fallbacks] Parse render-path args with
|
|
1112
|
-
// the standard util at the trust boundary. `--config <path>` is the sole
|
|
1113
|
-
// valid render flag; every other flag is surfaced as a render-time
|
|
1114
|
-
// diagnostic icon (caller composes it alongside config errors). The
|
|
1115
|
-
// `--config` value is `~`-expanded here, so every consumer downstream
|
|
1116
|
-
// receives a literal path — no caller has to remember to expand it.
|
|
1117
|
-
//
|
|
1118
|
-
// `tokens: true, strict: false, allowPositionals: true` together let the
|
|
1119
|
-
// parser emit a token entry for every flag (known or unknown) without
|
|
1120
|
-
// throwing on unknown ones, and without mis-classifying their values as
|
|
1121
|
-
// positionals.
|
|
1122
|
-
function parseRenderArgs(args: string[]): {
|
|
1123
|
-
configFile: string | undefined;
|
|
1124
|
-
unknownFlagsError: string | null;
|
|
1125
|
-
} {
|
|
1126
|
-
const { values, tokens } = parseArgs({
|
|
1127
|
-
args: args.slice(1), // skip binary path
|
|
1128
|
-
options: { config: { type: "string" } },
|
|
1129
|
-
strict: false,
|
|
1130
|
-
tokens: true,
|
|
1131
|
-
allowPositionals: true,
|
|
1132
|
-
});
|
|
1133
|
-
const unknown = [
|
|
1134
|
-
...new Set(
|
|
1135
|
-
(tokens ?? [])
|
|
1136
|
-
.filter(
|
|
1137
|
-
(t): t is Extract<typeof t, { kind: "option" }> =>
|
|
1138
|
-
t.kind === "option" && t.name !== "config",
|
|
1139
|
-
)
|
|
1140
|
-
.map((t) => `--${t.name}`),
|
|
1141
|
-
),
|
|
1142
|
-
];
|
|
1143
|
-
const rawConfig = values.config as string | undefined;
|
|
1144
|
-
return {
|
|
1145
|
-
configFile: rawConfig === undefined ? undefined : expandHome(rawConfig),
|
|
1146
|
-
unknownFlagsError:
|
|
1147
|
-
unknown.length > 0 ? `Unknown flags: ${unknown.join(", ")}` : null,
|
|
1148
|
-
};
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
// Per-line visible budget and max rows for multi-line diagnostic blocks.
|
|
1152
|
-
// Messages from the config validator (formatIssues) are already structured
|
|
1153
|
-
// as one line per issue, so splitting there is the natural unit of display.
|
|
1154
|
-
// Deliberately decoupled from DEFAULT_TERMINAL_WIDTH: that constant means
|
|
1155
|
-
// "raw terminal cols we assume" and is reserved-against before reaching the
|
|
1156
|
-
// renderer; this one is a direct visible-char cap on already-rendered
|
|
1157
|
-
// diagnostic text. They happen to share the value 120 today but have
|
|
1158
|
-
// different semantic intents.
|
|
1159
|
-
const MAX_DIAGNOSTIC_LINE_LEN = 120;
|
|
1160
|
-
const MAX_DIAGNOSTIC_LINES = 8;
|
|
1161
|
-
|
|
1162
|
-
function makeDiagnosticLink(
|
|
1163
|
-
verb: typeof VERB_SHOW_CONFIG_ERROR | typeof VERB_SHOW_CONFIG_WARNING,
|
|
1164
|
-
message: string,
|
|
1165
|
-
bg: string,
|
|
1166
|
-
fg: string,
|
|
1167
|
-
): string {
|
|
1168
|
-
// Full message in the OSC-8 URL (clipboard-copy on click) — truncation
|
|
1169
|
-
// only affects what is visible, never what is accessible. [LAW:single-enforcer]
|
|
1170
|
-
// The click URL is born through effectsUrl like every other click — one
|
|
1171
|
-
// single-effect dispatch list, no second URL-format in the codebase.
|
|
1172
|
-
const url = effectsUrl([{ verb, args: [message] }]);
|
|
1173
|
-
// [LAW:dataflow-not-control-flow] Split on natural line boundaries from
|
|
1174
|
-
// the source message (config validator emits one issue per line), sanitize
|
|
1175
|
-
// each line individually, then render each as a separate styled row.
|
|
1176
|
-
// This preserves structured multi-line output instead of collapsing N
|
|
1177
|
-
// issues into a single truncated string the user cannot read.
|
|
1178
|
-
const lines = message
|
|
1179
|
-
.split(/\r\n|\r|\n/)
|
|
1180
|
-
.map((l) => sanitizeAndTruncate(l, MAX_DIAGNOSTIC_LINE_LEN))
|
|
1181
|
-
.filter(Boolean)
|
|
1182
|
-
.slice(0, MAX_DIAGNOSTIC_LINES);
|
|
1183
|
-
if (lines.length === 0) return "";
|
|
1184
|
-
const first = `${OSC8_OPEN}${url}${ST}${bg}${fg} ⚠ ${lines[0]} ${ANSI_RESET}${OSC8_CLOSE}`;
|
|
1185
|
-
const rest = lines
|
|
1186
|
-
.slice(1)
|
|
1187
|
-
.map(
|
|
1188
|
-
(l) =>
|
|
1189
|
-
`${OSC8_OPEN}${url}${ST}${bg}${fg} ${l} ${ANSI_RESET}${OSC8_CLOSE}`,
|
|
1190
|
-
);
|
|
1191
|
-
return [first, ...rest].join("\n");
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
function composeWithDiagnostics(
|
|
1195
|
-
body: string,
|
|
1196
|
-
error: string | null,
|
|
1197
|
-
warning: string | null,
|
|
1198
|
-
): string {
|
|
1199
|
-
// [LAW:dataflow-not-control-flow] Diagnostics list is data; the
|
|
1200
|
-
// composer walks it. Each non-null channel contributes one or more prefix
|
|
1201
|
-
// rows (makeDiagnosticLink returns a \n-joined multi-line block when the
|
|
1202
|
-
// message has natural line breaks). Order is error-first (more severe),
|
|
1203
|
-
// then warning, then body.
|
|
1204
|
-
const prefixes: string[] = [];
|
|
1205
|
-
if (error) {
|
|
1206
|
-
prefixes.push(
|
|
1207
|
-
makeDiagnosticLink(
|
|
1208
|
-
VERB_SHOW_CONFIG_ERROR,
|
|
1209
|
-
error,
|
|
1210
|
-
DIAGNOSTIC_ERROR_BG,
|
|
1211
|
-
DIAGNOSTIC_ERROR_FG,
|
|
1212
|
-
),
|
|
1213
|
-
);
|
|
1214
|
-
}
|
|
1215
|
-
if (warning) {
|
|
1216
|
-
prefixes.push(
|
|
1217
|
-
makeDiagnosticLink(
|
|
1218
|
-
VERB_SHOW_CONFIG_WARNING,
|
|
1219
|
-
warning,
|
|
1220
|
-
DIAGNOSTIC_WARNING_BG,
|
|
1221
|
-
DIAGNOSTIC_WARNING_FG,
|
|
1222
|
-
),
|
|
1223
|
-
);
|
|
1224
|
-
}
|
|
1225
|
-
if (prefixes.length === 0) return body;
|
|
1226
|
-
// No body → emit the diagnostic strip alone (startup-error case). Body
|
|
1227
|
-
// present → prepend on its own line so it's visible regardless of bar
|
|
1228
|
-
// width. Multiple diagnostics stack on their own lines.
|
|
1229
|
-
const strip = prefixes.join("\n");
|
|
1230
|
-
return body ? `${strip}\n${body}` : strip;
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// --- click verb dispatch ---
|
|
1234
|
-
// [LAW:dataflow-not-control-flow] The dispatcher is a table lookup. The verb
|
|
1235
|
-
// table (src/daemon/verbs/index.ts) is the single canonical list of supported
|
|
1236
|
-
// verbs — handlers live there, the dispatcher only routes.
|
|
1237
|
-
//
|
|
1238
|
-
// [LAW:types-are-the-program] The error class on the throw determines the
|
|
1239
|
-
// response code: BadVerbArgs (invalid input shape) becomes BAD_REQUEST; any
|
|
1240
|
-
// other Error (operational failure) becomes RENDER_FAILED. No string matching.
|
|
1241
|
-
|
|
1242
|
-
const verbCtx = { sessionState, dlog };
|
|
1243
|
-
|
|
1244
|
-
// [LAW:single-enforcer] Style + color compatibility shared by the render
|
|
1245
|
-
// path and the lazy debug-side per-segment serializer. Per-request `width`
|
|
1246
|
-
// is composed on top at the wire boundary (handleRequest("render")) and
|
|
1247
|
-
// passed through as renderOpts. Debug serialization composes its own
|
|
1248
|
-
// per-segment opts with width: Number.POSITIVE_INFINITY since each segment
|
|
1249
|
-
// is rendered standalone (wrap doesn't apply to a one-segment projection).
|
|
1250
|
-
const RENDER_OPTS_BASE = {
|
|
1251
|
-
style: "powerline" as const,
|
|
1252
|
-
colorCompatibility: DEFAULT_COLOR_COMPATIBILITY,
|
|
1253
|
-
wrap: DEFAULT_WRAP,
|
|
1254
|
-
padding: DEFAULT_PADDING,
|
|
1255
|
-
charset: DEFAULT_CHARSET,
|
|
1256
|
-
};
|
|
1257
|
-
const DEBUG_RENDER_OPTS: BuildLineOptions = {
|
|
1258
|
-
...RENDER_OPTS_BASE,
|
|
1259
|
-
width: Number.POSITIVE_INFINITY,
|
|
1260
|
-
};
|
|
1261
|
-
|
|
1262
|
-
// [LAW:no-defensive-null-guards] Reused empty map for the `vars` /
|
|
1263
|
-
// `config` debug projections — they don't read lastRenderBySegment but
|
|
1264
|
-
// the DaemonDslState type requires the field.
|
|
1265
|
-
const EMPTY_RENDER_MAP = new Map<string, string>();
|
|
1266
|
-
|
|
1267
|
-
// [LAW:one-source-of-truth] The joiner glyph vocabulary and the color depth
|
|
1268
|
-
// are serialization-time choices, and both are config-only (no SessionState
|
|
1269
|
-
// half) — so the faithful values are fully derivable from the sampled entry's
|
|
1270
|
-
// config, unlike style, whose live session-over-config resolution needs a
|
|
1271
|
-
// session a debug request doesn't carry. The caller threads the
|
|
1272
|
-
// entry-resolved values; this serializer never re-defaults them.
|
|
1273
|
-
function serializeSegmentCells(
|
|
1274
|
-
cells: ReadonlyMap<string, readonly RichText[]>,
|
|
1275
|
-
charset: Charset,
|
|
1276
|
-
colorCompatibility: ColorCompatibility,
|
|
1277
|
-
): Map<string, string> {
|
|
1278
|
-
const out = new Map<string, string>();
|
|
1279
|
-
for (const [name, segCells] of cells) {
|
|
1280
|
-
out.set(
|
|
1281
|
-
name,
|
|
1282
|
-
renderStripCells(segCells, {
|
|
1283
|
-
...DEBUG_RENDER_OPTS,
|
|
1284
|
-
charset,
|
|
1285
|
-
colorCompatibility,
|
|
1286
|
-
}),
|
|
1287
|
-
);
|
|
1288
|
-
}
|
|
1289
|
-
return out;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// [LAW:single-enforcer] The payload-builder dependency bundle. One value
|
|
1293
|
-
// passed through every render — the data the daemon brings to each tick.
|
|
1294
|
-
const payloadDeps = {
|
|
1295
|
-
gitProvider: gitService,
|
|
1296
|
-
usageStore,
|
|
1297
|
-
contextProvider,
|
|
1298
|
-
metricsProvider,
|
|
1299
|
-
tmuxService,
|
|
1300
|
-
// [LAW:single-enforcer] buildRenderPayload is the one log site for the
|
|
1301
|
-
// outcome-carrying provider lanes (git, cache).
|
|
1302
|
-
log: dlog,
|
|
1303
|
-
// [LAW:single-enforcer] The daemon's wall clock — the same instant source
|
|
1304
|
-
// the rate-limit ETA projection and the template's reset countdown read.
|
|
1305
|
-
clock: () => new Date(),
|
|
1306
|
-
};
|
|
1307
|
-
|
|
1308
|
-
function handleClick(verb: string, value: string): Response {
|
|
1309
|
-
const handler = VERBS.get(verb);
|
|
1310
|
-
if (!handler) {
|
|
1311
|
-
return {
|
|
1312
|
-
ok: false,
|
|
1313
|
-
error: `unknown click verb: ${verb}`,
|
|
1314
|
-
code: "BAD_REQUEST",
|
|
1315
|
-
daemonV: PROTOCOL_VERSION,
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
try {
|
|
1319
|
-
handler(value, verbCtx);
|
|
1320
|
-
return { ok: true, output: "" };
|
|
1321
|
-
} catch (e) {
|
|
1322
|
-
const code = e instanceof BadVerbArgs ? "BAD_REQUEST" : "RENDER_FAILED";
|
|
1323
|
-
return {
|
|
1324
|
-
ok: false,
|
|
1325
|
-
error: String(e instanceof Error ? e.message : e),
|
|
1326
|
-
code,
|
|
1327
|
-
daemonV: PROTOCOL_VERSION,
|
|
1328
|
-
};
|
|
1329
|
-
}
|
|
1330
|
-
}
|