// Seat registration: one small record per seat under /seats/// // registration.json, written by `mosaic launch ` and read by the control // board so the board can show what a seat was told to do instead of guessing. // // A registration is rewritten on every launch. It is a launch record, not a // board file: the board must not write here, and a scan never changes it. // `mosaic seat task ` changes the task field only. // // Records are keyed by layout and seat name, ///, because // a name alone is not unique (this repository and the fleet both have a // "darkwing"). Two layouts of seat directory are known: // repo /agents//launch.sh, where /.git exists. // Sessions live in /.pi/state//sessions. // fleet /launch.sh with /.pi (the ~/.mosaic fleet layout). // Sessions live in /.pi/agent/sessions. // Anything else is layout "unknown" and records nulls; nothing is guessed. import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, statSync, realpathSync } from "node:fs"; import { join, basename, dirname, isAbsolute, resolve } from "node:path"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; export const REGISTRATION_VERSION = 1; export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/; export const TASK_LIMIT = 2000; // Who set the task (#1511): caller-supplied attribution, bounded to one // short token so it cannot carry markup or whitespace. The bound is a syntax // limit, not a privacy filter: a numeric ID or an email-like name still fits, // so callers choose what they put here. It is what the caller claimed, not // an authenticated identity, and it grants nothing: the board displays it // and does no more with it. export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/; export const SET_BY_UNKNOWN = "unknown"; export const SET_BY_ENV = "MOSAIC_AGENT_NAME"; // exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or // configuration, 4 usage. export class SeatError extends Error { constructor(message, exitCode = 2) { super(message); this.name = "SeatError"; this.exitCode = exitCode; } } export function defaultConfigPath(env = process.env) { return env.MOSAIC_CONFIG ? resolve(env.MOSAIC_CONFIG) : join(homedir(), ".config", "mosaic-dev", "config.json"); } // Fail closed: the config must exist, parse, and name an absolute dataRoot. // Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation. export function loadDataRoot(path = defaultConfigPath()) { if (!existsSync(path)) throw new SeatError(`config not found: ${path}`); let raw; try { raw = JSON.parse(readFileSync(path, "utf8")); } catch (err) { throw new SeatError(`config is not valid JSON: ${path} (${err.message})`); } if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`); if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`); return raw.dataRoot; } export function seatsDir(dataRoot) { return join(dataRoot, "seats"); } export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]); // Keyed by layout and seat, because a seat name is not unique across // layouts (this repository and the fleet both have a "darkwing"). export function registrationPath(seats, seat, layout) { if (!SEAT_NAME.test(String(seat))) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4); if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout: ${JSON.stringify(layout)}`, 4); return join(seats, layout, seat, "registration.json"); } // Turn "" or "" into everything a launch needs to // know. A name resolves under /agents; a path is taken as the seat dir. export function resolveSeat(arg, { repo = process.cwd() } = {}) { if (typeof arg !== "string" || arg.length === 0) throw new SeatError("seat name or seat directory required", 4); let seatDir; if (arg.includes("/") || (existsSync(arg) && statSync(arg).isDirectory())) seatDir = resolve(arg); else if (SEAT_NAME.test(arg)) seatDir = join(resolve(repo), "agents", arg); else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4); const seat = basename(seatDir); if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4); if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: ${seatDir}`); const launchScript = join(seatDir, "launch.sh"); if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: ${seatDir}`); if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: ${launchScript}`); const parent = dirname(seatDir); const root = basename(parent) === "agents" ? dirname(parent) : null; if (root && existsSync(join(root, ".git"))) { return { seat, seatDir, launchScript, layout: "repo", project: basename(root), sessionsDir: join(root, ".pi", "state", seat, "sessions"), defaultWorkspace: root, }; } if (existsSync(join(seatDir, ".pi"))) { return { seat, seatDir, launchScript, layout: "fleet", project: null, sessionsDir: join(seatDir, ".pi", "agent", "sessions"), defaultWorkspace: null }; } return { seat, seatDir, launchScript, layout: "unknown", project: null, sessionsDir: null, defaultWorkspace: null }; } // The tmux session this process runs in, from the TMUX/TMUX_PANE variables // tmux sets for its panes. null outside tmux or when tmux cannot answer. // socket is null on the default server, else the socket file's name // (the value tmux -L takes), matching the control board's spec shape. export function tmuxContext({ env = process.env, exec = spawnSync } = {}) { const tmux = env.TMUX; if (typeof tmux !== "string" || tmux.length === 0) return null; const socketPath = tmux.split(",")[0]; if (!socketPath) return null; const args = ["-S", socketPath, "display-message", "-p"]; if (env.TMUX_PANE) args.push("-t", env.TMUX_PANE); args.push("#{session_name}"); const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 }); if (r.error || r.status !== 0) return null; const session = String(r.stdout ?? "").trim(); if (!session) return null; const socket = basename(socketPath); return { socket: socket === "default" ? null : socket, session }; } const FIELDS = Object.freeze([ "version", "seat", "project", "task", "workspace", "tmux", "harness", "startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt", "taskSetBy", ]); const isNullableString = (v) => v === null || typeof v === "string"; const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v)); // Shape check for a record read from disk or about to be written. Throws // SeatError with the failing field; never echoes the offending value. export function validateRegistration(record) { if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object"); for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${key}`); if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version"); if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid"); if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string"); // Optional: records written before #1511 have no taskSetBy and still load // (the board shows "unknown"); no version change, no migration on read. if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid"); for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) { if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`); } if (!LAYOUTS.includes(record.layout)) throw new SeatError("registration.layout is invalid"); if (record.tmux !== null) { const t = record.tmux; if (!t || typeof t !== "object" || Array.isArray(t)) throw new SeatError("registration.tmux must be an object or null"); if (!isNullableString(t.socket) || typeof t.session !== "string") throw new SeatError("registration.tmux is invalid"); } if (!isTimestamp(record.startedAt)) throw new SeatError("registration.startedAt must be a timestamp"); if (record.updatedAt !== null && !isTimestamp(record.updatedAt)) throw new SeatError("registration.updatedAt must be a timestamp or null"); if (record.pid !== null && !(Number.isInteger(record.pid) && record.pid > 0)) throw new SeatError("registration.pid must be a positive integer or null"); return record; } export function makeRegistration({ resolved, task = "", project, workspace, harness = null, tmux = null, pid = null, now = () => new Date() }) { if (typeof task !== "string") throw new SeatError("task must be a string", 4); if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4); return validateRegistration({ version: REGISTRATION_VERSION, seat: resolved.seat, project: project ?? resolved.project ?? null, task, workspace: workspace ?? resolved.defaultWorkspace ?? null, tmux, harness, startedAt: now().toISOString(), pid, sessionsDir: resolved.sessionsDir, seatDir: resolved.seatDir, launchScript: resolved.launchScript, layout: resolved.layout, updatedAt: null, }); } // Private: directory 0700, file 0600, atomic tmp+rename so a reader never // sees a half-written record. export function writeRegistration(seats, record) { validateRegistration(record); const path = registrationPath(seats, record.seat, record.layout); mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const tmp = `${path}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 }); renameSync(tmp, path); return path; } // null when the seat has no registration. A present but unreadable or // malformed record throws SeatError rather than being treated as absent. export function readRegistration(seats, seat, layout) { const path = registrationPath(seats, seat, layout); if (!existsSync(path)) return null; let parsed; try { parsed = JSON.parse(readFileSync(path, "utf8")); } catch (err) { throw new SeatError(`registration is not valid JSON: ${path} (${err.message})`); } try { const record = validateRegistration(parsed); if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path"); return record; } catch (err) { throw new SeatError(`${err.message}: ${path}`); } } // Every registration for a seat name, across layouts. Unreadable records // throw; a name with no record gives []. export function findRegistrations(seats, seat) { return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean); } // Who a `seat task` is attributed to: an explicit `--by NAME`, else the // MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match // SET_BY_NAME; so must a non-empty environment value, because a malformed // or unexpected value there is not the same as no value and is refused // rather than silently reported as "unknown". Neither value is echoed back. // The result is a claim by the caller, nothing more. export function resolveSetBy({ by = null, env = process.env } = {}) { if (by !== null && by !== undefined) { if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4); return by; } const fromEnv = env[SET_BY_ENV]; if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN; if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4); return fromEnv; } // Change the task field, and record who set it (taskSetBy), and updatedAt. // Every other field, startedAt included, is carried over unchanged. Refuses // when the seat was never launched through `mosaic launch`, because there is // nothing to attach the task to. A name that exists in more than one layout // must be qualified with layout. export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) { if (typeof task !== "string") throw new SeatError("task must be a string", 4); if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4); if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4); const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat); if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1); if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4); const record = found[0]; const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() }; writeRegistration(seats, updated); return updated; } // True when two paths name the same directory: equal once resolved, or the // same real path when both exist (symlinked checkouts). export function samePath(a, b) { if (typeof a !== "string" || typeof b !== "string") return false; if (resolve(a) === resolve(b)) return true; try { return realpathSync(a) === realpathSync(b); } catch { return false; } }