@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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. 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
+ }