@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,451 @@
|
|
|
1
|
+
// [LAW:single-enforcer] Every subprocess in the Node runtime goes through one
|
|
2
|
+
// boundary. The primitive owns the import of `node:child_process`; every other
|
|
3
|
+
// module imports `launch`/`launchSync` from here. The ESLint config (and the
|
|
4
|
+
// kz8.2 DoD grep) enforces this.
|
|
5
|
+
//
|
|
6
|
+
// [LAW:one-type-per-behavior] `exec`/`execFile`/`spawn` and their sync twins
|
|
7
|
+
// are seven names for one act. `LaunchOpts` is the single shape; sync vs async
|
|
8
|
+
// is a separate function pair, not a config flag.
|
|
9
|
+
//
|
|
10
|
+
// [LAW:dataflow-not-control-flow] Categories flow through one boundary as
|
|
11
|
+
// data; the body is the same code path for every category. The metering layer
|
|
12
|
+
// reads the category off the request, not off the call site.
|
|
13
|
+
//
|
|
14
|
+
// [LAW:types-are-the-program] (kz8.6) Process lifetime is encoded in the
|
|
15
|
+
// operation, not in a flag. `launch`/`launchSync` are *waited*: the child is
|
|
16
|
+
// reaped before the caller resumes, so it cannot outlive its frame.
|
|
17
|
+
// `launchDetachedSync` is the *orphan*: it detaches and unrefs, deliberately
|
|
18
|
+
// outliving its caller — the daemon-handoff escape hatch, used only by the
|
|
19
|
+
// daemon-acquisition path. There is no `detached: boolean` flag on `LaunchOpts`
|
|
20
|
+
// ([LAW:no-mode-explosion]); the two lifetimes are two functions with two
|
|
21
|
+
// return contracts, so an unwaited helper that survives a render frame is
|
|
22
|
+
// unrepresentable here rather than forbidden by convention.
|
|
23
|
+
|
|
24
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
25
|
+
import type { ChildProcess, StdioOptions } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
import type { LaunchStatsHandle } from "./stats-handle";
|
|
28
|
+
|
|
29
|
+
// Closed list of subprocess categories. Adding a new spawn site requires
|
|
30
|
+
// adding its category here, which forces a code review of the new launch
|
|
31
|
+
// pattern. [LAW:no-mode-explosion]: no per-site escape hatch.
|
|
32
|
+
export const LAUNCH_CATEGORIES = [
|
|
33
|
+
"git",
|
|
34
|
+
"user-shell",
|
|
35
|
+
"tmux",
|
|
36
|
+
"click.pbcopy",
|
|
37
|
+
"click.open",
|
|
38
|
+
"install.plutil",
|
|
39
|
+
"install.osacompile",
|
|
40
|
+
"install.lsregister",
|
|
41
|
+
"install.pbcopy",
|
|
42
|
+
"install.open",
|
|
43
|
+
"daemon-spawn",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type LaunchCategory = (typeof LAUNCH_CATEGORIES)[number];
|
|
47
|
+
|
|
48
|
+
// [LAW:single-enforcer] Per-category minimum interval between spawn attempts
|
|
49
|
+
// (start timestamps). The limiter records on attempt, not on success — a
|
|
50
|
+
// failed spawn still arms the timer so a broken binary can't be retried in a
|
|
51
|
+
// tight loop. Sparse map: categories without entries have no rate limit.
|
|
52
|
+
// [LAW:no-mode-explosion] Bounds are constants here, not config knobs — the
|
|
53
|
+
// caps protect the host from misbehaving renderers/templates and don't need
|
|
54
|
+
// user tuning. Bump these if a legitimate workload starts hitting them.
|
|
55
|
+
const RATE_LIMITS: Partial<Record<LaunchCategory, number>> = {
|
|
56
|
+
// Click verbs: a misbehaving template emitting many clickable links + a
|
|
57
|
+
// user rapid-clicking = unbounded helpers. One spawn per second is enough
|
|
58
|
+
// for any human click cadence.
|
|
59
|
+
"click.pbcopy": 1000,
|
|
60
|
+
"click.open": 1000,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// [LAW:one-source-of-truth] Last-attempt timestamp per category — the data
|
|
64
|
+
// the rate-limit decision reads. Recorded for every attempted spawn (success
|
|
65
|
+
// or spawn-error); rate-limit rejections do NOT update this, because no
|
|
66
|
+
// spawn was attempted. Module-scope state is acceptable here because
|
|
67
|
+
// `launch.ts` is itself the single enforcer; nothing else mutates this.
|
|
68
|
+
const lastStartAt = new Map<LaunchCategory, number>();
|
|
69
|
+
|
|
70
|
+
// [LAW:dataflow-not-control-flow] The rate-limit decision is a pure function
|
|
71
|
+
// of (category, now, last-start, policy). Same code path every call; the
|
|
72
|
+
// result type carries which branch fired.
|
|
73
|
+
function checkRateLimit(
|
|
74
|
+
category: LaunchCategory,
|
|
75
|
+
):
|
|
76
|
+
| { allowed: true }
|
|
77
|
+
| { allowed: false; minIntervalMs: number; sinceLastMs: number } {
|
|
78
|
+
const minIntervalMs = RATE_LIMITS[category];
|
|
79
|
+
if (minIntervalMs === undefined) return { allowed: true };
|
|
80
|
+
const last = lastStartAt.get(category);
|
|
81
|
+
if (last === undefined) return { allowed: true };
|
|
82
|
+
const sinceLastMs = Date.now() - last;
|
|
83
|
+
if (sinceLastMs >= minIntervalMs) return { allowed: true };
|
|
84
|
+
return { allowed: false, minIntervalMs, sinceLastMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function recordStart(category: LaunchCategory): void {
|
|
88
|
+
lastStartAt.set(category, Date.now());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Exposed for tests only — resets the rate-limit tracker so each test starts
|
|
92
|
+
// from a clean state.
|
|
93
|
+
export function __resetRateLimitsForTest(): void {
|
|
94
|
+
lastStartAt.clear();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface LaunchOpts {
|
|
98
|
+
bin: string;
|
|
99
|
+
args?: string[];
|
|
100
|
+
cwd?: string;
|
|
101
|
+
env?: NodeJS.ProcessEnv;
|
|
102
|
+
timeoutMs?: number;
|
|
103
|
+
stdinInput?: string | Buffer;
|
|
104
|
+
category: LaunchCategory;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type LaunchResult =
|
|
108
|
+
| { ok: true; stdout: string; stderr: string; exitCode: number | null }
|
|
109
|
+
| {
|
|
110
|
+
ok: false;
|
|
111
|
+
// [LAW:one-type-per-behavior] Distinct termination causes get distinct
|
|
112
|
+
// tags so callers + stats can attribute correctly. "timeout" means the
|
|
113
|
+
// local timer fired; "signal" means the OS or external killer ended the
|
|
114
|
+
// child for some other reason (SIGKILL/SIGINT/SIGPIPE/SIGHUP/...);
|
|
115
|
+
// "non-zero" is a clean exit with a non-zero code; "spawn-error" is a
|
|
116
|
+
// failure before the child started; "rate-limited" means the primitive
|
|
117
|
+
// refused to spawn because the per-category minimum interval was not
|
|
118
|
+
// yet elapsed — no child process was launched.
|
|
119
|
+
reason:
|
|
120
|
+
| "timeout"
|
|
121
|
+
| "signal"
|
|
122
|
+
| "spawn-error"
|
|
123
|
+
| "non-zero"
|
|
124
|
+
| "rate-limited";
|
|
125
|
+
stdout: string;
|
|
126
|
+
stderr: string;
|
|
127
|
+
exitCode: number | null;
|
|
128
|
+
signal: NodeJS.Signals | null;
|
|
129
|
+
error?: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function rateLimitedResult(
|
|
133
|
+
category: LaunchCategory,
|
|
134
|
+
minIntervalMs: number,
|
|
135
|
+
sinceLastMs: number,
|
|
136
|
+
): LaunchResult {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: "rate-limited",
|
|
140
|
+
stdout: "",
|
|
141
|
+
stderr: "",
|
|
142
|
+
exitCode: null,
|
|
143
|
+
signal: null,
|
|
144
|
+
error: `rate-limited: ${category} min interval ${minIntervalMs}ms, last start ${sinceLastMs}ms ago`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let statsHandle: LaunchStatsHandle | null = null;
|
|
149
|
+
|
|
150
|
+
// Install the stats handle once, at daemon startup. Other runtimes (Node
|
|
151
|
+
// fallback, install path) leave it null and pay no metering cost.
|
|
152
|
+
export function setLaunchStats(handle: LaunchStatsHandle | null): void {
|
|
153
|
+
statsHandle = handle;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// [LAW:types-are-the-program] Grace between SIGTERM and SIGKILL on the timeout
|
|
157
|
+
// path. The lifetime invariant ("waited — child reaped before the caller
|
|
158
|
+
// resumes") requires that a child which ignores SIGTERM is still gone before we
|
|
159
|
+
// resolve. SIGTERM lets well-behaved children flush/clean up; SIGKILL is the
|
|
160
|
+
// backstop so the promise cannot resolve while the child is still alive.
|
|
161
|
+
const TIMEOUT_KILL_GRACE_MS = 250;
|
|
162
|
+
|
|
163
|
+
export async function launch(opts: LaunchOpts): Promise<LaunchResult> {
|
|
164
|
+
const gate = checkRateLimit(opts.category);
|
|
165
|
+
if (!gate.allowed) {
|
|
166
|
+
return rateLimitedResult(
|
|
167
|
+
opts.category,
|
|
168
|
+
gate.minIntervalMs,
|
|
169
|
+
gate.sinceLastMs,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
recordStart(opts.category);
|
|
173
|
+
const t0 = Date.now();
|
|
174
|
+
statsHandle?.onStart(opts.category);
|
|
175
|
+
|
|
176
|
+
return new Promise<LaunchResult>((resolve) => {
|
|
177
|
+
let child: ChildProcess;
|
|
178
|
+
try {
|
|
179
|
+
child = spawn(opts.bin, opts.args ?? [], {
|
|
180
|
+
cwd: opts.cwd,
|
|
181
|
+
env: opts.env,
|
|
182
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
statsHandle?.onEnd(opts.category, Date.now() - t0);
|
|
186
|
+
resolve({
|
|
187
|
+
ok: false,
|
|
188
|
+
reason: "spawn-error",
|
|
189
|
+
stdout: "",
|
|
190
|
+
stderr: "",
|
|
191
|
+
exitCode: null,
|
|
192
|
+
signal: null,
|
|
193
|
+
error: err instanceof Error ? err.message : String(err),
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let stdout = "";
|
|
199
|
+
let stderr = "";
|
|
200
|
+
let settled = false;
|
|
201
|
+
let timer: NodeJS.Timeout | null = null;
|
|
202
|
+
let killTimer: NodeJS.Timeout | null = null;
|
|
203
|
+
// [LAW:dataflow-not-control-flow] Whether the close was caused by *our*
|
|
204
|
+
// timer is data we have to carry. The OS doesn't tell us why a child was
|
|
205
|
+
// signalled — without this flag, SIGKILL from the OOM killer, SIGINT
|
|
206
|
+
// propagated through the tty, SIGPIPE on a closed pipe, etc. all get
|
|
207
|
+
// misreported as "timeout".
|
|
208
|
+
let timedOut = false;
|
|
209
|
+
|
|
210
|
+
const settle = (r: LaunchResult) => {
|
|
211
|
+
if (settled) return;
|
|
212
|
+
settled = true;
|
|
213
|
+
if (timer) clearTimeout(timer);
|
|
214
|
+
if (killTimer) clearTimeout(killTimer);
|
|
215
|
+
statsHandle?.onEnd(opts.category, Date.now() - t0);
|
|
216
|
+
resolve(r);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
220
|
+
stdout += chunk.toString("utf8");
|
|
221
|
+
});
|
|
222
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
223
|
+
stderr += chunk.toString("utf8");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
child.on("error", (err) => {
|
|
227
|
+
settle({
|
|
228
|
+
ok: false,
|
|
229
|
+
reason: "spawn-error",
|
|
230
|
+
stdout,
|
|
231
|
+
stderr,
|
|
232
|
+
exitCode: null,
|
|
233
|
+
signal: null,
|
|
234
|
+
error: err.message,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
child.on("close", (code, signal) => {
|
|
239
|
+
// [LAW:types-are-the-program] We resolve here, on the *actual* exit —
|
|
240
|
+
// including the timeout path. Once `timedOut` is set the deadline has
|
|
241
|
+
// elapsed, so the outcome is "timeout" regardless of which signal
|
|
242
|
+
// (SIGTERM or the escalated SIGKILL) finally ended the child.
|
|
243
|
+
if (timedOut) {
|
|
244
|
+
settle({
|
|
245
|
+
ok: false,
|
|
246
|
+
reason: "timeout",
|
|
247
|
+
stdout,
|
|
248
|
+
stderr,
|
|
249
|
+
exitCode: code,
|
|
250
|
+
signal,
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (code === 0) {
|
|
255
|
+
settle({ ok: true, stdout, stderr, exitCode: code });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const reason: "signal" | "non-zero" = signal ? "signal" : "non-zero";
|
|
259
|
+
settle({
|
|
260
|
+
ok: false,
|
|
261
|
+
reason,
|
|
262
|
+
stdout,
|
|
263
|
+
stderr,
|
|
264
|
+
exitCode: code,
|
|
265
|
+
signal,
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (opts.timeoutMs && opts.timeoutMs > 0) {
|
|
270
|
+
timer = setTimeout(() => {
|
|
271
|
+
timedOut = true;
|
|
272
|
+
// [LAW:types-are-the-program] Do NOT settle here. We signal and let
|
|
273
|
+
// the `close` handler resolve once the child is actually gone, so the
|
|
274
|
+
// promise never resolves while the child is still alive. SIGTERM
|
|
275
|
+
// first; SIGKILL after a grace period if the child ignores it.
|
|
276
|
+
//
|
|
277
|
+
// [LAW:dataflow-not-control-flow] `child.pid` is undefined when the
|
|
278
|
+
// spawn failed asynchronously (ENOENT) — there is no process to signal
|
|
279
|
+
// and the `error` event settles that case. kill() on a pid-less child
|
|
280
|
+
// signals the wrong target (verified: it can terminate the caller), so
|
|
281
|
+
// escalation is gated on the pid actually existing.
|
|
282
|
+
if (child.pid !== undefined) {
|
|
283
|
+
child.kill("SIGTERM");
|
|
284
|
+
killTimer = setTimeout(() => {
|
|
285
|
+
child.kill("SIGKILL");
|
|
286
|
+
}, TIMEOUT_KILL_GRACE_MS);
|
|
287
|
+
}
|
|
288
|
+
}, opts.timeoutMs);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (opts.stdinInput !== undefined && child.stdin) {
|
|
292
|
+
child.stdin.end(opts.stdinInput);
|
|
293
|
+
} else if (child.stdin) {
|
|
294
|
+
child.stdin.end();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Sync variant. For callers that genuinely cannot be async — the spawn
|
|
300
|
+
// outcome must be settled before the function returns.
|
|
301
|
+
export function launchSync(opts: LaunchOpts): LaunchResult {
|
|
302
|
+
const gate = checkRateLimit(opts.category);
|
|
303
|
+
if (!gate.allowed) {
|
|
304
|
+
return rateLimitedResult(
|
|
305
|
+
opts.category,
|
|
306
|
+
gate.minIntervalMs,
|
|
307
|
+
gate.sinceLastMs,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
recordStart(opts.category);
|
|
311
|
+
const t0 = Date.now();
|
|
312
|
+
statsHandle?.onStart(opts.category);
|
|
313
|
+
|
|
314
|
+
const stdio: StdioOptions = ["pipe", "pipe", "pipe"];
|
|
315
|
+
try {
|
|
316
|
+
const result = spawnSync(opts.bin, opts.args ?? [], {
|
|
317
|
+
cwd: opts.cwd,
|
|
318
|
+
env: opts.env,
|
|
319
|
+
input: opts.stdinInput,
|
|
320
|
+
timeout:
|
|
321
|
+
opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : undefined,
|
|
322
|
+
stdio,
|
|
323
|
+
encoding: "utf8",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
statsHandle?.onEnd(opts.category, Date.now() - t0);
|
|
327
|
+
|
|
328
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
329
|
+
const stderr = typeof result.stderr === "string" ? result.stderr : "";
|
|
330
|
+
|
|
331
|
+
if (result.error) {
|
|
332
|
+
// Node sets `error` for ETIMEDOUT and ENOENT; distinguish by code.
|
|
333
|
+
const code = (result.error as NodeJS.ErrnoException).code;
|
|
334
|
+
const reason: "timeout" | "spawn-error" =
|
|
335
|
+
code === "ETIMEDOUT" ? "timeout" : "spawn-error";
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
reason,
|
|
339
|
+
stdout,
|
|
340
|
+
stderr,
|
|
341
|
+
exitCode: null,
|
|
342
|
+
signal: result.signal ?? null,
|
|
343
|
+
error: result.error.message,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (result.status === 0) {
|
|
348
|
+
return { ok: true, stdout, stderr, exitCode: result.status };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// [LAW:dataflow-not-control-flow] The reason data lives in the
|
|
352
|
+
// spawnSync result, not in the surrounding control flow. Node sets
|
|
353
|
+
// `result.signal` whenever the child died from a signal — including but
|
|
354
|
+
// not limited to the timeout's SIGTERM. We can only attribute "timeout"
|
|
355
|
+
// when a timeout was actually requested; otherwise the signal came from
|
|
356
|
+
// somewhere else (OOM killer, ctrl-C through the tty group, etc.).
|
|
357
|
+
const hasTimeout = opts.timeoutMs !== undefined && opts.timeoutMs > 0;
|
|
358
|
+
const reason: "timeout" | "signal" | "non-zero" = result.signal
|
|
359
|
+
? hasTimeout && result.signal === "SIGTERM"
|
|
360
|
+
? "timeout"
|
|
361
|
+
: "signal"
|
|
362
|
+
: "non-zero";
|
|
363
|
+
return {
|
|
364
|
+
ok: false,
|
|
365
|
+
reason,
|
|
366
|
+
stdout,
|
|
367
|
+
stderr,
|
|
368
|
+
exitCode: result.status,
|
|
369
|
+
signal: result.signal ?? null,
|
|
370
|
+
};
|
|
371
|
+
} catch (err) {
|
|
372
|
+
statsHandle?.onEnd(opts.category, Date.now() - t0);
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
reason: "spawn-error",
|
|
376
|
+
stdout: "",
|
|
377
|
+
stderr: "",
|
|
378
|
+
exitCode: null,
|
|
379
|
+
signal: null,
|
|
380
|
+
error: err instanceof Error ? err.message : String(err),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// [LAW:single-enforcer] The one orphan operation: a detached, unref'd,
|
|
386
|
+
// fire-and-forget launch that deliberately outlives its caller. This is the
|
|
387
|
+
// only Node-side launch with that lifetime; everything else waits. It returns
|
|
388
|
+
// the typed spawn outcome synchronously (so a failed spawn surfaces as
|
|
389
|
+
// `ok: false` rather than a discarded Promise reporting success), and meters
|
|
390
|
+
// through the stats handle so orphan spawns still show up in daemon-stats.
|
|
391
|
+
export function launchDetachedSync(opts: LaunchOpts): LaunchResult {
|
|
392
|
+
const gate = checkRateLimit(opts.category);
|
|
393
|
+
if (!gate.allowed) {
|
|
394
|
+
return rateLimitedResult(
|
|
395
|
+
opts.category,
|
|
396
|
+
gate.minIntervalMs,
|
|
397
|
+
gate.sinceLastMs,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
recordStart(opts.category);
|
|
401
|
+
const t0 = Date.now();
|
|
402
|
+
statsHandle?.onStart(opts.category);
|
|
403
|
+
const result = launchDetachedSyncInner(opts);
|
|
404
|
+
statsHandle?.onEnd(opts.category, Date.now() - t0);
|
|
405
|
+
return result;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function launchDetachedSyncInner(opts: LaunchOpts): LaunchResult {
|
|
409
|
+
let child;
|
|
410
|
+
try {
|
|
411
|
+
child = spawn(opts.bin, opts.args ?? [], {
|
|
412
|
+
cwd: opts.cwd,
|
|
413
|
+
env: opts.env,
|
|
414
|
+
detached: true,
|
|
415
|
+
stdio: "ignore",
|
|
416
|
+
});
|
|
417
|
+
} catch (err) {
|
|
418
|
+
// spawn throws synchronously on some failure modes (invalid options,
|
|
419
|
+
// EACCES on some platforms).
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
reason: "spawn-error",
|
|
423
|
+
stdout: "",
|
|
424
|
+
stderr: "",
|
|
425
|
+
exitCode: null,
|
|
426
|
+
signal: null,
|
|
427
|
+
error: err instanceof Error ? err.message : String(err),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
// [LAW:no-silent-fallbacks] spawn() with ENOENT (e.g. missing binary) does
|
|
431
|
+
// *not* throw — it returns a ChildProcess with pid=undefined that emits
|
|
432
|
+
// 'error' asynchronously. Two things matter here:
|
|
433
|
+
// 1. The 'error' must have a listener or Node crashes the process.
|
|
434
|
+
// 2. The synchronous return must reflect that the spawn failed.
|
|
435
|
+
// We attach a no-op listener and use the synchronously-observable absence
|
|
436
|
+
// of a pid as the spawn-failure signal.
|
|
437
|
+
child.once("error", () => {});
|
|
438
|
+
if (child.pid === undefined) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
reason: "spawn-error",
|
|
442
|
+
stdout: "",
|
|
443
|
+
stderr: "",
|
|
444
|
+
exitCode: null,
|
|
445
|
+
signal: null,
|
|
446
|
+
error: `spawn(${opts.bin}): no pid (binary not found or unexecutable)`,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
child.unref();
|
|
450
|
+
return { ok: true, stdout: "", stderr: "", exitCode: null };
|
|
451
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// [LAW:locality-or-seam] The seam between `launch.ts` and `RuntimeStats`.
|
|
2
|
+
// `launch.ts` cannot import `RuntimeStats` directly — that would couple every
|
|
3
|
+
// caller's runtime (Node fallback, install path) to the daemon's stats object.
|
|
4
|
+
// Instead, the daemon constructs a stats object that implements this
|
|
5
|
+
// interface and hands it to `setLaunchStats()` at startup. Other runtimes
|
|
6
|
+
// pass null; the launcher no-ops.
|
|
7
|
+
|
|
8
|
+
import type { LaunchCategory } from "./launch";
|
|
9
|
+
|
|
10
|
+
export interface LaunchStatsHandle {
|
|
11
|
+
onStart(category: LaunchCategory): void;
|
|
12
|
+
onEnd(category: LaunchCategory, durationMs: number): void;
|
|
13
|
+
}
|