@codeyam-editor/codeyam-editor 0.1.14 → 0.1.15-staging.g8a9ebd4

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.
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // How each build agent's CLI gets installed on a fleet VM, and the in-container
3
+ // install script that does it. Pure — no I/O; npm/cloud.js runs the script over
4
+ // SSH (ensureAgentCliInstalled) and owns the gates around it. Split out of
5
+ // cloud.js so the install dimension is one concern with one test file.
6
+ //
7
+ // Two install kinds, mirroring the Rust `CliInstall::{Npm, Script}` split in
8
+ // BuildAgentProvider::cli_spec() (crates/types/src/config/harness.rs):
9
+ // npm — a global npm package (claude, codex, gemini)
10
+ // script — the agent's own install command, run verbatim (muse)
11
+
12
+ // Muse is not on npm: it installs through Meta's curl launcher into
13
+ // `~/.local/bin`. Mirrors `MUSE_INSTALL_COMMAND` in
14
+ // crates/types/src/config/harness.rs (the spike-verified command — the launcher
15
+ // download answers unauthenticated, so a VM needs no sign-in to install).
16
+ // Cross-reference: keep the two strings identical.
17
+ const MUSE_INSTALL_COMMAND =
18
+ 'mkdir -p "$HOME/.local/bin" && curl -fsSL https://api.meta.ai/muse-launcher.sh -o "$HOME/.local/bin/muse" && chmod +x "$HOME/.local/bin/muse" && MUSE_LAUNCHER_INSTALL=1 "$HOME/.local/bin/muse"';
19
+
20
+ const AGENT_CLI_INSTALLS = {
21
+ claude: { kind: "npm", package: "@anthropic-ai/claude-code" },
22
+ codex: { kind: "npm", package: "@openai/codex" },
23
+ gemini: { kind: "npm", package: "@google/gemini-cli" },
24
+ muse: { kind: "script", command: MUSE_INSTALL_COMMAND },
25
+ };
26
+
27
+ /** Map an agent name to how its CLI is installed — `{ kind: "npm", package }`
28
+ * or `{ kind: "script", command }`. Throws on unknown agents so adding an
29
+ * agent is a one-entry change in `AGENT_CLI_INSTALLS`. */
30
+ function agentCliInstall(agent) {
31
+ if (!Object.prototype.hasOwnProperty.call(AGENT_CLI_INSTALLS, agent)) {
32
+ throw new Error(
33
+ `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_CLI_INSTALLS).join(", ")}`,
34
+ );
35
+ }
36
+ return AGENT_CLI_INSTALLS[agent];
37
+ }
38
+
39
+ /** The bash script that installs `agent`'s CLI inside the editor container,
40
+ * dispatched on its install kind (see `agentCliInstall`). Pure — returns the
41
+ * script text. Both kinds short-circuit when the binary already resolves. A
42
+ * script install must end with a binary that runs and reports a version, so a
43
+ * launcher that downloaded but cannot execute fails provisioning loudly (with
44
+ * the install output attached by runFatalSshStep) instead of rostering a VM
45
+ * whose CLI was never usable. */
46
+ function buildAgentCliInstallScript(agent, install) {
47
+ const header = [
48
+ "#!/usr/bin/env bash",
49
+ "set -euo pipefail",
50
+ `BINARY="${agent}"`,
51
+ "",
52
+ ];
53
+ if (install.kind === "script") {
54
+ return [
55
+ ...header,
56
+ "if command -v \"$BINARY\" >/dev/null 2>&1 || [ -x \"$HOME/.local/bin/$BINARY\" ]; then",
57
+ " echo \" ✓ $BINARY already installed\"",
58
+ " exit 0",
59
+ "fi",
60
+ "",
61
+ "echo \"Installing $BINARY via its install script in container...\"",
62
+ install.command,
63
+ "RESOLVED=$(command -v \"$BINARY\" 2>/dev/null || echo \"$HOME/.local/bin/$BINARY\")",
64
+ "if [ ! -x \"$RESOLVED\" ]; then",
65
+ " echo \" ✗ $BINARY install script finished but no $BINARY binary was found (looked on PATH and in $HOME/.local/bin)\" >&2",
66
+ " exit 1",
67
+ "fi",
68
+ "\"$RESOLVED\" --version",
69
+ "echo \" ✓ Successfully installed $BINARY at $RESOLVED\"",
70
+ ].join("\n");
71
+ }
72
+ return [
73
+ ...header,
74
+ `PACKAGE="${install.package}"`,
75
+ `AGENT="${agent}"`,
76
+ "",
77
+ "if command -v \"$BINARY\" >/dev/null 2>&1; then",
78
+ " echo \" ✓ $BINARY already installed on PATH\"",
79
+ " exit 0",
80
+ "fi",
81
+ "",
82
+ "echo \"Installing $PACKAGE globally in container...\"",
83
+ "if [ \"$AGENT\" = \"claude\" ]; then",
84
+ " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
85
+ " npm install -g \"$PACKAGE\"",
86
+ " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
87
+ " if [ -n \"$RESOLVED\" ]; then",
88
+ " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
89
+ " fi",
90
+ "else",
91
+ " npm install -g \"$PACKAGE\"",
92
+ "fi",
93
+ "echo \" ✓ Successfully installed $BINARY\"",
94
+ ].join("\n");
95
+ }
96
+
97
+ module.exports = {
98
+ MUSE_INSTALL_COMMAND,
99
+ AGENT_CLI_INSTALLS,
100
+ agentCliInstall,
101
+ buildAgentCliInstallScript,
102
+ };
package/npm/cloud.js CHANGED
@@ -139,13 +139,17 @@ const AGENT_ENV_VARS = {
139
139
  claude: "ANTHROPIC_API_KEY",
140
140
  codex: "OPENAI_API_KEY",
141
141
  gemini: "GEMINI_API_KEY",
142
+ muse: "META_API_KEY",
142
143
  };
143
144
 
144
- const AGENT_NPM_PACKAGES = {
145
- claude: "@anthropic-ai/claude-code",
146
- codex: "@openai/codex",
147
- gemini: "@google/gemini-cli",
148
- };
145
+ // How each agent's CLI is installed (npm package vs Muse's launcher script)
146
+ // lives in its own module; re-exported below so existing importers keep working.
147
+ const {
148
+ MUSE_INSTALL_COMMAND,
149
+ AGENT_CLI_INSTALLS,
150
+ agentCliInstall,
151
+ buildAgentCliInstallScript,
152
+ } = require("./agent-cli-install");
149
153
 
150
154
  const KNOWN_FLAGS = [
151
155
  "repo", "branch", "instance-name", "project", "zone", "agent",
@@ -273,16 +277,6 @@ function agentEnvVarName(agent) {
273
277
  return AGENT_ENV_VARS[agent];
274
278
  }
275
279
 
276
- /** Map an agent name to its global npm package. Throws on unknown agents so
277
- * adding an agent is a one-entry change in `AGENT_NPM_PACKAGES`. */
278
- function agentNpmPackage(agent) {
279
- if (!Object.prototype.hasOwnProperty.call(AGENT_NPM_PACKAGES, agent)) {
280
- throw new Error(
281
- `Unknown agent: ${agent}. Supported: ${Object.keys(AGENT_NPM_PACKAGES).join(", ")}`,
282
- );
283
- }
284
- return AGENT_NPM_PACKAGES[agent];
285
- }
286
280
 
287
281
  /** Parse `--flag value` argv into an object. Unknown flags or missing values
288
282
  * throw with a usage hint — silently dropping them would let typos like
@@ -3653,6 +3647,34 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPo
3653
3647
  );
3654
3648
  }
3655
3649
 
3650
+ /** The notice cloud:up prints when the agent's API key is not set locally, so
3651
+ * only the provider is seeded. Names every way the operator can still
3652
+ * authenticate the agent on the VM. Muse also accepts an account login
3653
+ * (`muse login`, run once on the VM), which the editor's auth gate honours
3654
+ * in place of a key — so for Muse a key-less VM is "sign in on the VM", not
3655
+ * broken. Pure. */
3656
+ function noLocalKeySeedingNotice(agent) {
3657
+ const varName = agentEnvVarName(agent);
3658
+ const accountLogin =
3659
+ agent === "muse"
3660
+ ? ` • signing in once on the VM with \`muse login\` (a Meta account login; no key needed).\n`
3661
+ : "";
3662
+ const oauthCaveat =
3663
+ agent === "muse"
3664
+ ? ""
3665
+ : `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
3666
+ `supported headlessly on the VM.\n`;
3667
+ return (
3668
+ `\nNo ${varName} set locally — seeding provider only.\n` +
3669
+ `Authenticate ${agent} on the VM by either:\n` +
3670
+ ` • exporting ${varName} locally and re-running cloud:up, or\n` +
3671
+ ` • pasting the key into the Build tab's auth gate in the editor${accountLogin ? ", or" : "."}\n` +
3672
+ accountLogin +
3673
+ `Each path flows the credential through to the agent process on the VM.\n` +
3674
+ oauthCaveat
3675
+ );
3676
+ }
3677
+
3656
3678
  /** Write the chosen agent provider and local API keys into editor.local.json on
3657
3679
  * the VM BEFORE compose up so the file exists when the bind-mount happens —
3658
3680
  * the provider seed MUST run before ensureEditorContainerUp, because the editor
@@ -3670,15 +3692,7 @@ async function seedAgentApiKey(cfg, { sleepFn } = {}) {
3670
3692
  const patch = buildEditorLocalEnvPatch(process.env, cfg.agent);
3671
3693
  const hasKey = Boolean(patch.env);
3672
3694
  if (!hasKey) {
3673
- console.error(
3674
- `\nNo ${agentEnvVarName(cfg.agent)} set locally — seeding provider only.\n` +
3675
- `Authenticate ${cfg.agent} on the VM by either:\n` +
3676
- ` • exporting ${agentEnvVarName(cfg.agent)} locally and re-running cloud:up, or\n` +
3677
- ` • pasting the key into the Build tab's auth gate in the editor.\n` +
3678
- `Either path now flows the key through to the agent process on the VM.\n` +
3679
- `Interactive browser-based OAuth login (e.g. \`codex login\`) is not yet ` +
3680
- `supported headlessly on the VM.\n`,
3681
- );
3695
+ console.error(noLocalKeySeedingNotice(cfg.agent));
3682
3696
  }
3683
3697
  const repoName = resolveProject(cfg).name;
3684
3698
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
@@ -3833,7 +3847,10 @@ function probeAgentBinaryInstalled(cfg, container, binary) {
3833
3847
  // idempotent install (a fatal step) rather than a throw of its own.
3834
3848
  const probe = runGcloud(
3835
3849
  buildGcloudArgs("ssh-probe", cfg, {
3836
- command: `docker exec ${container} bash -lc 'command -v ${binary}'`,
3850
+ // `~/.local/bin` is checked too: a script-installed CLI (Muse) lands there,
3851
+ // which a login shell's PATH need not include — the editor's own resolver
3852
+ // scans the same dir, so it finds the binary either way.
3853
+ command: `docker exec ${container} bash -lc 'command -v ${binary} || { test -x "$HOME/.local/bin/${binary}" && echo "$HOME/.local/bin/${binary}"; }'`,
3837
3854
  }),
3838
3855
  { stdio: ["ignore", "pipe", "ignore"] },
3839
3856
  );
@@ -3901,7 +3918,7 @@ async function waitForContainerRunning(
3901
3918
  * pass `{ containerStarted: true }`. */
3902
3919
  async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
3903
3920
  const agent = cfg.agent;
3904
- const pkg = agentNpmPackage(agent);
3921
+ const install = agentCliInstall(agent);
3905
3922
  const container = process.env.CONTAINER || "codeyam-editor-editor-1";
3906
3923
 
3907
3924
  // The container may not be up the instant this runs — a slow `docker compose
@@ -3953,31 +3970,7 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
3953
3970
 
3954
3971
  console.error(`Ensuring build-agent CLI for '${agent}' is installed in container '${container}'...`);
3955
3972
 
3956
- const containerScript = [
3957
- "#!/usr/bin/env bash",
3958
- "set -euo pipefail",
3959
- `BINARY="${agent}"`,
3960
- `PACKAGE="${pkg}"`,
3961
- `AGENT="${agent}"`,
3962
- "",
3963
- "if command -v \"$BINARY\" >/dev/null 2>&1; then",
3964
- " echo \" ✓ $BINARY already installed on PATH\"",
3965
- " exit 0",
3966
- "fi",
3967
- "",
3968
- "echo \"Installing $PACKAGE globally in container...\"",
3969
- "if [ \"$AGENT\" = \"claude\" ]; then",
3970
- " rm -rf /usr/lib/node_modules/@anthropic-ai/.claude-code-*",
3971
- " npm install -g \"$PACKAGE\"",
3972
- " RESOLVED=$(find /usr/lib/node_modules/@anthropic-ai -name claude -type f -path '*linux-x64*' 2>/dev/null | head -1)",
3973
- " if [ -n \"$RESOLVED\" ]; then",
3974
- " ln -sf \"$RESOLVED\" /usr/local/bin/claude",
3975
- " fi",
3976
- "else",
3977
- " npm install -g \"$PACKAGE\"",
3978
- "fi",
3979
- "echo \" ✓ Successfully installed $BINARY\"",
3980
- ].join("\n");
3973
+ const containerScript = buildAgentCliInstallScript(agent, install);
3981
3974
 
3982
3975
  const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
3983
3976
 
@@ -3990,6 +3983,7 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
3990
3983
  });
3991
3984
  }
3992
3985
 
3986
+
3993
3987
  async function openTunnel(cfg, appPort = VITE_PORT) {
3994
3988
  console.error(`Opening IAP tunnel to ${cfg.instanceName}...`);
3995
3989
  const child = spawn("gcloud", buildGcloudArgs("ssh-tunnel", cfg, { appPort }), { stdio: "inherit" });
@@ -6108,9 +6102,12 @@ module.exports = {
6108
6102
  provisionFailureMessage,
6109
6103
  GCLOUD_PROVISION_TIMEOUT_MS,
6110
6104
  agentEnvVarName,
6111
- agentNpmPackage,
6105
+ agentCliInstall,
6112
6106
  AGENT_ENV_VARS,
6113
- AGENT_NPM_PACKAGES,
6107
+ AGENT_CLI_INSTALLS,
6108
+ MUSE_INSTALL_COMMAND,
6109
+ buildAgentCliInstallScript,
6110
+ noLocalKeySeedingNotice,
6114
6111
  ensureAgentCliInstalled,
6115
6112
  ensureEditorContainerUp,
6116
6113
  seedAgentApiKey,
package/npm/editor.js CHANGED
@@ -24,6 +24,9 @@ const {
24
24
  killChildProcess,
25
25
  isInteractiveLauncher,
26
26
  logLauncherTeardownSignal,
27
+ ownServerIdentity,
28
+ waitForOwnServer,
29
+ formatServerNotAnsweringError,
27
30
  readMergedEditorConfig,
28
31
  resolveLauncherPort,
29
32
  resolveWrappedAppPort,
@@ -36,6 +39,7 @@ const {
36
39
  enforceFreshBinary,
37
40
  } = require("./utils");
38
41
  const { open } = require("./open");
42
+ const { logLauncherBootEvent } = require("./launcher-boot-log");
39
43
 
40
44
  const shouldRestart = process.argv.includes("--restart");
41
45
 
@@ -299,6 +303,25 @@ async function main() {
299
303
  }
300
304
  }
301
305
 
306
+ // Only ever open the browser onto a confirmed-live server. The server can die
307
+ // between `ensureServer` and here (the Vite wait alone is up to 15s), and a
308
+ // page opened onto a dead port looks loaded but is frozen — so ask the server
309
+ // once more, and fail loudly instead of printing a cheerful URL.
310
+ // Judged against the same project dir `ensureServer` used (its default, cwd).
311
+ const projectDir = process.cwd();
312
+ const live = await waitForOwnServer(SERVER_PORT, {
313
+ ownServerIdentityFn: (p) => ownServerIdentity(p, projectDir),
314
+ });
315
+ logLauncherBootEvent(projectDir, "browser-gate", {
316
+ port: SERVER_PORT,
317
+ verdict: live ? "live" : "dead",
318
+ server_pid: live ? live.pid : undefined,
319
+ });
320
+ if (!live) {
321
+ console.error(formatServerNotAnsweringError(SERVER_PORT, projectDir));
322
+ process.exit(1);
323
+ }
324
+
302
325
  const url = `http://localhost:${SERVER_PORT}`;
303
326
  console.error(launcherMode ? `Project selector: ${url}` : `Editor UI: ${url}`);
304
327
  if (!launcherMode) {
@@ -446,6 +446,31 @@ function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {})
446
446
  );
447
447
  }
448
448
 
449
+ // Did the OPERATOR HOST run out of filesystem? Node spells it `ENOSPC: no space
450
+ // left on device`, the shell `No space left on device`, gcloud `OSError: [Errno
451
+ // 28] No space left on device` — matched on the message, never on the errno
452
+ // number, which means something else on other platforms. A transcript that says a
453
+ // script "failed on the VM" is quoting the VM's own log tail (npm/cloud.js prints
454
+ // that header above it), so an ENOSPC there is the VM's disk, not the host's, and
455
+ // stays with the generic arm.
456
+ const HOST_DISK_EXHAUSTED = /ENOSPC: no space left on device|No space left on device/i;
457
+
458
+ function hostDiskExhausted(logTail) {
459
+ const log = String(logTail || '');
460
+ return HOST_DISK_EXHAUSTED.test(log) && !/failed on the VM/i.test(log);
461
+ }
462
+
463
+ // Operator-facing recovery copy for the host-disk-exhausted arm. The ENOSPC fires
464
+ // on the operator host before `instances create`, so nothing exists on GCP; the
465
+ // fix is freeing the host's filesystem, which every add needs first.
466
+ function hostDiskExhaustedRecovery() {
467
+ return (
468
+ 'The operator host ran out of filesystem space (bytes or inodes) before anything was created, so there is no VM to ' +
469
+ 'reconcile or destroy. Prune old date directories under /root/.config/gcloud/logs — gcloud writes one log per ' +
470
+ 'invocation and is the usual cause — and confirm with `df -i` and `df -h`. Every add fails the same way until then.'
471
+ );
472
+ }
473
+
449
474
  // Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
450
475
  // instance may exist" is only true when the transcript does not show cloud:up
451
476
  // deleting what it created; saying it regardless sends the operator hunting for
@@ -700,9 +725,27 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
700
725
  streamedLibraryMissingRecovery({ ...missingLib, cleanTeardown: cleanTeardownReported(log) }));
701
726
  }
702
727
 
728
+ // 10b. host-disk-exhausted — the OPERATOR HOST's filesystem is full. Observed
729
+ // 2026-10-16: six adds died in 5–27s on `ENOSPC: no space left on device`
730
+ // writing `cloud.json.NNN.tmp`, with 6.4 GB free and 0 inodes free. No arm
731
+ // matched, so all six landed on the calm `verifying` residue: the badge held
732
+ // calm and Retry was refused as "still resolving", about adds that were dead.
733
+ //
734
+ // NOT retryable even though nothing was provisioned: a retry before the disk
735
+ // is freed fails identically — the streamed-library-missing reasoning. Not in
736
+ // CALM_ADD_CATEGORIES: an explicit error proves the failure, so it reads red.
737
+ //
738
+ // Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
739
+ // shell echoes cloud:up's tail, so this transcript ALSO carries the generic
740
+ // `cloud:up FAILED (exit N)` line.
741
+ if (hostDiskExhausted(log)) {
742
+ return buildFailureVerdict('host-disk-exhausted', 'Operator host disk full',
743
+ hostDiskExhaustedRecovery());
744
+ }
745
+
703
746
  // 11. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
704
747
  // vanished / stuck / stockout / missing-ref / wedged-insert / iap-not-ready /
705
- // streamed-library signatures above did not match. Not retryable. Whether a
748
+ // streamed-library / host-disk signatures above did not match. Not retryable. Whether a
706
749
  // partial instance may exist is read off the transcript's teardown evidence.
707
750
  if (/cloud:up FAILED \(exit/i.test(log)) {
708
751
  return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
@@ -977,4 +1020,12 @@ function tornDownRetryMessage(n) {
977
1020
  );
978
1021
  }
979
1022
 
980
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, tornDownRetryMessage, settledAgeMs, instanceAliveSince, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE, CLASSIFY_TAIL_LINES };
1023
+ // Refusal copy for a one-click "Retry add" after the operator host ran out of
1024
+ // filesystem. The generic refusal's "half-provisioned instance" is false — the
1025
+ // ENOSPC fired before anything was created — and a retry fails identically until
1026
+ // the host is pruned, so the copy names the prune. Pure; no I/O.
1027
+ function hostDiskExhaustedRetryMessage(n) {
1028
+ return `VM-${n} cannot be retried until the operator host has free space: ${hostDiskExhaustedRecovery()}`;
1029
+ }
1030
+
1031
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, iapNotReadyFacts, iapNotReadyRecovery, streamedLibraryMissingFacts, cleanTeardownReported, streamedLibraryMissingRecovery, cloudUpFailedRecovery, streamedLibraryRetryMessage, tornDownRetryMessage, hostDiskExhausted, hostDiskExhaustedRecovery, hostDiskExhaustedRetryMessage, settledAgeMs, instanceAliveSince, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE, CLASSIFY_TAIL_LINES };
package/npm/fleet-auth.js CHANGED
@@ -14,6 +14,15 @@
14
14
  // instead of distributing a single token. See docs/cloud-editor-stability.md
15
15
  // "Fleet auth".
16
16
 
17
+ // Whether VM `v` runs Claude as its build agent — the only agent the fleet's
18
+ // Claude-credential machinery (batched `claude /login`, donor credential copy)
19
+ // applies to. An editor that predates `currentProvider` reports null; that
20
+ // editor could only run Claude, so null reads as Claude. Pure.
21
+ function isClaudeVm(v) {
22
+ const p = v && v.currentProvider;
23
+ return !p || p === 'claude';
24
+ }
25
+
17
26
  // Classify one VM for the fleet-auth pass. Returns { eligible, reason }.
18
27
  //
19
28
  // The guardrail mirrors Destroy / Reconfigure / roll: NEVER touch an active
@@ -31,6 +40,9 @@ function classifyAuthVm(v, job) {
31
40
  }
32
41
  if (!v || !v.reachable) return { eligible: false, reason: 'unreachable' };
33
42
  if (v.hasSession) return { eligible: false, reason: 'active-session' };
43
+ // The batch drives `claude /login`. A VM running another agent (Muse signs in
44
+ // with its own `muse login` on the VM) must not be swept into it.
45
+ if (!isClaudeVm(v)) return { eligible: false, reason: 'not-claude' };
34
46
  const state = v.providerAuth && v.providerAuth.state;
35
47
  if (state === 'authenticated') return { eligible: false, reason: 'already-authenticated' };
36
48
  if (state === 'needs-login') return { eligible: true, reason: 'needs-login' };
@@ -154,12 +166,16 @@ function selectDonorForAccount(reachableDonors, vms, email) {
154
166
  // copied and those the integrity probe failed, so the add can drop the failed
155
167
  // ones BEFORE resolveCredsSource's first-donor fallback could pick one, and name
156
168
  // each dropped donor in the add log. Order is preserved; unprobed is usable.
169
+ // A VM running a non-Claude agent (Muse) holds no Claude credential to copy, so
170
+ // it is never a donor — left out of both lists, since it is not a failed probe.
157
171
  // Returns { usable: number[], skipped: number[] }. Pure.
158
172
  function partitionDonorsByIntegrity(reachableDonors, vms) {
159
173
  const usable = [];
160
174
  const skipped = [];
161
175
  for (const n of reachableDonors || []) {
162
- const pa = vms && vms[n] && vms[n].providerAuth;
176
+ const v = vms && vms[n];
177
+ if (v && !isClaudeVm(v)) continue;
178
+ const pa = v && v.providerAuth;
163
179
  if (pa && pa.credentialIntact === false) skipped.push(n);
164
180
  else usable.push(n);
165
181
  }
@@ -14,6 +14,7 @@ const { normalizeAccessList } = require("./fleet-vm-access");
14
14
  const { normalizeRepoUrl } = require("./fleet-env");
15
15
  const { API_BUDGET_DEFAULTS, wouldExceedApiBudget } = require("./fleet-api-budget");
16
16
  const { posix: posixPath } = require("node:path");
17
+ const { hostDiskHeadroomCheck } = require("./operator-host-disk");
17
18
 
18
19
  /** The build agents a VM's editor terminal can run, as the keys of cloud.js's
19
20
  * AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
@@ -30,7 +31,7 @@ const EMPTY_CONFIG = Object.freeze({
30
31
  newProjectName: "",
31
32
  editorVariant: "dev",
32
33
  editorBranch: "",
33
- // Build agent provider the VM's editor terminal runs (claude | codex | gemini).
34
+ // Build agent provider the VM's editor terminal runs (claude | codex | gemini | muse).
34
35
  // Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
35
36
  agent: "claude",
36
37
  // Optional human-readable hostname slug for a SINGLE VM. When set, cloudUpArgs
@@ -210,8 +211,22 @@ function missingBranchRefMessage(missing) {
210
211
  * - no GitHub token → the fresh VM's bootstrap dies cloning the PRIVATE editor
211
212
  * repo at `could not read Username for 'https://github.com'`.
212
213
  * `allowNoGitCredentials` mirrors cloud:up's --allow-no-git-credentials opt-out
213
- * for an intentionally read-only VM (no push token needed). Pure; no I/O. */
214
- function addPreflight({ zone, project, token, allowNoGitCredentials = false } = {}) {
214
+ * for an intentionally read-only VM (no push token needed).
215
+ *
216
+ *
217
+ * It also guards the operator host's own filesystem (the 2026-10-16 fault: six
218
+ * adds died on ENOSPC with bytes free and 0 inodes free). The caller measures
219
+ * `inodesFree` / `inodesTotal` / `bytesFree`; each is optional, and absent means
220
+ * "not measured", never zero — see operator-host-disk.js. Pure; no I/O. */
221
+ function addPreflight({
222
+ zone,
223
+ project,
224
+ token,
225
+ allowNoGitCredentials = false,
226
+ inodesFree,
227
+ inodesTotal,
228
+ bytesFree,
229
+ } = {}) {
215
230
  const blank = (v) => typeof v !== "string" || v.trim() === "";
216
231
  if (blank(zone)) {
217
232
  return {
@@ -238,6 +253,8 @@ function addPreflight({ zone, project, token, allowNoGitCredentials = false } =
238
253
  "read-only VM with --allow-no-git-credentials.",
239
254
  };
240
255
  }
256
+ const diskFull = hostDiskHeadroomCheck({ inodesFree, inodesTotal, bytesFree });
257
+ if (diskFull) return { ok: false, msg: diskFull };
241
258
  return { ok: true };
242
259
  }
243
260
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Launch-attempt forensics for the npm launcher.
3
+ *
4
+ * One line per event of `ensureServer`'s boot — spawn, readiness verdict (and
5
+ * how it was decided), kill, retry, adoption, final outcome — plus the browser
6
+ * gate's verdict, appended to `.codeyam/logs/launcher-boot.log`. A launch that
7
+ * ends with no server, or with a server SIGTERM'd by its own launch, is then
8
+ * self-explaining from this file beside `editor-server.log`, even when nobody
9
+ * can reproduce it.
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+
15
+ /**
16
+ * Format one launch-attempt trace line: an ISO-8601 UTC timestamp (the same
17
+ * shape `editor-server.log` uses, so the two files line up by eye), the event
18
+ * name, the launcher pid, then `key=value` fields in insertion order.
19
+ * `undefined` fields are dropped so callers can pass optional context without
20
+ * branching; a value containing whitespace is quoted so the line stays
21
+ * splittable on spaces.
22
+ */
23
+ function formatLauncherBootRecord({ timestamp, launcherPid, event, fields = {} }) {
24
+ const parts = [`${timestamp} launcher-boot event=${event} launcher_pid=${launcherPid}`];
25
+ for (const [key, value] of Object.entries(fields)) {
26
+ if (value === undefined) continue;
27
+ const text = typeof value === "string" ? value : JSON.stringify(value);
28
+ parts.push(`${key}=${/\s/.test(text) ? JSON.stringify(text) : text}`);
29
+ }
30
+ return parts.join(" ") + "\n";
31
+ }
32
+
33
+ /**
34
+ * Append one launch-attempt trace line to `<projectDir>/.codeyam/logs/
35
+ * launcher-boot.log`. Best-effort: never throws, so tracing can never change a
36
+ * launch's outcome.
37
+ */
38
+ function logLauncherBootEvent(projectDir, event, fields) {
39
+ try {
40
+ const dir = path.join(projectDir, ".codeyam", "logs");
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ const record = formatLauncherBootRecord({
43
+ timestamp: new Date().toISOString(),
44
+ launcherPid: process.pid,
45
+ event,
46
+ fields,
47
+ });
48
+ fs.appendFileSync(path.join(dir, "launcher-boot.log"), record);
49
+ } catch {
50
+ /* best-effort forensics — never change a launch's outcome on a logging failure */
51
+ }
52
+ }
53
+
54
+ module.exports = {
55
+ formatLauncherBootRecord,
56
+ logLauncherBootEvent,
57
+ };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ // Filesystem headroom of the OPERATOR HOST, judged before a dashboard Add.
4
+ //
5
+ // On 2026-10-16 six adds died in 5–27s on `ENOSPC: no space left on device`
6
+ // writing `cloud.json.NNN.tmp`, with 6.4 GB free and 0 of 3,268,608 inodes free.
7
+ // So headroom is judged on BOTH axes: a bytes-only reading would have passed
8
+ // every one of those adds through. Pure; the caller takes the readings.
9
+
10
+ // A cloud:up writes only a handful of files, so these are "effectively
11
+ // exhausted" floors, not capacity planning.
12
+ const MIN_FREE_INODES = 1000;
13
+ const MIN_FREE_BYTES = 256 * 1024 * 1024;
14
+
15
+ // The remedy: gcloud's per-invocation log files are what filled the inode table.
16
+ function hostDiskRemedy() {
17
+ return (
18
+ 'free space on the operator host before adding a VM — prune old date directories ' +
19
+ 'under /root/.config/gcloud/logs (gcloud writes a log file per invocation and is ' +
20
+ 'the usual cause), then confirm with `df -i` and `df -h`. Nothing was provisioned.'
21
+ );
22
+ }
23
+
24
+ const measured = (v) => typeof v === 'number' && Number.isFinite(v);
25
+
26
+ // Returns the refusal message when either axis is below its floor, else null.
27
+ //
28
+ // Every reading is optional: an absent (non-finite) value means "not measured"
29
+ // and passes, never "zero", or an unmeasurable filesystem would refuse every add.
30
+ // An `inodesTotal` of 0 means the filesystem does not count inodes (btrfs reports
31
+ // this), so the inode axis is skipped there.
32
+ function hostDiskHeadroomCheck({ inodesFree, inodesTotal, bytesFree } = {}) {
33
+ const countsInodes = !measured(inodesTotal) || inodesTotal > 0;
34
+ if (countsInodes && measured(inodesFree) && inodesFree < MIN_FREE_INODES) {
35
+ return `the operator host is out of inodes (${inodesFree} free) even if bytes remain — ${hostDiskRemedy()}`;
36
+ }
37
+ if (measured(bytesFree) && bytesFree < MIN_FREE_BYTES) {
38
+ const mb = Math.floor(bytesFree / (1024 * 1024));
39
+ return `the operator host is out of disk space (${mb} MB free) — ${hostDiskRemedy()}`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ module.exports = { hostDiskHeadroomCheck, hostDiskRemedy, MIN_FREE_INODES, MIN_FREE_BYTES };