@codeyam-editor/codeyam-editor 0.1.2 → 0.1.4

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 (37) hide show
  1. package/npm/cloud.js +779 -116
  2. package/npm/container.js +11 -0
  3. package/npm/disk-pressure.js +89 -0
  4. package/npm/editor-dev.js +144 -52
  5. package/npm/editor.js +47 -17
  6. package/npm/fleet-branch.js +49 -0
  7. package/npm/fleet-broker-death.js +44 -0
  8. package/npm/fleet-broker-debounce.js +63 -0
  9. package/npm/fleet-broker-status.js +45 -0
  10. package/npm/fleet-build-data.js +81 -0
  11. package/npm/fleet-cleanup.js +216 -16
  12. package/npm/fleet-effect-status.js +179 -0
  13. package/npm/fleet-grouping.js +56 -0
  14. package/npm/fleet-job-badge.js +83 -0
  15. package/npm/fleet-memory.js +35 -5
  16. package/npm/fleet-plans.js +96 -0
  17. package/npm/fleet-reconcile.js +58 -1
  18. package/npm/fleet-reset.js +94 -16
  19. package/npm/launch-config.js +197 -14
  20. package/npm/preflight-guidance.js +9 -0
  21. package/npm/scenario-check.js +106 -1
  22. package/npm/scenario-handlers.js +16 -0
  23. package/npm/scenario-interactivity.js +53 -7
  24. package/npm/scenario-playwright.js +184 -5
  25. package/npm/utils.js +238 -53
  26. package/package.json +5 -5
  27. package/ui/dist/assets/EditorShell-tMMdarZD.js +187 -0
  28. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +5 -0
  29. package/ui/dist/assets/{ScenarioDataPanelFullPage-C0YHQB-T.js → ScenarioDataPanelFullPage-5G6KuZgw.js} +1 -1
  30. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +1 -0
  31. package/ui/dist/assets/index-BGqairFV.css +1 -0
  32. package/ui/dist/assets/{index-BUYNFqKK.js → index-BuK0lizt.js} +9 -9
  33. package/ui/dist/index.html +2 -2
  34. package/ui/dist/assets/EditorShell-DWaeBxgj.js +0 -186
  35. package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +0 -5
  36. package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +0 -1
  37. package/ui/dist/assets/index-DVr5rY2d.css +0 -1
@@ -219,6 +219,73 @@ async function waitForAnimationsSettled(
219
219
  return { settled: false, elapsedMs: Date.now() - started };
220
220
  }
221
221
 
222
+ // Track in-flight network requests on a Playwright page so a capture can wait
223
+ // for client-side data fetches to settle before screenshotting. The
224
+ // resource-timing API (`performance.getEntriesByType("resource")`) only records
225
+ // COMPLETED requests, so it cannot see a fetch that is still in flight — the
226
+ // exact window where a client-fetch page shows a loading skeleton. We count
227
+ // request starts against finishes/failures instead. Returns a live view:
228
+ // `inFlight()` is the current outstanding count and `lastActivityMs()` is the
229
+ // timestamp of the most recent request start OR completion (0 when the page has
230
+ // made no requests since the tracker attached). Attach BEFORE navigation so
231
+ // every request is counted. Stack-agnostic — it observes raw HTTP activity, not
232
+ // any framework's fetch wrapper.
233
+ function createNetworkTracker(page) {
234
+ let inFlight = 0;
235
+ let lastActivityMs = 0;
236
+ const bump = () => {
237
+ lastActivityMs = Date.now();
238
+ };
239
+ if (page && typeof page.on === "function") {
240
+ page.on("request", () => {
241
+ inFlight += 1;
242
+ bump();
243
+ });
244
+ const settle = () => {
245
+ inFlight = Math.max(0, inFlight - 1);
246
+ bump();
247
+ };
248
+ page.on("requestfinished", settle);
249
+ page.on("requestfailed", settle);
250
+ }
251
+ return {
252
+ inFlight: () => inFlight,
253
+ lastActivityMs: () => lastActivityMs,
254
+ };
255
+ }
256
+
257
+ // Bounded network-quiet wait: after the DOM is stable a client-side data fetch
258
+ // can still be in flight — the loading skeleton is gone but the fetched rows
259
+ // haven't replaced it yet, so a screenshot here catches the in-between frame.
260
+ // Wait until no request has been outstanding for `quietWindowMs`, hard-capped at
261
+ // `overallTimeoutMs`. Two properties matter:
262
+ // - A page that made NO requests (lastActivityMs stays 0) is already quiet and
263
+ // returns on the first poll, so server-rendered captures incur no extra wait.
264
+ // - A streaming / long-poll endpoint that never goes idle hits the cap and the
265
+ // caller captures anyway — the wait can never hang the capture.
266
+ async function waitForNetworkQuiet(
267
+ tracker,
268
+ { quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
269
+ ) {
270
+ const started = Date.now();
271
+ while (Date.now() - started < overallTimeoutMs) {
272
+ const idleForMs = Date.now() - tracker.lastActivityMs();
273
+ if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) {
274
+ const elapsedMs = Date.now() - started;
275
+ logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs });
276
+ return { quiet: true, elapsedMs };
277
+ }
278
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
279
+ }
280
+ const elapsedMs = Date.now() - started;
281
+ logCaptureTiming("network-quiet", {
282
+ outcome: "capped",
283
+ elapsedMs,
284
+ inFlight: tracker.inFlight(),
285
+ });
286
+ return { quiet: false, elapsedMs };
287
+ }
288
+
222
289
  // `loadingMarkers` are the project's app-specific loading strings (from
223
290
  // stack.json `capture.loadingMarkers`); they extend the codeyam-harness
224
291
  // defaults so a stable-but-still-loading app screen counts as "not ready"
@@ -233,15 +300,41 @@ async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers
233
300
  while (Date.now() - started < timeoutMs) {
234
301
  await page.waitForTimeout(500);
235
302
 
236
- const pageState = await target.evaluate(() => ({
237
- bodyText: document.body?.innerText ?? "",
238
- html: document.body?.innerHTML ?? "",
239
- }));
303
+ const pageState = await target.evaluate(() => {
304
+ const getById = document.getElementById;
305
+ const root =
306
+ typeof getById === "function" ? document.getElementById("root") : null;
307
+ return {
308
+ bodyText: document.body?.innerText ?? "",
309
+ html: document.body?.innerHTML ?? "",
310
+ // Whether the SPA mount point exists AND has painted anything. An
311
+ // existing-but-empty `<div id="root">` is the pre-paint window of a
312
+ // slow-first-paint scenario (e.g. a live-session app that spends a few
313
+ // seconds connecting before the gate UI mounts). `rootExists` lets us
314
+ // distinguish that from a mid-redirect/teardown `null` body, where
315
+ // there is no root to wait on and a stable-empty page is legitimately
316
+ // settled.
317
+ rootExists: !!root,
318
+ rootChildCount: root ? root.childElementCount : 0,
319
+ };
320
+ });
240
321
 
241
322
  lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
242
323
  lastHtmlChanged = pageState.html !== lastHtml;
243
324
 
244
- if (!lastHadLoadingMarkers && !lastHtmlChanged) {
325
+ // A mounted-but-unpainted root is "still loading", not "settled": the HTML
326
+ // can sit byte-stable for a second or two while the SPA boots, which would
327
+ // otherwise satisfy the stability check and capture a blank frame before
328
+ // first paint. Treat an existing root with zero children AND no body text
329
+ // as not-ready so the loop keeps polling until the app actually paints (or
330
+ // the overall timeout fires, by which point real content is present). A
331
+ // `null` body (rootExists=false) is unaffected — it stays trivially stable.
332
+ const rootUnpainted =
333
+ pageState.rootExists &&
334
+ pageState.rootChildCount === 0 &&
335
+ (pageState.bodyText ?? "").trim().length === 0;
336
+
337
+ if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) {
245
338
  stableCount += 1;
246
339
  if (stableCount >= 2) {
247
340
  const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
@@ -339,6 +432,88 @@ async function loadScenarioTopLevel(
339
432
  return { frame: page.mainFrame(), response };
340
433
  }
341
434
 
435
+ // Collect up to 20 distinct visible labels of interactive elements on the
436
+ // page — buttons, links, role=button, form controls, <summary>, and anything
437
+ // with an onclick. Used to build an ACTIONABLE error when an interaction's
438
+ // target matches nothing: the agent reliably knows a label it rendered, so
439
+ // listing the real candidates turns a silent blank capture into a "did you
440
+ // mean one of these?" hint. Pure read (no clicks); falls back to value /
441
+ // aria-label / placeholder when an element has no text.
442
+ async function collectInteractiveLabels(frame) {
443
+ return frame.evaluate(() => {
444
+ const selector =
445
+ "button, a[href], [role=button], input, select, textarea, summary, [onclick]";
446
+ const nodes = Array.from(document.querySelectorAll(selector));
447
+ const labels = nodes
448
+ .map((node) => {
449
+ const text =
450
+ (node.innerText || node.textContent || "").trim() ||
451
+ (typeof node.value === "string" ? node.value.trim() : "") ||
452
+ (node.getAttribute && node.getAttribute("aria-label")) ||
453
+ (node.getAttribute && node.getAttribute("placeholder")) ||
454
+ "";
455
+ return String(text).trim();
456
+ })
457
+ .filter((label) => label.length > 0);
458
+ return Array.from(new Set(labels)).slice(0, 20);
459
+ });
460
+ }
461
+
462
+ // Drive a single user-style interaction against the settled frame before the
463
+ // screenshot, so an interactive state (expanded accordion, open modal, filled
464
+ // field) can be captured without editing app source.
465
+ //
466
+ // The target is matched by visible `text` (preferred — the agent reliably
467
+ // knows the label it rendered) or a CSS `selector`. `action` is click / fill /
468
+ // press; `value` carries the text for `fill` or the key for `press` (e.g.
469
+ // `Enter`). On a no-match target this THROWS with the list of candidate
470
+ // interactive labels — the capture script's outer catch turns that into a
471
+ // failed capture with an actionable message, never a silent blank screenshot.
472
+ async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
473
+ const { action, selector, text, value } = interaction || {};
474
+
475
+ let locator;
476
+ let targetDesc;
477
+ if (typeof text === "string" && text.length > 0) {
478
+ locator = frame.getByText(text, { exact: false }).first();
479
+ targetDesc = `text "${text}"`;
480
+ } else if (typeof selector === "string" && selector.length > 0) {
481
+ locator = frame.locator(selector).first();
482
+ targetDesc = `selector "${selector}"`;
483
+ } else {
484
+ throw new Error(
485
+ "preview-interact: interaction requires a `text` or `selector` target",
486
+ );
487
+ }
488
+
489
+ const matchCount = await locator.count();
490
+ if (matchCount === 0) {
491
+ const candidates = await collectInteractiveLabels(frame);
492
+ const candidateList =
493
+ candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
494
+ throw new Error(
495
+ `preview-interact: no element matched ${targetDesc}. ` +
496
+ `Candidate interactive labels: ${candidateList}`,
497
+ );
498
+ }
499
+
500
+ switch (action) {
501
+ case "click":
502
+ await locator.click({ timeout: timeoutMs });
503
+ break;
504
+ case "fill":
505
+ await locator.fill(value ?? "", { timeout: timeoutMs });
506
+ break;
507
+ case "press":
508
+ await locator.press(value || "Enter", { timeout: timeoutMs });
509
+ break;
510
+ default:
511
+ throw new Error(
512
+ `preview-interact: unknown action "${action}" (expected click | fill | press)`,
513
+ );
514
+ }
515
+ }
516
+
342
517
  module.exports = {
343
518
  logCaptureTiming,
344
519
  resolveTcpTarget,
@@ -349,7 +524,11 @@ module.exports = {
349
524
  collectImageStates,
350
525
  waitForImagesSettled,
351
526
  waitForAnimationsSettled,
527
+ createNetworkTracker,
528
+ waitForNetworkQuiet,
352
529
  waitForStablePage,
353
530
  loadScenarioInIframe,
354
531
  loadScenarioTopLevel,
532
+ collectInteractiveLabels,
533
+ performInteraction,
355
534
  };
package/npm/utils.js CHANGED
@@ -8,14 +8,20 @@ const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
8
8
 
9
9
  /**
10
10
  * Map (process.platform, process.arch) → the platform sub-package that
11
- * carries the matching native binary. Kept as a small data table so adding
12
- * a new platform (e.g. linux/arm64) is one entry, not a new branch.
11
+ * carries the matching native binaries. Each sub-package ships TWO
12
+ * binaries side-by-side in its `bin/` dir: the `codeyam-editor` monolith
13
+ * and the dedicated `codeyam-editor-pty-broker` daemon (its own
14
+ * executable file / inode, for a stable honest process name). They live
15
+ * in the same dir so the broker auto-spawn's sibling-of-current-exe
16
+ * resolution finds the broker next to the monolith with no copying.
17
+ * Kept as a small data table so adding a new platform (e.g. linux/arm64)
18
+ * is one entry, not a new branch.
13
19
  */
14
20
  const PLATFORM_PACKAGES = {
15
- "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor" },
16
- "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor" },
17
- "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor" },
18
- "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe" },
21
+ "darwin-arm64": { pkg: "@codeyam-editor/codeyam-editor-darwin-arm64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
22
+ "darwin-x64": { pkg: "@codeyam-editor/codeyam-editor-darwin-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
23
+ "linux-x64": { pkg: "@codeyam-editor/codeyam-editor-linux-x64", binary: "codeyam-editor", broker: "codeyam-editor-pty-broker" },
24
+ "win32-x64": { pkg: "@codeyam-editor/codeyam-editor-win32-x64", binary: "codeyam-editor.exe", broker: "codeyam-editor-pty-broker.exe" },
19
25
  };
20
26
 
21
27
  /**
@@ -27,6 +33,14 @@ function binaryName(platform = process.platform) {
27
33
  return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor";
28
34
  }
29
35
 
36
+ /**
37
+ * Platform-correct executable filename for the dedicated broker binary.
38
+ * Mirrors {@link binaryName} for the second shipped binary.
39
+ */
40
+ function brokerBinaryName(platform = process.platform) {
41
+ return platform === "win32" ? "codeyam-editor-pty-broker.exe" : "codeyam-editor-pty-broker";
42
+ }
43
+
30
44
  /** Look up the platform sub-package metadata for a given platform/arch pair. */
31
45
  function platformPackageInfo(platform = process.platform, arch = process.arch) {
32
46
  return PLATFORM_PACKAGES[`${platform}-${arch}`] || null;
@@ -62,10 +76,25 @@ function findBinary() {
62
76
  }
63
77
 
64
78
  const name = binaryName();
65
- const devCandidates = [
66
- path.join(rootDir, "target", "debug", name),
67
- path.join(rootDir, "target", "release", name),
68
- ];
79
+ // Target directories to search, in precedence order. A custom
80
+ // CARGO_TARGET_DIR (cloud VMs set CARGO_TARGET_DIR=/codeyam-build-target)
81
+ // wins over the default ./target — cargo builds ONLY into the configured
82
+ // dir, so ./target may be stale or absent. Honoring it here keeps
83
+ // `ensureBinary` from building successfully and then failing to locate the
84
+ // result. CARGO_TARGET_DIR may be absolute or relative to the workspace
85
+ // root; path.resolve handles both (an absolute value ignores rootDir).
86
+ const targetDirs = [];
87
+ const customTarget = process.env.CARGO_TARGET_DIR;
88
+ if (customTarget) {
89
+ targetDirs.push(path.resolve(rootDir, customTarget));
90
+ }
91
+ targetDirs.push(path.join(rootDir, "target"));
92
+
93
+ const devCandidates = [];
94
+ for (const dir of targetDirs) {
95
+ devCandidates.push(path.join(dir, "debug", name));
96
+ devCandidates.push(path.join(dir, "release", name));
97
+ }
69
98
  for (const candidate of devCandidates) {
70
99
  if (fs.existsSync(candidate)) return candidate;
71
100
  }
@@ -133,48 +162,71 @@ function ensureBinary() {
133
162
  console.error("Binary not found after build.");
134
163
  process.exit(1);
135
164
  }
165
+ verifyBrokerBinaryPresent(binary);
136
166
  return binary;
137
167
  }
138
168
 
139
- /** Resolve the UI dist directory for the binary to serve. */
140
- function uiDistDir() {
141
- return path.join(rootDir, "ui", "dist");
142
- }
143
-
144
169
  /**
145
- * True when `cwd` IS the editor repo root (or is nested under it), so the
146
- * wrapper is being used to develop the editor itself rather than against an
147
- * external client project. Compares realpath-resolved paths so a symlinked
148
- * checkout (`~/work/foo` → `/Volumes/dev/foo`) still matches — the same
149
- * convention `crates/control-api/src/state.rs:203` documents for the
150
- * server-side identity probe.
151
- *
152
- * Pure and synchronous (uses `fs.realpathSync`). On a realpath failure (an
153
- * unreadable path, race with a moved file) the un-resolved value is used as
154
- * a fallback — the same defensive shape `canonicalisePath` uses, so the
155
- * wrapper never bails on a transient fs hiccup.
156
- *
157
- * Gate for the dev wrapper's passthrough `cargo build`: when the target
158
- * project is the editor itself, rebuilding makes sense (the developer is
159
- * iterating on editor source). When it is any other project, the rebuild
160
- * does nothing of value and ripples into the stale-binary watcher's
161
- * SIGUSR1 re-exec, resetting the user's session for no reason. The
162
- * `CODEYAM_DEV_REBUILD=1` env preserves the one legitimate "editing the
163
- * editor against a client project" workflow.
170
+ * Resolve the dedicated broker binary path. Mirrors {@link findBinary}:
171
+ * the published platform sub-package ships it next to the monolith, and
172
+ * a dev `cargo build` produces it under target/debug|release. Returns
173
+ * null if it can't be located — the Rust broker auto-spawn then falls
174
+ * back to the monolith `editor pty-broker daemon` shim, so a missing
175
+ * broker binary degrades gracefully rather than breaking the editor.
164
176
  */
165
- function isSelfHostTarget(cwd, repoRoot) {
166
- const safeReal = (p) => {
177
+ function findBrokerBinary() {
178
+ const info = platformPackageInfo();
179
+ if (info && info.broker) {
167
180
  try {
168
- return fs.realpathSync(p);
169
- } catch {
170
- return p;
181
+ const resolved = require.resolve(`${info.pkg}/bin/${info.broker}`, {
182
+ paths: [rootDir],
183
+ });
184
+ if (fs.existsSync(resolved)) return resolved;
185
+ } catch (e) {
186
+ if (e.code !== "MODULE_NOT_FOUND") throw e;
171
187
  }
172
- };
173
- const cwdReal = safeReal(cwd);
174
- const repoReal = safeReal(repoRoot);
175
- if (cwdReal === repoReal) return true;
176
- const sep = path.sep;
177
- return cwdReal.startsWith(repoReal.endsWith(sep) ? repoReal : repoReal + sep);
188
+ }
189
+
190
+ const name = brokerBinaryName();
191
+ const targetDirs = [];
192
+ const customTarget = process.env.CARGO_TARGET_DIR;
193
+ if (customTarget) {
194
+ targetDirs.push(path.resolve(rootDir, customTarget));
195
+ }
196
+ targetDirs.push(path.join(rootDir, "target"));
197
+
198
+ for (const dir of targetDirs) {
199
+ for (const profile of ["debug", "release"]) {
200
+ const candidate = path.join(dir, profile, name);
201
+ if (fs.existsSync(candidate)) return candidate;
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /**
208
+ * Best-effort check that the dedicated broker binary sits next to the
209
+ * monolith (the layout the broker auto-spawn's sibling resolution
210
+ * expects). Logs a one-line note when it's missing — never throws or
211
+ * exits, because the Rust side degrades to the monolith shim. `monolith`
212
+ * is the resolved monolith binary path; the broker is expected as its
213
+ * sibling.
214
+ */
215
+ function verifyBrokerBinaryPresent(monolith) {
216
+ const sibling = path.join(path.dirname(monolith), brokerBinaryName());
217
+ if (fs.existsSync(sibling)) return;
218
+ const located = findBrokerBinary();
219
+ if (located) return;
220
+ console.error(
221
+ "Note: dedicated broker binary (codeyam-editor-pty-broker) not found next to " +
222
+ `${monolith}. The PTY broker will fall back to the monolith \`editor pty-broker daemon\` ` +
223
+ "shim. Reinstall the platform sub-package, or run `cargo build -p codeyam-pty-broker`."
224
+ );
225
+ }
226
+
227
+ /** Resolve the UI dist directory for the binary to serve. */
228
+ function uiDistDir() {
229
+ return path.join(rootDir, "ui", "dist");
178
230
  }
179
231
 
180
232
  /**
@@ -568,6 +620,114 @@ function tryKillPid(pid) {
568
620
  }
569
621
  }
570
622
 
623
+ /**
624
+ * True when the launcher is attached to an interactive terminal — i.e. a human
625
+ * could have typed Ctrl+C. Headless / agent-driven launchers (cloud containers,
626
+ * `npm run editor` under a supervisor) have no tty here. Used to decide whether a
627
+ * received SIGINT is a deliberate "stop the editor" (forward it) or a stray
628
+ * group / spurious signal that must NOT reap the now-detached server.
629
+ */
630
+ function isInteractiveLauncher(stream = process.stdout) {
631
+ return Boolean(stream && stream.isTTY);
632
+ }
633
+
634
+ /**
635
+ * Spawn options for the editor server child. The server is spawned `detached:
636
+ * true` so it leads its OWN session + process group (setsid), decoupling its
637
+ * lifetime from the launcher's controlling terminal and process group — a stray
638
+ * tty Ctrl+C or a group-wide `kill(-pgid, SIGINT)` can no longer reach it. stdio
639
+ * is detached from the terminal for the same reason (an inherited tty can still
640
+ * deliver tty signals to a detached child): stdout/stderr go to `logFd` when one
641
+ * is supplied, else are ignored — the Rust server writes its own
642
+ * `.codeyam/logs/editor-server.log` regardless.
643
+ */
644
+ function buildServerSpawnOptions({ cwd, env, logFd = null }) {
645
+ return {
646
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
647
+ cwd,
648
+ detached: true,
649
+ env,
650
+ };
651
+ }
652
+
653
+ /**
654
+ * Open (creating dirs as needed) the launcher-managed server stdout/stderr log
655
+ * in append mode and return its fd, or null if it can't be opened. Kept separate
656
+ * from the Rust server's own structured `editor-server.log` so the two streams
657
+ * never interleave. Best-effort: a failure here just means the detached server's
658
+ * stdio is ignored, never that the launch itself fails.
659
+ */
660
+ function openServerOutputLog(cwd) {
661
+ try {
662
+ const dir = path.join(cwd, ".codeyam", "logs");
663
+ fs.mkdirSync(dir, { recursive: true });
664
+ return fs.openSync(path.join(dir, "editor-server.out.log"), "a");
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Format one launcher-teardown-signal record for
672
+ * `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
673
+ * formatting is unit-testable without a real signal or `ps`. Captures the
674
+ * launcher's own pid/ppid/pgid, whether it was interactive (a tty Ctrl+C) vs
675
+ * headless (a stray/spurious signal), the decision taken (forwarded teardown vs
676
+ * kept the server alive), and a best-effort `ps` group snapshot so the SENDER of
677
+ * a spurious `kill()` can be identified on the next repro.
678
+ */
679
+ function formatLauncherSignalRecord({ signal, pid, ppid, pgid, interactive, forwarded, ps, timestamp }) {
680
+ const lines = [
681
+ `[${timestamp}] launcher-signal signal=${signal} pid=${pid} ppid=${ppid} pgid=${pgid} ` +
682
+ `interactive=${interactive} action=${forwarded ? "forwarded-teardown" : "ignored-kept-server-alive"}`,
683
+ ];
684
+ if (ps && ps.trim() !== "") {
685
+ lines.push(ps.trimEnd());
686
+ }
687
+ return lines.join("\n") + "\n";
688
+ }
689
+
690
+ /**
691
+ * Append a launcher-teardown-signal forensic record. Called from each launcher's
692
+ * SIGINT handler BEFORE it decides whether to forward, so a server reaped by a
693
+ * stray group SIGINT (or a spurious `kill()` to a headless launcher) always
694
+ * leaves a trail naming the launcher's process group. Best-effort: never throws,
695
+ * so a logging failure can never block the teardown decision.
696
+ */
697
+ function logLauncherTeardownSignal(signal, projectDir, { interactive, forwarded }) {
698
+ try {
699
+ const pid = process.pid;
700
+ const ppid = typeof process.ppid === "number" ? process.ppid : -1;
701
+ let pgid = -1;
702
+ let ps = "";
703
+ if (process.platform !== "win32") {
704
+ const pgidOut = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" });
705
+ if (!pgidOut.error) {
706
+ pgid = parseInt(pgidOut.stdout.trim(), 10);
707
+ }
708
+ if (Number.isInteger(pgid) && pgid > 0) {
709
+ const grp = spawnSync("ps", ["-o", "pid,ppid,pgid,tpgid,comm", "-g", String(pgid)], { encoding: "utf8" });
710
+ if (!grp.error) ps = grp.stdout;
711
+ }
712
+ }
713
+ const dir = path.join(projectDir, ".codeyam", "logs");
714
+ fs.mkdirSync(dir, { recursive: true });
715
+ const record = formatLauncherSignalRecord({
716
+ signal,
717
+ pid,
718
+ ppid,
719
+ pgid,
720
+ interactive: Boolean(interactive),
721
+ forwarded: Boolean(forwarded),
722
+ ps,
723
+ timestamp: new Date().toISOString(),
724
+ });
725
+ fs.appendFileSync(path.join(dir, "launcher-signals.log"), record);
726
+ } catch {
727
+ /* best-effort forensics — never block teardown on a logging failure */
728
+ }
729
+ }
730
+
571
731
  /**
572
732
  * Send the platform-correct termination signal to a spawned child process.
573
733
  * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
@@ -674,12 +834,29 @@ async function ensureServer(port = 14199) {
674
834
  const binary = ensureBinary();
675
835
  // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
676
836
  // now an explicit dev-override only.
677
- const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
678
- stdio: "inherit",
679
- cwd: process.cwd(),
680
- detached: false,
681
- env: process.env,
682
- });
837
+ //
838
+ // The server is spawned into its OWN session/process group (detached: true)
839
+ // with stdio detached from the launcher's controlling terminal, so a stray
840
+ // tty Ctrl+C or a group-wide SIGINT can no longer reap it — its lifetime is
841
+ // governed only by the launcher's deliberate teardown forward and the
842
+ // pidfile / API-shutdown paths. stdout/stderr are appended to
843
+ // `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
844
+ // editor-server.log). `unref()` keeps the detached child from pinning the
845
+ // launcher's event loop alive on its own.
846
+ const logFd = openServerOutputLog(process.cwd());
847
+ const child = spawn(
848
+ binary,
849
+ ["start", "--no-open", "--port", String(port)],
850
+ buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
851
+ );
852
+ if (logFd !== null) {
853
+ try {
854
+ fs.closeSync(logFd);
855
+ } catch {
856
+ /* child holds its own dup of the fd; the launcher's copy is no longer needed */
857
+ }
858
+ }
859
+ child.unref();
683
860
 
684
861
  const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
685
862
 
@@ -688,7 +865,8 @@ async function ensureServer(port = 14199) {
688
865
  const detail = signal != null ? `signal ${signal}` : `code ${code}`;
689
866
  console.error(
690
867
  `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
691
- "Likely a preflight bail; see output above.",
868
+ "Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
869
+ "and .codeyam/logs/editor-server.log.",
692
870
  );
693
871
  // Always propagate as a real failure — even on code 0 the caller
694
872
  // needs to know the server isn't running.
@@ -734,11 +912,13 @@ module.exports = {
734
912
  rootDir,
735
913
  serverStatePath,
736
914
  binaryName,
915
+ brokerBinaryName,
737
916
  platformPackageInfo,
738
917
  PLATFORM_PACKAGES,
739
918
  findBinary,
919
+ findBrokerBinary,
920
+ verifyBrokerBinaryPresent,
740
921
  ensureBinary,
741
- isSelfHostTarget,
742
922
  staleBinaryBanner,
743
923
  enforceFreshBinary,
744
924
  uiDistDir,
@@ -760,6 +940,11 @@ module.exports = {
760
940
  restartServer,
761
941
  ensureServer,
762
942
  awaitServerReadyOrChildExit,
943
+ isInteractiveLauncher,
944
+ buildServerSpawnOptions,
945
+ openServerOutputLog,
946
+ formatLauncherSignalRecord,
947
+ logLauncherTeardownSignal,
763
948
  killChildProcess,
764
949
  deepMergeJson,
765
950
  stripUnderscoreTopLevelKeys,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Language-agnostic managed execution sandbox for scenario-driven development",
5
5
  "bin": {
6
6
  "codeyam-editor": "./npm/cli.js"
@@ -9,10 +9,10 @@
9
9
  "playwright": "^1.58.2"
10
10
  },
11
11
  "optionalDependencies": {
12
- "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.2",
13
- "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.2",
14
- "@codeyam-editor/codeyam-editor-linux-x64": "0.1.2",
15
- "@codeyam-editor/codeyam-editor-win32-x64": "0.1.2"
12
+ "@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.4",
13
+ "@codeyam-editor/codeyam-editor-darwin-x64": "0.1.4",
14
+ "@codeyam-editor/codeyam-editor-linux-x64": "0.1.4",
15
+ "@codeyam-editor/codeyam-editor-win32-x64": "0.1.4"
16
16
  },
17
17
  "keywords": [
18
18
  "codeyam",