@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

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 (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/utils.js CHANGED
@@ -2,6 +2,7 @@ const { execSync, spawn, spawnSync } = require("child_process");
2
2
  const net = require("net");
3
3
  const path = require("path");
4
4
  const fs = require("fs");
5
+ const readline = require("readline");
5
6
 
6
7
  const rootDir = path.resolve(__dirname, "..");
7
8
  const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
@@ -276,6 +277,25 @@ function enforceFreshBinary(buildOk, { action, continueVerb = "continuing with"
276
277
  );
277
278
  }
278
279
 
280
+ // Default boot budget for the editor server. A real cold boot legitimately
281
+ // spends ~7–10s on plan-claims git migration + reap, a PTY-broker probe/respawn
282
+ // (worse when a stale orphan broker's socket is dead), and a branch-tip fetch.
283
+ // 30s is ~3× that — comfortable headroom without letting a genuinely hung
284
+ // server hang indefinitely.
285
+ const DEFAULT_START_TIMEOUT_MS = 30000;
286
+
287
+ /**
288
+ * Resolve the boot budget (ms) for `ensureServer`, honoring the
289
+ * `CODEYAM_EDITOR_START_TIMEOUT_MS` override. Mirrors the existing env-read
290
+ * style (`CODEYAM_ALLOW_STALE`): parse the raw value, accept only a positive
291
+ * integer, and fall back to the 30s default on missing/non-numeric/≤0 input.
292
+ * Exported so it's unit-testable without spawning the real binary.
293
+ */
294
+ function resolveStartTimeoutMs(env = process.env) {
295
+ const parsed = Number.parseInt(env.CODEYAM_EDITOR_START_TIMEOUT_MS, 10);
296
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_START_TIMEOUT_MS;
297
+ }
298
+
279
299
  function isPlainJsonObject(value) {
280
300
  return value !== null && typeof value === "object" && !Array.isArray(value);
281
301
  }
@@ -491,9 +511,14 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
491
511
 
492
512
  /**
493
513
  * GET /api/codeyam-server-identity from the listener on `port` with a
494
- * sub-second timeout. Returns the parsed `{projectDir, pid, version,
495
- * startedAt}` body on a 2xx response, or `null` on any failure
496
- * (timeout, non-200, parse failure, connection refused).
514
+ * sub-second timeout. Returns the parsed identity body on a 2xx response,
515
+ * or `null` on any failure (timeout, non-200, parse failure, connection
516
+ * refused). Two identity shapes are recognized:
517
+ * - a per-project editor: `{projectDir, pid, version, startedAt}`
518
+ * - the Project Launcher (selector): `{role: "launcher", pid, version}`
519
+ * — deliberately NO `projectDir`, since the selector isn't bound to one
520
+ * project. We must NOT reject it for the missing `projectDir`, or the
521
+ * wrapper would misclassify the live selector as a foreign squatter.
497
522
  *
498
523
  * `null` carries two distinct meanings that the caller must
499
524
  * disambiguate by also checking `isPortInUse`:
@@ -513,9 +538,10 @@ async function fetchServerIdentity(port, timeoutMs = 500) {
513
538
  });
514
539
  if (!resp.ok) return null;
515
540
  const body = await resp.json();
516
- if (!body || typeof body !== "object" || typeof body.projectDir !== "string") {
517
- return null;
518
- }
541
+ if (!body || typeof body !== "object") return null;
542
+ // The Project Launcher advertises a `launcher` role and no projectDir.
543
+ if (body.role === "launcher") return body;
544
+ if (typeof body.projectDir !== "string") return null;
519
545
  return body;
520
546
  } catch {
521
547
  return null;
@@ -572,11 +598,47 @@ function formatCrossProjectError(port, holder, verb) {
572
598
  );
573
599
  }
574
600
 
601
+ /**
602
+ * Build the "server never bound the port" failure message, shown only after
603
+ * the boot budget AND one automatic retry have both been exhausted. Pure
604
+ * string builder (same shape as `formatForeignListenerError` /
605
+ * `formatCrossProjectError`): names what happened, the likely cause, where to
606
+ * look, and concrete next steps. Stack-agnostic — no app-type/framework
607
+ * assumptions.
608
+ */
609
+ function formatServerStartTimeoutError(port, timeoutMs) {
610
+ const seconds = Math.round(timeoutMs / 1000);
611
+ return [
612
+ `codeyam-editor server did not bind port ${port} within ${seconds}s, ` +
613
+ `even after one automatic retry.`,
614
+ "",
615
+ "Likely cause: a slow cold boot (plan-claims git migration, PTY-broker " +
616
+ "respawn) — often a stale codeyam-editor-pty-broker whose socket is gone.",
617
+ "",
618
+ "Or: with 2+ projects registered this launch diverts to the Project " +
619
+ "Launcher (selector). An old binary that still discards --port binds the " +
620
+ "selector on a DIFFERENT ephemeral port, so the wait for this one times " +
621
+ "out — check .codeyam/logs/editor-server.out.log for a `Project Launcher " +
622
+ "running at` line and rebuild the editor.",
623
+ "",
624
+ "Where to look:",
625
+ " .codeyam/logs/editor-server.log",
626
+ " .codeyam/logs/editor-server.out.log",
627
+ "",
628
+ "What to try:",
629
+ " - Re-run the launcher.",
630
+ " - If a stale codeyam-editor-pty-broker is lingering, kill it, then re-run.",
631
+ " - If boots are consistently slow, raise the budget, e.g. " +
632
+ "CODEYAM_EDITOR_START_TIMEOUT_MS=60000.",
633
+ ].join("\n");
634
+ }
635
+
575
636
  /**
576
637
  * Classify whatever is listening on `port` relative to `projectDir`.
577
638
  * Returns one of:
578
639
  * - `{ kind: "free" }` — nothing on the port
579
640
  * - `{ kind: "foreign" }` — port held by a non-editor process
641
+ * - `{ kind: "launcher", identity }` — held by the Project Launcher (selector)
580
642
  * - `{ kind: "cross-project", wantDir, haveDir, identity }`
581
643
  * — held by another project's editor
582
644
  * - `{ kind: "same-project", identity }` — held by THIS project's editor
@@ -596,6 +658,12 @@ async function classifyPortHolder(port, projectDir = process.cwd()) {
596
658
  if (identity === null) {
597
659
  return { kind: "foreign" };
598
660
  }
661
+ // The Project Launcher (selector) holds the port in multi-project mode. It is
662
+ // codeyam-editor, not a foreign process and not a per-project editor, so it
663
+ // gets its own kind — the caller just re-opens it rather than refusing.
664
+ if (identity.role === "launcher") {
665
+ return { kind: "launcher", identity };
666
+ }
599
667
  const wantDir = await canonicalisePath(projectDir);
600
668
  const haveDir = await canonicalisePath(identity.projectDir);
601
669
  if (wantDir !== haveDir) {
@@ -620,6 +688,184 @@ function tryKillPid(pid) {
620
688
  }
621
689
  }
622
690
 
691
+ /**
692
+ * True when the launcher is attached to an interactive terminal — i.e. a human
693
+ * could have typed Ctrl+C. Headless / agent-driven launchers (cloud containers,
694
+ * `npm run editor` under a supervisor) have no tty here. Used to decide whether a
695
+ * received SIGINT is a deliberate "stop the editor" (forward it) or a stray
696
+ * group / spurious signal that must NOT reap the now-detached server.
697
+ */
698
+ function isInteractiveLauncher(stream = process.stdout) {
699
+ return Boolean(stream && stream.isTTY);
700
+ }
701
+
702
+ /**
703
+ * Ask a yes/no question at the terminal and resolve `true` only on an
704
+ * affirmative `y`/`yes` (case-insensitive). A bare Enter, EOF, `n`, or any
705
+ * other text resolves `false` — the safe default for a destructive prompt.
706
+ *
707
+ * Mirrors `promptForInstanceName` in `npm/cloud.js`: readline reads from stdin
708
+ * and writes its prompt to stderr by default, so the question never pollutes a
709
+ * piped stdout. `input`/`output` are injectable so the helper is unit-testable
710
+ * by feeding a fake stream without a real TTY.
711
+ */
712
+ function promptYesNo(question, { input = process.stdin, output = process.stderr } = {}) {
713
+ return new Promise((resolve) => {
714
+ const rl = readline.createInterface({ input, output });
715
+ rl.question(question, (ans) => {
716
+ rl.close();
717
+ const normalized = String(ans).trim().toLowerCase();
718
+ resolve(normalized === "y" || normalized === "yes");
719
+ });
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Decide what to do when `port` is held by a DIFFERENT project's editor server.
725
+ *
726
+ * Policy (shared by both launcher entry points so the wording and gating stay
727
+ * identical):
728
+ * - Non-interactive launcher (cloud container, agent-driven, piped/CI): print
729
+ * `formatCrossProjectError` and return `{ action: "abort" }` — never kill
730
+ * another project's server without a human at the keyboard, since the kill
731
+ * also tears down any Claude agent attached via the PTY broker.
732
+ * - Interactive launcher: print the full who/what/where, then ask whether to
733
+ * stop the other server and take over. On `y` → `stopServer(port)` and
734
+ * return `{ action: "took-over" }`. On anything else → `{ action: "declined" }`.
735
+ *
736
+ * Gates on `process.stdin.isTTY` (the INPUT stream — a human can only answer a
737
+ * prompt when stdin is a terminal), not stdout. The resolver prints the full
738
+ * `formatCrossProjectError` message in BOTH the abort and the interactive paths,
739
+ * so the caller only decides exit-vs-continue.
740
+ *
741
+ * Dependencies are injected (defaulting to the real implementations) so the
742
+ * resolver is unit-testable without a TTY or a live server — same style as
743
+ * `classifyPortHolder` / `resolveBootedServer`.
744
+ */
745
+ async function resolveCrossProjectCollision(
746
+ port,
747
+ holder,
748
+ verb,
749
+ {
750
+ isInteractive = isInteractiveLauncher,
751
+ prompt = promptYesNo,
752
+ stop = stopServer,
753
+ } = {},
754
+ ) {
755
+ console.error(formatCrossProjectError(port, holder, verb));
756
+ if (!isInteractive(process.stdin)) {
757
+ return { action: "abort" };
758
+ }
759
+ const pidNote = Number.isInteger(holder.identity && holder.identity.pid)
760
+ ? ` (pid ${holder.identity.pid})`
761
+ : "";
762
+ const accepted = await prompt(
763
+ `\nKill that server${pidNote} and start this project's editor instead? [y/N] `,
764
+ );
765
+ if (!accepted) {
766
+ return { action: "declined" };
767
+ }
768
+ await stop(port);
769
+ return { action: "took-over" };
770
+ }
771
+
772
+ /**
773
+ * Spawn options for the editor server child. The server is spawned `detached:
774
+ * true` so it leads its OWN session + process group (setsid), decoupling its
775
+ * lifetime from the launcher's controlling terminal and process group — a stray
776
+ * tty Ctrl+C or a group-wide `kill(-pgid, SIGINT)` can no longer reach it. stdio
777
+ * is detached from the terminal for the same reason (an inherited tty can still
778
+ * deliver tty signals to a detached child): stdout/stderr go to `logFd` when one
779
+ * is supplied, else are ignored — the Rust server writes its own
780
+ * `.codeyam/logs/editor-server.log` regardless.
781
+ */
782
+ function buildServerSpawnOptions({ cwd, env, logFd = null }) {
783
+ return {
784
+ stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
785
+ cwd,
786
+ detached: true,
787
+ env,
788
+ };
789
+ }
790
+
791
+ /**
792
+ * Open (creating dirs as needed) the launcher-managed server stdout/stderr log
793
+ * in append mode and return its fd, or null if it can't be opened. Kept separate
794
+ * from the Rust server's own structured `editor-server.log` so the two streams
795
+ * never interleave. Best-effort: a failure here just means the detached server's
796
+ * stdio is ignored, never that the launch itself fails.
797
+ */
798
+ function openServerOutputLog(cwd) {
799
+ try {
800
+ const dir = path.join(cwd, ".codeyam", "logs");
801
+ fs.mkdirSync(dir, { recursive: true });
802
+ return fs.openSync(path.join(dir, "editor-server.out.log"), "a");
803
+ } catch {
804
+ return null;
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Format one launcher-teardown-signal record for
810
+ * `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
811
+ * formatting is unit-testable without a real signal or `ps`. Captures the
812
+ * launcher's own pid/ppid/pgid, whether it was interactive (a tty Ctrl+C) vs
813
+ * headless (a stray/spurious signal), the decision taken (forwarded teardown vs
814
+ * kept the server alive), and a best-effort `ps` group snapshot so the SENDER of
815
+ * a spurious `kill()` can be identified on the next repro.
816
+ */
817
+ function formatLauncherSignalRecord({ signal, pid, ppid, pgid, interactive, forwarded, ps, timestamp }) {
818
+ const lines = [
819
+ `[${timestamp}] launcher-signal signal=${signal} pid=${pid} ppid=${ppid} pgid=${pgid} ` +
820
+ `interactive=${interactive} action=${forwarded ? "forwarded-teardown" : "ignored-kept-server-alive"}`,
821
+ ];
822
+ if (ps && ps.trim() !== "") {
823
+ lines.push(ps.trimEnd());
824
+ }
825
+ return lines.join("\n") + "\n";
826
+ }
827
+
828
+ /**
829
+ * Append a launcher-teardown-signal forensic record. Called from each launcher's
830
+ * SIGINT handler BEFORE it decides whether to forward, so a server reaped by a
831
+ * stray group SIGINT (or a spurious `kill()` to a headless launcher) always
832
+ * leaves a trail naming the launcher's process group. Best-effort: never throws,
833
+ * so a logging failure can never block the teardown decision.
834
+ */
835
+ function logLauncherTeardownSignal(signal, projectDir, { interactive, forwarded }) {
836
+ try {
837
+ const pid = process.pid;
838
+ const ppid = typeof process.ppid === "number" ? process.ppid : -1;
839
+ let pgid = -1;
840
+ let ps = "";
841
+ if (process.platform !== "win32") {
842
+ const pgidOut = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" });
843
+ if (!pgidOut.error) {
844
+ pgid = parseInt(pgidOut.stdout.trim(), 10);
845
+ }
846
+ if (Number.isInteger(pgid) && pgid > 0) {
847
+ const grp = spawnSync("ps", ["-o", "pid,ppid,pgid,tpgid,comm", "-g", String(pgid)], { encoding: "utf8" });
848
+ if (!grp.error) ps = grp.stdout;
849
+ }
850
+ }
851
+ const dir = path.join(projectDir, ".codeyam", "logs");
852
+ fs.mkdirSync(dir, { recursive: true });
853
+ const record = formatLauncherSignalRecord({
854
+ signal,
855
+ pid,
856
+ ppid,
857
+ pgid,
858
+ interactive: Boolean(interactive),
859
+ forwarded: Boolean(forwarded),
860
+ ps,
861
+ timestamp: new Date().toISOString(),
862
+ });
863
+ fs.appendFileSync(path.join(dir, "launcher-signals.log"), record);
864
+ } catch {
865
+ /* best-effort forensics — never block teardown on a logging failure */
866
+ }
867
+ }
868
+
623
869
  /**
624
870
  * Send the platform-correct termination signal to a spawned child process.
625
871
  * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
@@ -634,22 +880,60 @@ function killChildProcess(child) {
634
880
  }
635
881
  }
636
882
 
637
- function findListeningPids(port) {
638
- // Process discovery on Windows is best-effort — `lsof` is not installed and
639
- // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
640
- // PID-file path together stop the server cleanly in normal operation.
641
- if (process.platform === "win32") return [];
883
+ /** Parse `lsof -t` stdout (one pid per line) into an array of integer pids. */
884
+ function parseLsofPids(stdout) {
885
+ return (stdout || "")
886
+ .split(/\s+/)
887
+ .map((line) => parseInt(line, 10))
888
+ .filter((pid) => Number.isInteger(pid));
889
+ }
642
890
 
643
- const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
891
+ /** Run `lsof` for the listeners on a TCP port. Returns the raw spawnSync
892
+ * result (`{ error?, stdout }`) so callers can distinguish a spawn failure
893
+ * from a genuinely empty result. */
894
+ function defaultRunLsof(port) {
895
+ return spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
644
896
  encoding: "utf8",
645
897
  });
898
+ }
646
899
 
647
- if (result.error) return [];
900
+ /** Synchronous millisecond sleep — used to back off between lsof retries. */
901
+ function sleepSync(ms) {
902
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
903
+ }
648
904
 
649
- return result.stdout
650
- .split(/\s+/)
651
- .map((line) => parseInt(line, 10))
652
- .filter((pid) => Number.isInteger(pid));
905
+ /**
906
+ * Find the pids listening on a TCP port via `lsof`. Returns [] on Windows
907
+ * (lsof absent) or when no process is listening.
908
+ *
909
+ * A transient *spawn* failure (e.g. fork `EAGAIN` under heavy concurrent load —
910
+ * the cause of the flaky multi-runner test failure) is RETRIED with a short
911
+ * backoff rather than swallowed into "no listeners found". This matters because
912
+ * `findListeningPids` feeds `stopServer`: a one-off lsof spawn error must not be
913
+ * mistaken for "the server isn't running" and leave it alive. A clean run that
914
+ * legitimately reports no listeners returns [] on the first attempt (no error,
915
+ * so no retry) — free-port behavior and latency are unchanged.
916
+ *
917
+ * `runLsof` and `sleep` are injectable so the retry path is unit-testable
918
+ * without depending on real system load.
919
+ */
920
+ function findListeningPids(port, runLsof = defaultRunLsof, sleep = sleepSync) {
921
+ // Process discovery on Windows is best-effort — `lsof` is not installed and
922
+ // a `netstat -ano` parser is a follow-up. The API-shutdown path and the
923
+ // PID-file path together stop the server cleanly in normal operation.
924
+ if (process.platform === "win32") return [];
925
+
926
+ const MAX_ATTEMPTS = 5;
927
+ const BACKOFF_MS = 50;
928
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
929
+ const result = runLsof(port);
930
+ // No spawn error → trust the result (empty stdout legitimately means "no
931
+ // listeners"; a non-empty stdout yields the pids).
932
+ if (!result.error) return parseLsofPids(result.stdout);
933
+ // Transient spawn failure — back off and retry, unless we're out of tries.
934
+ if (attempt < MAX_ATTEMPTS) sleep(BACKOFF_MS);
935
+ }
936
+ return [];
653
937
  }
654
938
 
655
939
  /** Stop the Rust backend server if it is running. */
@@ -711,10 +995,31 @@ async function ensureServer(port = 14199) {
711
995
  console.error(formatForeignListenerError(port));
712
996
  process.exit(1);
713
997
  break;
714
- case "cross-project":
715
- console.error(formatCrossProjectError(port, holder, "attach"));
998
+ case "cross-project": {
999
+ const outcome = await resolveCrossProjectCollision(port, holder, "attach");
1000
+ if (outcome.action === "took-over") {
1001
+ // `stopServer` already waited for the port to close, so fall through to
1002
+ // the fresh-boot path below — `spawnAndAwait` binds the now-free port.
1003
+ break;
1004
+ }
1005
+ // "abort" (non-TTY) or "declined" (user said no): the resolver already
1006
+ // printed the cross-project message. On decline, remind how to keep both.
1007
+ if (outcome.action === "declined") {
1008
+ console.error(
1009
+ "Leaving the existing server running. Change controlPort in " +
1010
+ ".codeyam/editor.local.json to run both, e.g.:\n" +
1011
+ " codeyam-editor editor config-override proxy.controlPort 14399",
1012
+ );
1013
+ }
716
1014
  process.exit(1);
717
1015
  break;
1016
+ }
1017
+ case "launcher":
1018
+ // The selector is already up on this port (multi-project mode). Treat it
1019
+ // like an already-running same-project server: don't refuse, don't
1020
+ // double-spawn — just let the caller re-open the port.
1021
+ console.error(`Project selector already running on port ${port}`);
1022
+ return null;
718
1023
  case "same-project":
719
1024
  console.error(`Server already running on port ${port}`);
720
1025
  return null;
@@ -724,36 +1029,130 @@ async function ensureServer(port = 14199) {
724
1029
  }
725
1030
 
726
1031
  const binary = ensureBinary();
1032
+ const startTimeoutMs = resolveStartTimeoutMs();
1033
+
1034
+ // Spawn a fresh server child and race its boot against `timeoutMs`. Factored
1035
+ // into a closure so the auto-retry path re-runs an identical spawn with a
1036
+ // longer budget without duplicating the spawn options.
1037
+ //
727
1038
  // UI bundle is embedded in the Rust binary; CODEYAM_EDITOR_UI_DIR is
728
1039
  // now an explicit dev-override only.
729
- const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
730
- stdio: "inherit",
731
- cwd: process.cwd(),
732
- detached: false,
733
- env: process.env,
734
- });
735
-
736
- const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);
737
-
738
- if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
739
- const { code, signal } = exitedBeforeBind ?? { code: null, signal: null };
740
- const detail = signal != null ? `signal ${signal}` : `code ${code}`;
741
- console.error(
742
- `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
743
- "Likely a preflight bail; see output above.",
1040
+ //
1041
+ // The server is spawned into its OWN session/process group (detached: true)
1042
+ // with stdio detached from the launcher's controlling terminal, so a stray
1043
+ // tty Ctrl+C or a group-wide SIGINT can no longer reap it — its lifetime is
1044
+ // governed only by the launcher's deliberate teardown forward and the
1045
+ // pidfile / API-shutdown paths. stdout/stderr are appended to
1046
+ // `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
1047
+ // editor-server.log). `unref()` keeps the detached child from pinning the
1048
+ // launcher's event loop alive on its own.
1049
+ const spawnAndAwait = async (timeoutMs) => {
1050
+ const logFd = openServerOutputLog(process.cwd());
1051
+ const child = spawn(
1052
+ binary,
1053
+ ["start", "--no-open", "--port", String(port)],
1054
+ buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
1055
+ );
1056
+ if (logFd !== null) {
1057
+ try {
1058
+ fs.closeSync(logFd);
1059
+ } catch {
1060
+ /* child holds its own dup of the fd; the launcher's copy is no longer needed */
1061
+ }
1062
+ }
1063
+ child.unref();
1064
+ const race = await awaitServerReadyOrChildExit(child, port, (p) =>
1065
+ waitForPort(p, timeoutMs),
744
1066
  );
745
- // Always propagate as a real failure — even on code 0 the caller
746
- // needs to know the server isn't running.
747
- process.exit(code != null && code !== 0 ? code : 1);
1067
+ return { child, ...race };
1068
+ };
1069
+
1070
+ // Orchestrate the boot (re-check-before-kill + one auto-retry) with the real
1071
+ // effects injected, then map the discriminated result onto the process-exit
1072
+ // policy. Keeping the orchestration in `resolveBootedServer` (pure control
1073
+ // flow over injected effects) is what makes the re-check guard and the retry
1074
+ // unit-testable without spawning the real binary or stubbing process.exit.
1075
+ const result = await resolveBootedServer({
1076
+ port,
1077
+ startTimeoutMs,
1078
+ spawnAndAwait,
1079
+ isPortInUseFn: isPortInUse,
1080
+ });
1081
+ switch (result.kind) {
1082
+ case "ready":
1083
+ return result.child;
1084
+ case "early-exit": {
1085
+ // A genuine early exit (preflight bail) is always a hard fail — even on
1086
+ // code 0: the caller needs to know the server isn't running.
1087
+ const { code, signal } = result.exitedBeforeBind ?? { code: null, signal: null };
1088
+ const detail = signal != null ? `signal ${signal}` : `code ${code}`;
1089
+ console.error(
1090
+ `codeyam-editor server exited (${detail}) before binding port ${port}. ` +
1091
+ "Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
1092
+ "and .codeyam/logs/editor-server.log.",
1093
+ );
1094
+ process.exit(code != null && code !== 0 ? code : 1);
1095
+ break;
1096
+ }
1097
+ case "timeout-exhausted":
1098
+ default:
1099
+ console.error(formatServerStartTimeoutError(port, result.retryTimeoutMs));
1100
+ process.exit(1);
748
1101
  }
1102
+ }
749
1103
 
750
- if (outcome === "timeout") {
751
- console.error("Server failed to start within 10 seconds");
752
- child.kill();
753
- process.exit(1);
754
- }
1104
+ /**
1105
+ * Boot orchestration for `ensureServer`, isolated from the process-exit policy
1106
+ * and from the spawn/port effects so the re-check-before-kill guard and the
1107
+ * single auto-retry are unit-testable. Pure control flow over two injected
1108
+ * effects:
1109
+ * - `spawnAndAwait(timeoutMs)` → `{ child, outcome, exitedBeforeBind }` —
1110
+ * one boot attempt racing the port against the child exit.
1111
+ * - `isPortInUseFn(port)` → bool — the final re-check probe.
1112
+ *
1113
+ * Returns a discriminated result the caller maps to an action:
1114
+ * - `{ kind: "ready", child }` — server bound; keep this child.
1115
+ * - `{ kind: "early-exit", exitedBeforeBind }` — child exited before bind
1116
+ * (preflight bail); a hard fail, never retried.
1117
+ * - `{ kind: "timeout-exhausted", retryTimeoutMs }` — both the budget and a
1118
+ * longer-budget retry missed; give up with actionable guidance.
1119
+ *
1120
+ * The destructive `child.kill()` only fires after a re-check confirms the port
1121
+ * is NOT listening — a server that bound a hair after the deadline is kept, not
1122
+ * torn down (the reported race). The retry runs at most once.
1123
+ */
1124
+ async function resolveBootedServer({ port, startTimeoutMs, spawnAndAwait, isPortInUseFn }) {
1125
+ // One boot attempt, with the re-check folded in: a post-deadline bind is
1126
+ // reported as "ready" rather than "timeout" so the caller never kills it.
1127
+ const attempt = async (timeoutMs) => {
1128
+ const { child, outcome, exitedBeforeBind } = await spawnAndAwait(timeoutMs);
1129
+ if (outcome === "ready") return { kind: "ready", child };
1130
+ if (outcome === "exited" || (outcome === "timeout" && exitedBeforeBind !== null)) {
1131
+ return { kind: "early-exit", exitedBeforeBind };
1132
+ }
1133
+ // Genuine timeout — re-check before reporting a miss.
1134
+ if (await isPortInUseFn(port)) return { kind: "ready", child };
1135
+ return { kind: "timeout", child };
1136
+ };
1137
+
1138
+ const first = await attempt(startTimeoutMs);
1139
+ if (first.kind !== "timeout") return first;
1140
+
1141
+ // The boot was just slow (cold caches, broker respawn). Kill the stalled
1142
+ // attempt and auto-retry ONCE with a longer budget — a single retry, not a
1143
+ // loop, so a truly broken server isn't masked behind endless retries.
1144
+ const retryTimeoutMs = startTimeoutMs * 2;
1145
+ console.error(
1146
+ `Server did not bind port ${port} within ${Math.round(startTimeoutMs / 1000)}s; ` +
1147
+ `retrying once with a longer budget...`,
1148
+ );
1149
+ first.child.kill();
1150
+
1151
+ const second = await attempt(retryTimeoutMs);
1152
+ if (second.kind !== "timeout") return second;
755
1153
 
756
- return child;
1154
+ second.child.kill();
1155
+ return { kind: "timeout-exhausted", retryTimeoutMs };
757
1156
  }
758
1157
 
759
1158
  /**
@@ -808,12 +1207,23 @@ module.exports = {
808
1207
  classifyPortHolder,
809
1208
  formatForeignListenerError,
810
1209
  formatCrossProjectError,
1210
+ formatServerStartTimeoutError,
1211
+ resolveStartTimeoutMs,
811
1212
  tryKillPid,
812
1213
  findListeningPids,
1214
+ parseLsofPids,
813
1215
  stopServer,
814
1216
  restartServer,
815
1217
  ensureServer,
1218
+ resolveBootedServer,
816
1219
  awaitServerReadyOrChildExit,
1220
+ isInteractiveLauncher,
1221
+ promptYesNo,
1222
+ resolveCrossProjectCollision,
1223
+ buildServerSpawnOptions,
1224
+ openServerOutputLog,
1225
+ formatLauncherSignalRecord,
1226
+ logLauncherTeardownSignal,
817
1227
  killChildProcess,
818
1228
  deepMergeJson,
819
1229
  stripUnderscoreTopLevelKeys,