Sign In

@codeyam-editor/codeyam-editor

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@codeyam-editor/codeyam-editor - npm Package Compare versions

Comparing version
0.1.3
to
0.1.4
+89
npm/disk-pressure.js
'use strict';
// Pure decision module for the VM root-disk guardrail. Given a single integer
// percent (from `df`'s Use%/Capacity column), classify the pressure into
// ok | warn | critical, and expose the allowlist of regenerable cache dirs the
// watchdog may auto-prune at the critical threshold. No I/O — the watchdog
// (bash) and the fleet dashboard (node) both consume these constants so the
// decision boundary is testable without a live VM, mirroring the
// pure-decision-vs-I/O split of npm/fleet-effect-status.js.
//
// Why two thresholds: warn (80%) is visibility only — a badge + log line so the
// operator sees pressure building. critical (90%) authorizes a bounded
// auto-prune of regenerable build caches, because a 100%-full disk cascades far
// worse (it truncated ~/.claude.json mid-write and OOM-killed the broker on the
// VM4 outage this guards against).
const DISK_WARN_PCT = 80;
const DISK_CRITICAL_PCT = 90;
// Allowlist of regenerable dev/build cache directories that are safe to delete
// on ANY stack because the toolchain rebuilds them on demand. This is an
// allowlist (known-safe), NOT a denylist, so an unknown stack's important files
// are never deleted — worst case we free less, never break a build.
//
// Stack assumption: each entry is a cache OUTPUT of a specific toolchain
// (.next/.turbo → Next.js/Turborepo, .svelte-kit → SvelteKit, .vite → Vite,
// .parcel-cache → Parcel, .angular/cache → Angular, node_modules/.cache →
// generic JS build caches). A project on none of these stacks simply has none
// of these dirs and the prune is a no-op.
//
// Deliberately disjoint from internal_paths.rs::ALWAYS_EXCLUDED_DIRS
// (node_modules, .codeyam, .git, target): those mean "don't index," NOT "safe
// to delete." node_modules is load-bearing and Rust target/ is expensive to
// rebuild, so neither appears here.
//
// Follow-up: a future migration can move this into stack.json so each project
// declares its own cache dirs; kept operator-side here to stay self-contained.
const PRUNABLE_CACHE_DIRS = [
'.next/cache',
'.next/dev/cache',
'node_modules/.cache',
'.turbo',
'.svelte-kit',
'.vite',
'.parcel-cache',
'.angular/cache',
];
// Classify an integer disk-usage percent into a pressure level. A non-numeric,
// non-finite, or out-of-range (<0 or >100) value is treated as a BAD READING
// and classified 'ok' with no action — a misread must never escalate to a
// destructive prune.
function classifyDiskPressure(usedPct) {
if (typeof usedPct !== 'number' || !Number.isFinite(usedPct)) return 'ok';
if (usedPct < 0 || usedPct > 100) return 'ok';
if (usedPct >= DISK_CRITICAL_PCT) return 'critical';
if (usedPct >= DISK_WARN_PCT) return 'warn';
return 'ok';
}
// Parse the integer used-percent from a `df -P /` block. `df -P` (POSIX) emits a
// header line then one data line whose Capacity field is like "82%". We find the
// first NN% token on the data line, which is robust to a mount point containing
// spaces (which would shift positional fields). Returns null on unparseable
// input so the caller can fail safe rather than prune on garbage.
function parseDfUsedPct(dfOutput) {
const lines = String(dfOutput == null ? '' : dfOutput)
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
// The data line is the second non-empty line (after the header). Fall back to
// scanning all lines so a header-less one-liner still parses.
const candidates = lines.length >= 2 ? [lines[1], ...lines] : lines;
for (const line of candidates) {
const m = line.match(/(\d+)%/);
if (m) {
const pct = parseInt(m[1], 10);
if (Number.isFinite(pct)) return pct;
}
}
return null;
}
module.exports = {
DISK_WARN_PCT,
DISK_CRITICAL_PCT,
PRUNABLE_CACHE_DIRS,
classifyDiskPressure,
parseDfUsedPct,
};
'use strict';
// Assemble the per-VM wire object the dashboard's /data response carries to the
// browser. This is the single source of truth for a card's transport shape: the
// card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
// off each entry, so every field the card renders MUST be emitted here.
//
// It was split out of server.js's buildData() so the shape is unit-testable.
// The regression that motivated the split: `currentProvider` (the VM's
// configured build agent) was merged onto the poll state AND read by the card to
// render the 🤖 agent badge, but it was silently missing from this envelope — so
// it lived in server memory and never reached the browser, and the badge never
// appeared. A hand-maintained object literal inside a 197KB monolith has no
// guard against that drift; a pure function does.
//
// `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
// cross-VM, and side-effecting values buildData computes per VM (status,
// debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
// the things this pure function can't know on its own.
function buildVmEnvelope(v, ctx) {
return {
n: ctx.n,
url: v.url || null,
reachable: !!v.reachable,
hasSession: !!v.hasSession,
// Three-state: true = broker confirmed up, false = confirmed down, null =
// editor unreachable so broker state is unknown. Never conflate "down" with
// "unreachable" — that mismatch is what made the card show "broker down"
// while a direct pgrep proved the broker was up.
brokerLive: v.reachable ? !!v.agentBrokerLive : null,
// Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived
// upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
brokerStatus: ctx.brokerStatus,
awaitingInput: !!v.awaitingUserInput,
feature: v.feature || null,
step: v.step != null ? v.step : null,
mode: v.mode || null,
label: v.label || null,
status: ctx.status,
orphan: ctx.orphan || null,
degradedReasons: ctx.degradedReasons || [],
isHead: !!ctx.isHead,
// True when this VM holds the queue head but is unreachable (operator
// decides manually whether to release it).
headHolderUnreachable: !!ctx.headHolderUnreachable,
queuePos: ctx.queuePos != null ? ctx.queuePos : null,
queueLen: ctx.queueLen,
job: ctx.job || null,
config: ctx.config || null,
project: ctx.project != null ? ctx.project : null,
variant: ctx.variant != null ? ctx.variant : null,
cardIdentity: ctx.cardIdentity,
brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [],
brokerDeath: ctx.brokerDeath,
// Auth state + reason + token expiry. Only surfaced when the editor is
// reachable (an unreachable VM's cached auth is stale and misleading).
providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
effects: v.effects || null,
// The VM's live-configured build agent (lowercase provider), reported by
// /api/session-info and merged (sticky) onto the poll state. Drives the
// card's 🤖 agent badge. null for an older VM whose editor predates the
// field (the card omits the badge rather than rendering a blank chip).
currentProvider: v.currentProvider || null,
};
}
// The fields the card UI depends on being present in every /data VM entry. This
// is the contract `buildVmEnvelope` must satisfy: the regression that prompted
// this module was a field the card read but the envelope didn't emit, so the
// test asserts the envelope keys are a superset of this list. Adding a field the
// card renders means adding it here AND in buildVmEnvelope — the test fails loud
// if you do one without the other.
const REQUIRED_CARD_FIELDS = [
'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
'awaitingInput', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
'degradedReasons', 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
];
module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
'use strict';
/**
* Indexes the currently-reachable VMs by their live sessionId.
* @param {Array|Object} vms The merged VM states on the dashboard.
* @returns {Object} A map of sessionId -> VM number (n).
*/
function indexVmsBySessionId(vms) {
const map = {};
if (!vms) return map;
const list = Array.isArray(vms) ? vms : Object.values(vms);
for (const vm of list) {
if (vm && vm.sessionId && vm.n !== undefined) {
map[vm.sessionId] = vm.n;
}
}
return map;
}
/**
* Computes which dependencies are unsatisfied (i.e. not in the completedSlugs set).
* @param {string[]} dependsOn Prerequisite slugs.
* @param {Set|string[]} completedSlugs Set or array of completed plan slugs.
* @returns {string[]} The unsatisfied dependency slugs.
*/
function unsatisfiedDeps(dependsOn, completedSlugs) {
const deps = Array.isArray(dependsOn) ? dependsOn : [];
const completedSet = completedSlugs instanceof Set ? completedSlugs : new Set(completedSlugs || []);
return deps.filter(dep => !completedSet.has(dep));
}
/**
* Resolves claims and dependency states for each queued plan.
* @param {Object} params
* @param {Object[]} params.plans Array of PlanSummary objects from /api/plans.
* @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
* @param {Set|string[]} params.completedSlugs Completed plan slugs.
* @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
* @returns {Object[]} Annotated plan rows.
*/
function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
const list = Array.isArray(plans) ? plans : [];
const claimsList = Array.isArray(claims) ? claims : [];
return list.map(plan => {
// 1. Extract plan fields
const slug = plan.slug;
const title = plan.title;
const mode = plan.mode || null;
const order = plan.order != null ? plan.order : null;
const prefix = plan.prefix || null;
// 2. Resolve dependency-blocked state
const dependsOn = Array.isArray(plan.depends_on) ? plan.depends_on : (plan.dependsOn ? plan.dependsOn : []);
const blockedBy = unsatisfiedDeps(dependsOn, completedSlugs);
// 3. Resolve claim state
const claim = claimsList.find(c => c.slug === slug);
let claimInfo = null;
if (claim) {
const claimedBy = claim.claimedBy || claim.claimed_by || {};
const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
claimInfo = {
vm,
machine: claimedBy.machine || null,
agent: claimedBy.agentProvider || claimedBy.agent_provider || null,
sessionAlive: claim.sessionAlive !== undefined ? claim.sessionAlive : (claim.session_alive !== undefined ? claim.session_alive : null),
source: claim.source || null,
currentStep: claimedBy.currentStep || claimedBy.current_step || null,
};
}
return {
slug,
title,
mode,
order,
prefix,
blockedBy,
claim: claimInfo
};
});
}
const REQUIRED_PLAN_ROW_FIELDS = [
'slug', 'title', 'mode', 'order', 'prefix', 'blockedBy', 'claim'
];
module.exports = {
indexVmsBySessionId,
unsatisfiedDeps,
resolvePlanRows,
REQUIRED_PLAN_ROW_FIELDS
};

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-xWYGZOpo.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-BuK0lizt.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage};

Sorry, the diff of this file is too big to display

+11
-0

@@ -15,2 +15,4 @@ #!/usr/bin/env node

killChildProcess,
isInteractiveLauncher,
logLauncherTeardownSignal,
readMergedEditorConfig,

@@ -45,2 +47,11 @@ resolveLauncherPort,

process.on("SIGINT", () => {
// The container launcher is inherently headless: a SIGINT here is stray
// (the server is torn down via API shutdown / orchestration, never an
// interactive Ctrl+C), so do NOT forward it — the detached server stays
// alive. Record the signal's process-group forensics for root-cause tracing.
const interactive = isInteractiveLauncher();
logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
if (!interactive) {
return;
}
clearInterval(keepAlive);

@@ -47,0 +58,0 @@ killChildProcess(serverChild);

@@ -38,2 +38,4 @@ #!/usr/bin/env node

killChildProcess,
isInteractiveLauncher,
logLauncherTeardownSignal,
readMergedEditorConfig,

@@ -432,2 +434,11 @@ resolveLauncherPort,

process.on("SIGINT", () => {
// Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
// A SIGINT to a headless / agent-driven launcher is stray and must NOT
// reap the now-detached server, so swallow it and keep supervising.
// Record the signal's process-group forensics either way.
const interactive = isInteractiveLauncher();
logLauncherTeardownSignal("SIGINT", projectDir, { interactive, forwarded: interactive });
if (!interactive) {
return;
}
tearingDown = true;

@@ -434,0 +445,0 @@ clearInterval(keepAlive);

@@ -25,2 +25,4 @@ #!/usr/bin/env node

killChildProcess,
isInteractiveLauncher,
logLauncherTeardownSignal,
readMergedEditorConfig,

@@ -255,2 +257,12 @@ resolveLauncherPort,

process.on("SIGINT", () => {
// Forward a deliberate Ctrl+C (interactive launcher) to stop the editor.
// A SIGINT to a headless / agent-driven launcher is stray — the editor is
// torn down via API shutdown / pidfile there, never an interactive Ctrl+C —
// so do NOT forward it: the detached server stays alive. Either way, record
// the signal's process-group forensics for root-cause tracing.
const interactive = isInteractiveLauncher();
logLauncherTeardownSignal("SIGINT", rootDir, { interactive, forwarded: interactive });
if (!interactive) {
return;
}
clearInterval(keepAlive);

@@ -257,0 +269,0 @@ killChildProcess(serverChild);

@@ -209,2 +209,28 @@ 'use strict';

// improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that
// a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached
// `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up`
// ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window,
// with the wrapping EXIT trap still recording 0 (the live VM2 signature: built
// image, repo cloned, ZERO editor containers). So 'done' no longer means "roster
// it"; it means "confirm the container is actually up first." Both the clean-exit
// ('done') and the no-usable-marker ('reconcile') cases therefore route through
// the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict),
// which rosters only a genuinely-up + wired add and routes a half-provisioned one
// to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still
// alive) are unchanged. reattachVerdict stays the honest classifier of the
// child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that
// exit-0 no longer implies rostering. Pure: server.js maps the returned action to
// its side-effecting step, so the matrix is exhaustively testable without a VM.
// verdict next step meaning
// 'done' 'probe' confirm the container before rostering
// 'reconcile' 'probe' no usable marker — let the gcloud probe decide
// 'error' 'surface-error' real failure — show add-failed with the code
// 'running' 'watch' child still alive — keep polling for the marker
function reattachNextStep(verdict) {
if (verdict === 'error') return 'surface-error';
if (verdict === 'running') return 'watch';
return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
}
// Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of

@@ -298,2 +324,3 @@ // recoveries ALREADY persisted on the sidecar, return the next attempt number,

reattachVerdict,
reattachNextStep,
};
+45
-1

@@ -39,2 +39,46 @@ 'use strict';

module.exports = { shouldReconcileFailedBadge };
// Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
// is now idle, decide whether the recovered badge should be FULLY CLEARED — the
// job entry dropped from state.jobs so the card goes clean — rather than merely
// muted as a "recovered (was: …)" note (improve41).
//
// badgeReconciled the shouldReconcileFailedBadge verdict for this VM
// idle the VM answered this poll, is not running a build session,
// and its broker is not actively serving an agent
//
// A recovered VM that is also idle has demonstrably finished recovering, so the
// stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
// or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
// visible during that transient window. Never clears a badge that wasn't first
// judged recovered — an unreachable / drifted / off-branch error stays surfaced.
function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
return !!badgeReconciled && !!idle;
}
// Pure guard for the operator "Clear badge" action (improve41). Decides whether
// VM-n's persisted job entry may be dropped after an out-of-band recovery,
// independent of the server's state / fs I/O so the cases are unit-tested
// without a live dashboard. The shell (server.js) supplies the live signals and
// turns the verdict into the {ok,msg} response + the actual delete + snapshot.
//
// rostered the VM number is in the live roster
// job the carried job record ({ state, ... }) or null/undefined
//
// Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
// failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
// any other job state / non-rostered combination is not clearable.
// Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
// operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
function canClearBadge({ rostered, job } = {}) {
if (!rostered) {
if (job && job.action === 'add' && job.state === 'error') {
return { ok: true, reason: null, cleanup: 'add-failed' };
}
return { ok: false, reason: 'not-rostered' };
}
if (!job) return { ok: false, reason: 'no-badge' };
if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
return { ok: true, reason: null, cleanup: 'badge-only' };
}
module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };

@@ -36,2 +36,59 @@ 'use strict';

module.exports = { parseRunningFleetVms, computeUnrostered };
// Map a single degraded reason to the live health signal that resolves it.
// Returns true only when that signal now passes; a reason with no mapped signal
// returns false (retained — a genuine config problem, never a false-green).
// Start with the two observed classes (improve41); extend the table as new
// degraded reasons gain a corresponding live signal.
function reasonClearedByHealth(reason, { reachableServing, authed }) {
const r = String(reason || '');
if (/claude auth|claude\.json|login/i.test(r)) return authed;
if (/container|never came up|provisioning failed/i.test(r)) return reachableServing;
return false;
}
// Given a terminal add-job badge and the VM's live health, return the corrected
// badge — or null when nothing changes. Conservative by construction: clears a
// degraded reason only when its live health signal now passes; flips a bare
// `error` add to `done` when the VM is reachable + serving; leaves any reason
// with no health signal intact (no false-green). The poll loop supplies the live
// facts (I/O at the edge) and applies the result; the verdict itself is pure.
// job: { state: 'done'|'error', degraded, degradedReasons }
// health: { reachable, editorHttpOk, providerAuthState }
// → null | { state, degraded, degradedReasons, transition }
function reconcileTerminalAddBadge({ job, health } = {}) {
if (!job || !health) return null;
const reachableServing = !!(health.reachable && health.editorHttpOk);
const authed = health.providerAuthState === 'authenticated';
// Bare `error` add (no degraded reasons): flip to `done` once the VM is
// reachable + serving — the VM3 case (a manual `cloud:up` recovered the editor
// outside the dashboard's view).
if (job.state === 'error') {
const reasons = job.degradedReasons || [];
if (reasons.length === 0 && reachableServing) {
return { state: 'done', degraded: false, degradedReasons: [], transition: 'error→done (editor 200)' };
}
return null;
}
// Terminal `done` that is degraded: clear each reason whose live signal now
// passes; retain the rest. Drop `degraded` only when no reasons remain.
if (job.state === 'done' && job.degraded) {
const reasons = job.degradedReasons || [];
const remaining = reasons.filter((reason) => !reasonClearedByHealth(reason, { reachableServing, authed }));
if (remaining.length === reasons.length) return null; // nothing cleared → no-op
const cleared = reasons.length - remaining.length;
return {
state: 'done',
degraded: remaining.length > 0,
degradedReasons: remaining,
transition: remaining.length > 0
? `degraded: cleared ${cleared} reason(s), ${remaining.length} remain`
: 'degraded→clear (health restored)',
};
}
return null;
}
module.exports = { parseRunningFleetVms, computeUnrostered, reconcileTerminalAddBadge };

@@ -11,4 +11,11 @@ "use strict";

const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename } = require("./cloud");
const { CANONICAL_EDITOR_REPO, EDITOR_VARIANTS, repoBasename, AGENT_ENV_VARS } = require("./cloud");
/** The build agents a VM's editor terminal can run, as the keys of cloud.js's
* AGENT_ENV_VARS. Deriving the selectable set from that map keeps it a single
* source of truth with the agent→API-key-env-var mapping (cloud.js) and the
* gcp-bootstrap CLI installer, so adding an agent is still a one-line change in
* AGENT_ENV_VARS rather than a list to keep in sync here. */
const AGENTS = Object.freeze(Object.keys(AGENT_ENV_VARS));
const EMPTY_CONFIG = Object.freeze({

@@ -22,2 +29,5 @@ clientRepo: "",

editorBranch: "",
// Build agent provider the VM's editor terminal runs (claude | codex | gemini).
// Defaults to claude; cloudUpArgs forwards it as cloud:up's --agent flag.
agent: "claude",
});

@@ -31,2 +41,6 @@

const editorVariant = EDITOR_VARIANTS.includes(input.editorVariant) ? input.editorVariant : "dev";
// Coerce an unknown/absent agent to the default rather than rejecting — the
// form's agent selector only ever submits a known value, so an out-of-set
// agent is a stale query param, not an error.
const agent = AGENTS.includes(input.agent) ? input.agent : "claude";
return {

@@ -38,2 +52,3 @@ clientRepo: str(input.clientRepo),

editorBranch: str(input.editorBranch),
agent,
};

@@ -118,9 +133,21 @@ }

* image from editorBranch); the client repo/branch is what lands in
* /workspace. Decoupled exactly as cloud.js consumes them. */
function cloudUpArgs(cfg, instanceName, agent = "claude") {
* /workspace. The build agent rides on `cfg.agent` (normalizeLaunchConfig
* guarantees a known value, defaulting to claude), so a single config object
* carries every launch choice. Decoupled exactly as cloud.js consumes them. */
function cloudUpArgs(cfg, instanceName, project) {
const args = ["--instance-name", instanceName];
// Pass the authoritative GCP project (the dashboard's resolved PROJECT)
// explicitly so cloud:up never relies on the spawning shell's ambient
// `gcloud config get-value project`. Critically, a `--project` flag OVERRIDES
// a persisted .codeyam/cloud.json `project` (cloud.js merges
// `{ ...existing, ...flags }`), which a bare cloud:up reuses verbatim with no
// gcloud fallback — the sticky-wrong-project failure mode where a stale
// "gcp-proj" in cloud.json wedges every re-provision with a firewall-create
// PERMISSION_DENIED. Omitted when unset to preserve the pre-existing arg shape
// (cloud.js then falls back to the gcloud default, as before).
if (project) args.push("--project", project);
// Greenfield (new folder) vs clone an existing repo.
if (cfg.newProjectName) args.push("--new-project", cfg.newProjectName);
else args.push("--repo", cfg.clientRepo);
args.push("--agent", agent, "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
args.push("--agent", cfg.agent || "claude", "--editor-repo", CANONICAL_EDITOR_REPO, "--editor-variant", cfg.editorVariant);
if (!cfg.newProjectName && cfg.clientBranch) args.push("--branch", cfg.clientBranch);

@@ -245,2 +272,36 @@ if (cfg.editorBranch) args.push("--editor-branch", cfg.editorBranch);

/** Display-side companion to `distinctConfigs`: derive the two label strings the
* "recent launches" picker renders for one config, so two configs that differ
* only by build agent (or greenfield project name) look different instead of
* collapsing to identical rows. `distinctConfigs` de-dups on the FULL config, so
* the rows are genuinely distinct launches — the old inline label just never
* surfaced the distinguishing fields (agent, greenfield name).
*
* Returns { primary, secondary }:
* - primary: greenfield (`!clientRepo && newProjectName`) → `new: <name>` so
* two different greenfield projects no longer both read `(none)`;
* else `repoBasename(clientRepo)` + (`@<branch>` when set); else
* `(none)` when there's neither a repo nor a project name.
* - secondary: `editor <variant>` + (`@<editorBranch>` when set) + ` · <agent>`.
*
* Normalizes through EMPTY_CONFIG so a pre-`agent` history entry still renders
* `· claude`. Reuses `repoBasename` (cloud.js) so server/browser basename
* semantics stay single-sourced. Pure; no I/O. */
function recentLaunchDisplay(config) {
const cfg = { ...EMPTY_CONFIG, ...(config || {}) };
let primary;
if (!cfg.clientRepo && cfg.newProjectName) {
primary = `new: ${cfg.newProjectName}`;
} else if (cfg.clientRepo) {
primary = repoBasename(cfg.clientRepo) + (cfg.clientBranch ? `@${cfg.clientBranch}` : "");
} else {
primary = "(none)";
}
const secondary =
`editor ${cfg.editorVariant || "dev"}` +
(cfg.editorBranch ? `@${cfg.editorBranch}` : "") +
` · ${cfg.agent || "claude"}`;
return { primary, secondary };
}
/** The default config to pre-fill the launch form: the most recent launch's

@@ -600,3 +661,44 @@ * config, or an empty config when there's no history. */

/** Get authenticated Git URL for checking client branch. */
function getAuthRepoUrl(repo, token) {
if (!repo) return "";
if (!token) return repo;
const sshRegex = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/;
const sshMatch = repo.match(sshRegex);
if (sshMatch) {
return `https://x-access-token:${token}@github.com/${sshMatch[1]}/${sshMatch[2]}`;
}
if (repo.startsWith("https://github.com/")) {
return repo.replace(/^https:\/\/github\.com\//, `https://x-access-token:${token}@github.com/`);
}
return repo;
}
/** Interpret the output of git ls-remote to see if a branch exists. */
function interpretLsRemote({ stdout = "", code = 0, err = null, repo = "", branch = "" } = {}) {
const b = (branch || "").trim();
if (!b) {
return { ok: true, msg: "using repo default branch" };
}
if (err || code !== 0) {
const errMsg = (err && (err.message || String(err))) || "command failed";
return {
ok: false,
soft: true,
msg: `couldn't verify branch: ${errMsg.trim()}`
};
}
const trimmed = (stdout || "").trim();
if (trimmed) {
return { ok: true };
} else {
return {
ok: false,
msg: `branch '${b}' not found in ${repo || "repository"}`
};
}
}
module.exports = {
AGENTS,
EMPTY_CONFIG,

@@ -616,2 +718,3 @@ normalizeLaunchConfig,

distinctConfigs,
recentLaunchDisplay,
defaultConfig,

@@ -630,2 +733,4 @@ configForVm,

decideStartJob,
getAuthRepoUrl,
interpretLsRemote,
};

@@ -278,2 +278,72 @@ #!/usr/bin/env node

// Read-only page-state snapshot for `capture-state`: the full localStorage
// map, a bounded sample of visible text nodes (document order), and — when a
// selector is given — that element's text. Evaluated in-page against the
// settled frame so it reflects exactly what a real capture saw (the proxy
// already injected the scenario's seed into the served HTML). Every read is
// individually guarded so a sandboxed/cross-origin localStorage never throws
// the whole capture; the worst case is an empty section, not a failure.
async function dumpPageState(frame, selector) {
return frame.evaluate((sel) => {
const localStorage = {};
try {
for (let i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
if (key != null) localStorage[key] = window.localStorage.getItem(key);
}
} catch (_) {
/* localStorage may be unavailable (sandboxed/opaque origin) */
}
const visibleText = [];
try {
// Reject text inside non-rendered tags (SCRIPT/STYLE/etc.) so an
// injected proxy script or inline CSS never masquerades as on-screen
// text — that noise is exactly what makes a state dump misleading.
const SKIP_TAGS = new Set([
"SCRIPT",
"STYLE",
"NOSCRIPT",
"TEMPLATE",
"HEAD",
"TITLE",
]);
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const parent = node.parentElement;
if (parent && SKIP_TAGS.has(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return (node.textContent || "").trim()
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
},
);
let node;
while ((node = walker.nextNode()) && visibleText.length < 40) {
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
if (text) visibleText.push(text);
}
} catch (_) {
/* no body / detached document */
}
let selectorText = null;
if (sel) {
try {
const el = document.querySelector(sel);
if (el) selectorText = (el.textContent || "").replace(/\s+/g, " ").trim();
} catch (_) {
/* invalid selector — leave selectorText null */
}
}
return { localStorage, visibleText, selectorText };
}, selector || null);
}
// `preflight` is injectable (defaulting to the real app-port reachability

@@ -464,3 +534,3 @@ // check) so unit tests that mock the browser can stay network-free.

return buildResult({
const result = buildResult({
loaded,

@@ -472,2 +542,11 @@ hasContent,

});
// `capture-state` mode: attach the read-only page-state snapshot so the
// backend can report localStorage + rendered text. Off by default, so a
// normal error-check capture is byte-for-byte unchanged.
if (config.captureState) {
result.state = await dumpPageState(frame, config.stateSelector);
}
return result;
} catch (error) {

@@ -512,2 +591,3 @@ pushIssue(

runScenarioCheck,
dumpPageState,
readStackLoadingMarkers,

@@ -514,0 +594,0 @@ applyBrowserState,

@@ -51,2 +51,18 @@ const { createIssue } = require("./scenario-issues");

// Ignore the browser's blocked-script warning for sandboxed mockup-preview
// frames. Mockup previews render untrusted AI-generated HTML inside a
// `sandbox=""` iframe; the HTML-injection proxy injects an error-capture
// <script> tag, which the browser then refuses to run, emitting
// "Blocked script execution ... because the frame is sandboxed". That block
// is the capture's own injected script being denied — benign for capture
// purposes. Match narrowly on BOTH the block phrase and the "sandboxed"
// signature so a genuine non-sandbox CSP block ("Blocked script execution"
// without "sandboxed") still surfaces as a real issue.
if (
text.includes("Blocked script execution") &&
text.includes("sandboxed")
) {
return null;
}
return createIssue("console", text);

@@ -53,0 +69,0 @@ }

@@ -624,2 +624,110 @@ const { execSync, spawn, spawnSync } = require("child_process");

/**
* True when the launcher is attached to an interactive terminal — i.e. a human
* could have typed Ctrl+C. Headless / agent-driven launchers (cloud containers,
* `npm run editor` under a supervisor) have no tty here. Used to decide whether a
* received SIGINT is a deliberate "stop the editor" (forward it) or a stray
* group / spurious signal that must NOT reap the now-detached server.
*/
function isInteractiveLauncher(stream = process.stdout) {
return Boolean(stream && stream.isTTY);
}
/**
* Spawn options for the editor server child. The server is spawned `detached:
* true` so it leads its OWN session + process group (setsid), decoupling its
* lifetime from the launcher's controlling terminal and process group — a stray
* tty Ctrl+C or a group-wide `kill(-pgid, SIGINT)` can no longer reach it. stdio
* is detached from the terminal for the same reason (an inherited tty can still
* deliver tty signals to a detached child): stdout/stderr go to `logFd` when one
* is supplied, else are ignored — the Rust server writes its own
* `.codeyam/logs/editor-server.log` regardless.
*/
function buildServerSpawnOptions({ cwd, env, logFd = null }) {
return {
stdio: logFd === null ? "ignore" : ["ignore", logFd, logFd],
cwd,
detached: true,
env,
};
}
/**
* Open (creating dirs as needed) the launcher-managed server stdout/stderr log
* in append mode and return its fd, or null if it can't be opened. Kept separate
* from the Rust server's own structured `editor-server.log` so the two streams
* never interleave. Best-effort: a failure here just means the detached server's
* stdio is ignored, never that the launch itself fails.
*/
function openServerOutputLog(cwd) {
try {
const dir = path.join(cwd, ".codeyam", "logs");
fs.mkdirSync(dir, { recursive: true });
return fs.openSync(path.join(dir, "editor-server.out.log"), "a");
} catch {
return null;
}
}
/**
* Format one launcher-teardown-signal record for
* `.codeyam/logs/launcher-signals.log`. Pure: every input is passed in, so the
* formatting is unit-testable without a real signal or `ps`. Captures the
* launcher's own pid/ppid/pgid, whether it was interactive (a tty Ctrl+C) vs
* headless (a stray/spurious signal), the decision taken (forwarded teardown vs
* kept the server alive), and a best-effort `ps` group snapshot so the SENDER of
* a spurious `kill()` can be identified on the next repro.
*/
function formatLauncherSignalRecord({ signal, pid, ppid, pgid, interactive, forwarded, ps, timestamp }) {
const lines = [
`[${timestamp}] launcher-signal signal=${signal} pid=${pid} ppid=${ppid} pgid=${pgid} ` +
`interactive=${interactive} action=${forwarded ? "forwarded-teardown" : "ignored-kept-server-alive"}`,
];
if (ps && ps.trim() !== "") {
lines.push(ps.trimEnd());
}
return lines.join("\n") + "\n";
}
/**
* Append a launcher-teardown-signal forensic record. Called from each launcher's
* SIGINT handler BEFORE it decides whether to forward, so a server reaped by a
* stray group SIGINT (or a spurious `kill()` to a headless launcher) always
* leaves a trail naming the launcher's process group. Best-effort: never throws,
* so a logging failure can never block the teardown decision.
*/
function logLauncherTeardownSignal(signal, projectDir, { interactive, forwarded }) {
try {
const pid = process.pid;
const ppid = typeof process.ppid === "number" ? process.ppid : -1;
let pgid = -1;
let ps = "";
if (process.platform !== "win32") {
const pgidOut = spawnSync("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8" });
if (!pgidOut.error) {
pgid = parseInt(pgidOut.stdout.trim(), 10);
}
if (Number.isInteger(pgid) && pgid > 0) {
const grp = spawnSync("ps", ["-o", "pid,ppid,pgid,tpgid,comm", "-g", String(pgid)], { encoding: "utf8" });
if (!grp.error) ps = grp.stdout;
}
}
const dir = path.join(projectDir, ".codeyam", "logs");
fs.mkdirSync(dir, { recursive: true });
const record = formatLauncherSignalRecord({
signal,
pid,
ppid,
pgid,
interactive: Boolean(interactive),
forwarded: Boolean(forwarded),
ps,
timestamp: new Date().toISOString(),
});
fs.appendFileSync(path.join(dir, "launcher-signals.log"), record);
} catch {
/* best-effort forensics — never block teardown on a logging failure */
}
}
/**
* Send the platform-correct termination signal to a spawned child process.

@@ -730,8 +838,25 @@ * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —

// now an explicit dev-override only.
const child = spawn(binary, ["start", "--no-open", "--port", String(port)], {
stdio: "inherit",
cwd: process.cwd(),
detached: false,
env: process.env,
});
//
// The server is spawned into its OWN session/process group (detached: true)
// with stdio detached from the launcher's controlling terminal, so a stray
// tty Ctrl+C or a group-wide SIGINT can no longer reap it — its lifetime is
// governed only by the launcher's deliberate teardown forward and the
// pidfile / API-shutdown paths. stdout/stderr are appended to
// `.codeyam/logs/editor-server.out.log` (the Rust server also writes its own
// editor-server.log). `unref()` keeps the detached child from pinning the
// launcher's event loop alive on its own.
const logFd = openServerOutputLog(process.cwd());
const child = spawn(
binary,
["start", "--no-open", "--port", String(port)],
buildServerSpawnOptions({ cwd: process.cwd(), env: process.env, logFd }),
);
if (logFd !== null) {
try {
fs.closeSync(logFd);
} catch {
/* child holds its own dup of the fd; the launcher's copy is no longer needed */
}
}
child.unref();

@@ -745,3 +870,4 @@ const { outcome, exitedBeforeBind } = await awaitServerReadyOrChildExit(child, port);

`codeyam-editor server exited (${detail}) before binding port ${port}. ` +
"Likely a preflight bail; see output above.",
"Likely a preflight bail; see .codeyam/logs/editor-server.out.log " +
"and .codeyam/logs/editor-server.log.",
);

@@ -820,2 +946,7 @@ // Always propagate as a real failure — even on code 0 the caller

awaitServerReadyOrChildExit,
isInteractiveLauncher,
buildServerSpawnOptions,
openServerOutputLog,
formatLauncherSignalRecord,
logLauncherTeardownSignal,
killChildProcess,

@@ -822,0 +953,0 @@ deepMergeJson,

+5
-5
{
"name": "@codeyam-editor/codeyam-editor",
"version": "0.1.3",
"version": "0.1.4",
"description": "Language-agnostic managed execution sandbox for scenario-driven development",

@@ -12,6 +12,6 @@ "bin": {

"optionalDependencies": {
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.3",
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.3",
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.3",
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.3"
"@codeyam-editor/codeyam-editor-darwin-arm64": "0.1.4",
"@codeyam-editor/codeyam-editor-darwin-x64": "0.1.4",
"@codeyam-editor/codeyam-editor-linux-x64": "0.1.4",
"@codeyam-editor/codeyam-editor-win32-x64": "0.1.4"
},

@@ -18,0 +18,0 @@ "keywords": [

@@ -8,6 +8,6 @@ <!DOCTYPE html>

<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<script type="module" crossorigin src="/assets/index-KDFAgt2e.js"></script>
<script type="module" crossorigin src="/assets/index-BuK0lizt.js"></script>
<link rel="modulepreload" crossorigin href="/assets/react-CSS0HapR.js">
<link rel="modulepreload" crossorigin href="/assets/markdown-v0F0UEt3.js">
<link rel="stylesheet" crossorigin href="/assets/index-Bih6HJ_u.css">
<link rel="stylesheet" crossorigin href="/assets/index-BGqairFV.css">
</head>

@@ -14,0 +14,0 @@ <body>

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

import{j as c}from"./markdown-v0F0UEt3.js";import{b as o}from"./react-CSS0HapR.js";import{S as m}from"./ScenarioDataPanel-nuLXJzLf.js";import"./useEvents-DggWrtHe.js";import"./xterm--24IGk-x.js";import"./index-KDFAgt2e.js";function S({slug:t}){const[r,i]=o.useState(void 0);return o.useEffect(()=>{let n=!1;return fetch(`/api/scenarios/${encodeURIComponent(t)}`).then(e=>e.ok?e.json():null).then(e=>{if(n||!e)return;const a=typeof e=="object"&&e!==null&&"name"in e?String(e.name):void 0;i(a),a&&(document.title=`${a} · data`)}).catch(()=>{}),()=>{n=!0}},[t]),c.jsx(m,{slug:t,scenarioName:r,variant:"fullPage"})}export{S as ScenarioDataPanelFullPage};

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display