Add mosaic launch <seat> with seat registration for the control board (#1504)

New package packages/seat and wrapper scripts/mosaic. `launch <seat>` writes
<dataRoot>/seats/<layout>/<seat>/registration.json and then execs the seat's
launch.sh unchanged; `seat task <seat> <text>` edits the task only. The board
reads registrations, matches by sessions directory, and lets a registered
task, project or workspace override the derived value with a source tag.
The four repository launch scripts register themselves unless already
registered or run with --check. Fleet launchers untouched; one-liner on the
plan page.

Review found the record path keyed by seat name alone (repo and fleet
"darkwing" would collide); fixed by keying on layout. Also: the Pi pin
refusal now names installed and required versions.

Tests: seat 15, control-board 89, launch scripts 5, registry 69, config 24.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 10:56:17 -05:00
co-authored by Claude Fable 5.1
parent 01d9a19612
commit 69f99323c7
25 changed files with 1766 additions and 46 deletions
+138
View File
@@ -0,0 +1,138 @@
# seat
`mosaic launch <seat>` starts a seat through its existing launch script,
unchanged, and leaves one registration record that the control board reads
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
that record. Nothing else. No provider or auth registry, no roster schema
change, no stopping or killing of seats, one record per seat.
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
## Commands
The repository wrapper is `scripts/mosaic`. It is not on PATH and is not
the npm-global `mosaic` CLI from the estate tooling, which has no `launch`
or `seat` command; run it by path.
```
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
```
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
current directory) or a path to a seat directory such as
`~/.mosaic/fleet/agents/orch-01`. Either way the directory must hold an
executable `launch.sh`.
- Everything after `--` goes to the launch script untouched.
- `launch` writes the registration, then replaces itself with the launch
script (`process.execve`). The seat keeps the same pid, terminal and
process group as if you had run `launch.sh` yourself, the launch script's
exit code is yours, and the tmux pane's foreground command stays the
harness, which the control board's liveness check depends on. Node marks
`process.execve` experimental (present since 24); it is the only way to
keep the seat's pid, so the package accepts that and pins Node 24 or
newer. The seat tests exercise it directly (exit passthrough).
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
launch script that sees this variable is already registered and must not
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
The four repository seats (`agents/darkwing`, `agents/dewey`,
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
re-enters through `scripts/mosaic launch` unless already registered or
called with `--check`. So `agents/darkwing/launch.sh` and
`scripts/mosaic launch darkwing --task "..."` are the same path; the second
form is how you attach a task. A `--check` run never writes a record.
## The record
`<dataRoot>/seats/<layout>/<seat>/registration.json`, directory 0700, file 0600,
written atomically. `layout` is `repo`, `fleet` or `unknown` (see below);
it is part of the path because a seat name alone is not unique, and
`seat task` refuses a bare name that is registered in more than one layout
until `--layout` says which. `dataRoot` comes from `~/.config/mosaic-dev/config.json`
(or `$MOSAIC_CONFIG`); a missing or unreadable config refuses the launch.
The record is rewritten on every launch. It is written before the launch
script runs, so a launch the script itself refuses (a failed `--check`-style
precondition, a missing context file) still leaves a record with a pid that
is no longer running; the next launch replaces it. It is a launch record,
not a run record: it is not evidence, it holds one seat's latest launch only, and it
lives outside `<dataRoot>/board/` because the board never writes here and a
scan never changes it.
```json
{
"version": 1,
"seat": "darkwing",
"project": "mosaic-stack",
"task": "Control board: seat registration (#1504)",
"workspace": "/mnt/storage/src/mosaic-stack",
"tmux": { "socket": null, "session": "darkwing" },
"harness": "pi",
"startedAt": "2026-09-12T16:20:11.000Z",
"pid": 431734,
"sessionsDir": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions",
"seatDir": "/mnt/storage/src/mosaic-stack/agents/darkwing",
"launchScript": "/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh",
"layout": "repo",
"updatedAt": null
}
```
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
socket, from the `TMUX` variable of the pane the launch ran in; null outside
tmux), `harness` (only what `--harness` says; the repository launch scripts
pass `pi` or `claude-code`), `startedAt`, `pid`.
Fields added, and why:
- `sessionsDir`: how the board matches a record to a row. Seat names are not
unique across layouts (there is a `darkwing` in this repository and a
`darkwing` in the fleet), so the record names the sessions directory the
board already scans, and only an exact match counts.
- `seatDir`, `launchScript`, `layout`: what was launched and how the paths
were derived, so a wrong record can be traced without re-running anything.
`layout` is `repo` (`<repo>/agents/<seat>` with `<repo>/.git`), `fleet`
(`<seatDir>/.pi` exists) or `unknown` (nothing derived, `sessionsDir` null,
the record is still written but the board cannot match it).
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
from a relaunch.
- `version`: so a later shape change can be refused rather than misread.
`project` and `workspace` are derived only for the repo layout (the
repository's basename and root). For the fleet layout they are null unless
`--project` and `--workspace` are given, because the roster has no project
field and all fleet seats run in `~/.mosaic`; the board then keeps its own
derived values for those rows.
## How the board uses it
`packages/control-board` reads every `<dataRoot>/seats/<layout>/<seat>/registration.json`
on each scan. A registered task, project or workspace wins over the derived
value, and the row's `taskSource`, `activeProjectSource` or
`workspaceSource` says `registration`. An empty task or a null project or
workspace in the record leaves the derived value in place. Rows without a
registration are unchanged. A malformed record is reported in
`registrationErrors` on the index and skipped; it never takes the board down
and it is never treated as absent silently.
## Exit codes
`launch` exits with the launch script's own code once the script runs.
Before that: 1 the launch script could not be started (the record is
removed again), 2 invalid data or configuration (missing config, no such
seat, no executable `launch.sh`, bad record on disk), 4 usage.
`seat task`: 0 ok, 1 no registration for that seat, 2 as above, 4 usage.
## Tests
```
node --test packages/seat/tests/
```
The repository launch scripts are covered end to end by
`scripts/test-darkwing-launch.mjs` (darkwing, dewey, filbert) and
`scripts/test-rocko-launch.mjs`, which run each `launch.sh` in a fixture
root that is also its own data root, so no registration reaches a real one.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@mosaic/seat",
"version": "0.1.0",
"private": true,
"description": "Seat launch and registration: `mosaic launch <seat>` runs a seat's launch script unchanged and leaves one registration record the control board reads.",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=24" },
"bin": { "mosaic": "src/cli.mjs" },
"exports": { ".": "./src/seat.mjs" },
"scripts": { "test": "node --test tests/" }
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env node
// Usage:
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
//
// `launch` writes <dataRoot>/seats/<seat>/registration.json, then replaces
// itself with the seat's launch.sh, unchanged, with everything after `--` as
// its arguments and MOSAIC_LAUNCH_REGISTERED set to the record path. A launch
// script that sees that variable is already registered and must not call
// mosaic launch again. The exit code is the launch script's own.
//
// Exit codes: the launch script's code (launch); 0 ok; 1 operation failed;
// 2 invalid data or configuration; 4 usage.
import { rmSync } from "node:fs";
import {
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
makeRegistration, writeRegistration, updateTask,
} from "./seat.mjs";
const USAGE = [
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
].join("\n");
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
function parseLaunch(argv) {
const opts = { seat: null, task: "", project: undefined, workspace: undefined, harness: null, repo: process.cwd(), config: defaultConfigPath(), args: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
return argv[++i];
};
if (a === "--") { opts.args = argv.slice(i + 1); break; }
else if (a === "--task") opts.task = next();
else if (a === "--project") opts.project = next();
else if (a === "--workspace") opts.workspace = next();
else if (a === "--harness") opts.harness = next();
else if (a === "--repo") opts.repo = next();
else if (a === "--config") opts.config = next();
else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.seat === null) opts.seat = a;
else throw new SeatError(`unexpected argument: ${a} (put launch script arguments after --)`, 4);
}
if (opts.seat === null) throw new SeatError(USAGE, 4);
return opts;
}
function parseSeatTask(argv) {
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--layout") {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
opts[a.slice(2)] = argv[++i];
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
else if (opts.seat === null) opts.seat = a;
else if (opts.task === null) opts.task = a;
else throw new SeatError(`unexpected argument: ${a}`, 4);
}
if (opts.seat === null || opts.task === null) throw new SeatError(USAGE, 4);
return opts;
}
function launch(argv) {
const opts = parseLaunch(argv);
const resolved = resolveSeat(opts.seat, { repo: opts.repo });
const seats = seatsDir(loadDataRoot(opts.config));
// Register first, then replace this process with the launch script (same
// pid, same terminal, same process group). The launch script's exit code
// and signals are then the shell's to see directly, and the tmux pane's
// foreground command stays the harness itself, which the control board's
// liveness check depends on.
const record = makeRegistration({
resolved, task: opts.task, project: opts.project, workspace: opts.workspace,
harness: opts.harness, tmux: tmuxContext(), pid: process.pid,
});
const path = writeRegistration(seats, record);
process.stderr.write(`mosaic launch: registered ${resolved.seat} (${resolved.layout} layout) at ${path}\n`);
try {
process.execve(resolved.launchScript, [resolved.launchScript, ...opts.args], { ...process.env, [REGISTERED_ENV]: path });
} catch (err) {
rmSync(path, { force: true });
throw new SeatError(`could not run ${resolved.launchScript}: ${err.message}`, 1);
}
return 0;
}
function seatTask(argv) {
const opts = parseSeatTask(argv);
const seats = seatsDir(loadDataRoot(opts.config));
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
return 0;
}
function main(argv) {
if (process.env[REGISTERED_ENV] && argv[0] === "launch") {
throw new SeatError(`${REGISTERED_ENV} is already set; refusing to register a seat from inside a registered launch`, 4);
}
if (argv[0] === "launch") return launch(argv.slice(1));
if (argv[0] === "seat" && argv[1] === "task") return seatTask(argv.slice(2));
throw new SeatError(USAGE, 4);
}
try {
process.exitCode = main(process.argv.slice(2));
} catch (err) {
if (!(err instanceof SeatError)) throw err;
process.stderr.write(`refused: ${err.message}\n`);
process.exitCode = err.exitCode;
}
+236
View File
@@ -0,0 +1,236 @@
// Seat registration: one small record per seat under <dataRoot>/seats/<layout>/<seat>/
// registration.json, written by `mosaic launch <seat>` 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 <seat> <text>` changes the task field only.
//
// Records are keyed by layout and seat name, <seats>/<layout>/<seat>/, because
// a name alone is not unique (this repository and the fleet both have a
// "darkwing"). Two layouts of seat directory are known:
// repo <repo>/agents/<seat>/launch.sh, where <repo>/.git exists.
// Sessions live in <repo>/.pi/state/<seat>/sessions.
// fleet <seatDir>/launch.sh with <seatDir>/.pi (the ~/.mosaic fleet layout).
// Sessions live in <seatDir>/.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;
// 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 "<name>" or "<path to seat dir>" into everything a launch needs to
// know. A name resolves under <repo>/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",
]);
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");
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);
}
// Change the task field only. 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() } = {}) {
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);
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, 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;
}
}
+562
View File
@@ -0,0 +1,562 @@
import { test, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
readdirSync,
rmSync,
chmodSync,
symlinkSync,
statSync,
existsSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve, dirname, basename } from "node:path";
import { spawnSync } from "node:child_process";
import {
SeatError,
loadDataRoot,
seatsDir,
registrationPath,
resolveSeat,
tmuxContext,
validateRegistration,
makeRegistration,
writeRegistration,
readRegistration,
updateTask,
findRegistrations,
samePath,
TASK_LIMIT,
} from "../src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
const cli = join(pkgRoot, "src", "cli.mjs");
// Track every tmpdir so a stray failure never leaves fixtures behind.
const roots = [];
function makeRoot() {
const root = mkdtempSync(join(tmpdir(), "seat-test-"));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
function writeFile(path, content) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content);
}
function writeExecutable(path, content) {
writeFile(path, content);
chmodSync(path, 0o755);
}
const FAKE_LAUNCH_SH = [
"#!/usr/bin/env bash",
'printf \'%s\\n\' "$@" > "$(dirname "$0")/args.txt"',
'printf \'%s\\n\' "${MOSAIC_LAUNCH_REGISTERED:-unset}" > "$(dirname "$0")/env.txt"',
'exit "${FAKE_EXIT:-0}"',
"",
].join("\n");
// Repo layout fixture: <root>/.git, <root>/agents/<seat>/launch.sh.
function buildRepoSeat(root, seat, { launchContent = FAKE_LAUNCH_SH, executable = true } = {}) {
mkdirSync(join(root, ".git"), { recursive: true });
const seatDir = join(root, "agents", seat);
const launchScript = join(seatDir, "launch.sh");
if (executable) writeExecutable(launchScript, launchContent);
else writeFile(launchScript, launchContent);
return seatDir;
}
// Fleet layout fixture: <root>/fleet/agents/<seat>/{launch.sh,.pi/}, no <root>/.git.
function buildFleetSeat(root, seat) {
const seatDir = join(root, "fleet", "agents", seat);
writeExecutable(join(seatDir, "launch.sh"), FAKE_LAUNCH_SH);
mkdirSync(join(seatDir, ".pi"), { recursive: true });
return seatDir;
}
function buildConfig(root, dataRoot = join(root, "data")) {
const configPath = join(root, "config.json");
writeFile(configPath, JSON.stringify({ configVersion: 1, dataRoot }));
return { configPath, dataRoot };
}
function cliEnv(overrides = {}) {
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
delete env.MOSAIC_LAUNCH_REGISTERED;
return { ...env, ...overrides };
}
function runCli(args, { cwd, env = cliEnv() } = {}) {
return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8", env, timeout: 15000 });
}
function baseResolved(seatDir, seat = "myseat") {
return {
seat,
seatDir,
launchScript: join(seatDir, "launch.sh"),
layout: "repo",
project: "someproject",
sessionsDir: join(seatDir, "sessions"),
defaultWorkspace: dirname(dirname(seatDir)),
};
}
// ---------------------------------------------------------------------------
// 1-3. resolveSeat
// ---------------------------------------------------------------------------
test("resolveSeat: by name under --repo resolves the repo layout", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
assert.equal(resolved.seat, "myseat");
assert.equal(resolved.seatDir, seatDir);
assert.equal(resolved.layout, "repo");
assert.equal(resolved.project, basename(root));
assert.equal(resolved.sessionsDir, join(root, ".pi", "state", "myseat", "sessions"));
assert.equal(resolved.defaultWorkspace, root);
assert.equal(resolved.launchScript, join(seatDir, "launch.sh"));
});
test("resolveSeat: by path resolves the fleet layout", () => {
const root = makeRoot();
const seatDir = buildFleetSeat(root, "myseat");
const resolved = resolveSeat(seatDir);
assert.equal(resolved.seat, "myseat");
assert.equal(resolved.layout, "fleet");
assert.equal(resolved.project, null);
assert.equal(resolved.sessionsDir, join(seatDir, ".pi", "agent", "sessions"));
assert.equal(resolved.defaultWorkspace, null);
});
test("resolveSeat: refusals for missing dir, missing launch.sh, non-executable launch.sh, invalid name, and unknown layout", () => {
const root = makeRoot();
// Missing seat directory entirely.
assert.throws(() => resolveSeat(join(root, "agents", "ghost")), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// Seat directory exists but has no launch.sh.
const noLaunchDir = join(root, "agents", "nolaunch");
mkdirSync(noLaunchDir, { recursive: true });
assert.throws(() => resolveSeat(noLaunchDir), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// launch.sh present but not executable.
const notExecDir = join(root, "agents", "notexec");
writeFile(join(notExecDir, "launch.sh"), FAKE_LAUNCH_SH);
chmodSync(join(notExecDir, "launch.sh"), 0o644);
assert.throws(() => resolveSeat(notExecDir), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 2);
return true;
});
// Invalid seat name.
assert.throws(() => resolveSeat("Bad Name"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
return true;
});
// Parent not named "agents", no .git, no .pi: layout "unknown".
const looseDir = join(root, "loose", "myseat");
writeExecutable(join(looseDir, "launch.sh"), FAKE_LAUNCH_SH);
const resolved = resolveSeat(looseDir);
assert.equal(resolved.layout, "unknown");
assert.equal(resolved.project, null);
assert.equal(resolved.sessionsDir, null);
assert.equal(resolved.defaultWorkspace, null);
});
// ---------------------------------------------------------------------------
// 4. tmuxContext
// ---------------------------------------------------------------------------
test("tmuxContext: outside tmux, default socket, custom socket, and exec failure", () => {
// No TMUX var at all.
assert.equal(tmuxContext({ env: {} }), null);
// Empty TMUX (matches TMUX: "" in CLI fixtures).
assert.equal(tmuxContext({ env: { TMUX: "" } }), null);
// Default socket, with TMUX_PANE set: session name comes from exec stdout.
let captured = null;
const okExec = (cmd, args) => {
captured = { cmd, args };
return { status: 0, stdout: "sess\n" };
};
const result = tmuxContext({
env: { TMUX: "/tmp/tmux-1000/default,123,0", TMUX_PANE: "%3" },
exec: okExec,
});
assert.deepEqual(result, { socket: null, session: "sess" });
assert.equal(captured.cmd, "tmux");
assert.deepEqual(captured.args.slice(0, 4), ["-S", "/tmp/tmux-1000/default", "display-message", "-p"]);
assert.ok(captured.args.includes("-t"));
assert.equal(captured.args[captured.args.indexOf("-t") + 1], "%3");
// Non-default socket name is surfaced.
const namedSocket = tmuxContext({
env: { TMUX: "/tmp/tmux-1000/mosaic-fleet,1,0" },
exec: () => ({ status: 0, stdout: "other\n" }),
});
assert.deepEqual(namedSocket, { socket: "mosaic-fleet", session: "other" });
// Non-zero exit and exec error both yield null.
assert.equal(tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ status: 1, stdout: "" }) }), null);
assert.equal(
tmuxContext({ env: { TMUX: "/tmp/tmux-1000/default,1,0" }, exec: () => ({ error: new Error("no tmux"), status: null }) }),
null
);
});
// ---------------------------------------------------------------------------
// 5. makeRegistration + validateRegistration
// ---------------------------------------------------------------------------
test("makeRegistration produces a record that validates; each shape violation throws SeatError", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const record = makeRegistration({ resolved, task: "do things", pid: 1234 });
assert.deepEqual(validateRegistration(record), record);
const mutate = (patch) => ({ ...record, ...patch });
assert.throws(() => validateRegistration(mutate({ bogusField: "x" })), SeatError);
assert.throws(() => validateRegistration(mutate({ version: 2 })), SeatError);
assert.throws(() => validateRegistration(mutate({ task: "x".repeat(TASK_LIMIT + 1) })), SeatError);
assert.throws(() => validateRegistration(mutate({ pid: 1.5 })), SeatError);
assert.throws(() => validateRegistration(mutate({ pid: "1234" })), SeatError);
assert.throws(() => validateRegistration(mutate({ tmux: { socket: 5, session: "s" } })), SeatError);
assert.throws(() => validateRegistration(mutate({ tmux: { socket: null } })), SeatError);
});
// ---------------------------------------------------------------------------
// 6. writeRegistration / readRegistration
// ---------------------------------------------------------------------------
test("writeRegistration/readRegistration: round trip, permissions, absence, and malformed records", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "hello", pid: 42 });
const path = writeRegistration(seats, record);
assert.deepEqual(readRegistration(seats, "myseat", "repo"), record);
assert.equal(statSync(path).mode & 0o777, 0o600);
assert.equal(statSync(dirname(path)).mode & 0o777, 0o700);
// No leftover *.tmp-* artifacts after the atomic write.
for (const name of readdirSync(dirname(path))) assert.ok(!name.includes(".tmp-"), `leftover tmp file: ${name}`);
// Missing registration reads as null.
assert.equal(readRegistration(seats, "ghost", "repo"), null);
// Malformed JSON throws.
const malformedSeat = "malformed";
writeFile(registrationPath(seats, malformedSeat, "repo"), "{ not json");
assert.throws(() => readRegistration(seats, malformedSeat, "repo"), SeatError);
// A record with an unknown field throws rather than reading as absent.
const unknownFieldSeat = "unknownfield";
const badRecord = { ...record, seat: unknownFieldSeat, extra: "nope" };
writeFile(registrationPath(seats, unknownFieldSeat, "repo"), JSON.stringify(badRecord));
assert.throws(() => readRegistration(seats, unknownFieldSeat, "repo"), (err) => {
assert.ok(err instanceof SeatError);
assert.match(err.message, /unknown field/);
return true;
});
});
// ---------------------------------------------------------------------------
// 7. updateTask
// ---------------------------------------------------------------------------
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "original", pid: 1 });
writeRegistration(seats, record);
const updated = updateTask(seats, "myseat", "revised");
assert.equal(updated.task, "revised");
assert.notEqual(updated.updatedAt, null);
for (const key of Object.keys(record)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
}
// Refuses when there is no registration to attach to.
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 1);
return true;
});
// Refuses an over-limit task.
assert.throws(() => updateTask(seats, "myseat", "x".repeat(TASK_LIMIT + 1)), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
return true;
});
// The same name in a second layout makes the bare name ambiguous; the
// layout qualifier resolves it and each layout keeps its own record.
const fleetRecord = { ...record, layout: "fleet", sessionsDir: join(root, "fleet", "myseat", ".pi", "agent", "sessions"), task: "fleet task" };
writeRegistration(seats, fleetRecord);
assert.throws(() => updateTask(seats, "myseat", "x"), (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
assert.match(err.message, /more than one layout/);
return true;
});
assert.equal(updateTask(seats, "myseat", "fleet revised", { layout: "fleet" }).task, "fleet revised");
assert.equal(readRegistration(seats, "myseat", "repo").task, "revised");
assert.equal(readRegistration(seats, "myseat", "fleet").task, "fleet revised");
assert.equal(findRegistrations(seats, "myseat").length, 2);
});
// ---------------------------------------------------------------------------
// 8. CLI launch end to end
// ---------------------------------------------------------------------------
test("CLI launch: registers, execs the fake launch script, and passes args through", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--task", "do x", "--config", configPath, "--", "--foo", "bar"], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const seatDir = join(root, "agents", "myseat");
assert.equal(readFileSync(join(seatDir, "args.txt"), "utf8"), "--foo\nbar\n");
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
assert.equal(readFileSync(join(seatDir, "env.txt"), "utf8").trim(), path);
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.seat, "myseat");
assert.equal(record.project, basename(root));
assert.equal(record.task, "do x");
assert.equal(record.workspace, root);
assert.equal(record.harness, null);
assert.equal(record.tmux, null);
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
assert.equal(record.layout, "repo");
assert.ok(Number.isFinite(Date.parse(record.startedAt)));
});
test("CLI launch: --harness lands in the record", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--task", "t", "--harness", "pi", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.harness, "pi");
});
test("CLI launch: the launch script's own exit code passes through", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath } = buildConfig(root);
const r = runCli(["launch", "myseat", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath, FAKE_EXIT: "7" }),
});
assert.equal(r.status, 7, r.stderr);
});
// ---------------------------------------------------------------------------
// 9. CLI relaunch rewrites the single registration file
// ---------------------------------------------------------------------------
test("CLI launch: relaunching a seat rewrites the one registration record", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r1 = runCli(["launch", "myseat", "--task", "first", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r1.status, 0, r1.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record1 = JSON.parse(readFileSync(path, "utf8"));
const r2 = runCli(["launch", "myseat", "--task", "second", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r2.status, 0, r2.stderr);
const record2 = JSON.parse(readFileSync(path, "utf8"));
assert.deepEqual(readdirSync(dirname(path)), ["registration.json"]);
assert.equal(record2.task, "second");
assert.ok(Date.parse(record2.startedAt) >= Date.parse(record1.startedAt));
});
// ---------------------------------------------------------------------------
// 10. CLI launch with no --task
// ---------------------------------------------------------------------------
test("CLI launch: omitting --task records an empty string, not null", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const r = runCli(["launch", "myseat", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const record = JSON.parse(readFileSync(path, "utf8"));
assert.equal(record.task, "");
});
// ---------------------------------------------------------------------------
// 11. CLI seat task
// ---------------------------------------------------------------------------
test("CLI seat task: updates only the task after a launch, and refuses on an unlaunched seat", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(launchResult.status, 0, launchResult.stderr);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const before = JSON.parse(readFileSync(path, "utf8"));
const r = runCli(["seat", "task", "myseat", "new text", "--config", configPath], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: configPath }),
});
assert.equal(r.status, 0, r.stderr);
const after1 = JSON.parse(readFileSync(path, "utf8"));
assert.equal(after1.task, "new text");
for (const key of Object.keys(before)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(after1[key], before[key], `field ${key} changed unexpectedly`);
}
// No registration for a seat that was never launched.
const root2 = makeRoot();
buildRepoSeat(root2, "unlaunched");
const { configPath: configPath2 } = buildConfig(root2);
const r2 = runCli(["seat", "task", "unlaunched", "text", "--config", configPath2], {
cwd: root2,
env: cliEnv({ MOSAIC_CONFIG: configPath2 }),
});
assert.equal(r2.status, 1);
assert.match(r2.stderr, /no registration/);
});
// ---------------------------------------------------------------------------
// 12. CLI refusals
// ---------------------------------------------------------------------------
test("CLI refusals: no args, unknown flag, missing config, already-registered env, and exec failure", () => {
// No args: usage, exit 4.
const rNoArgs = runCli([]);
assert.equal(rNoArgs.status, 4);
assert.match(rNoArgs.stderr, /usage: mosaic launch/);
// Unknown flag: exit 4.
const rUnknown = runCli(["launch", "myseat", "--bogus"]);
assert.equal(rUnknown.status, 4);
assert.match(rUnknown.stderr, /unknown argument/);
// Missing config with an otherwise-valid seat: exit 2.
const root = makeRoot();
buildRepoSeat(root, "myseat");
const missingConfig = join(root, "missing.json");
assert.ok(!existsSync(missingConfig));
const rMissingConfig = runCli(["launch", "myseat", "--config", missingConfig], {
cwd: root,
env: cliEnv({ MOSAIC_CONFIG: missingConfig }),
});
assert.equal(rMissingConfig.status, 2);
assert.match(rMissingConfig.stderr, /config not found/);
// MOSAIC_LAUNCH_REGISTERED already set: exit 4, no registration written.
const root2 = makeRoot();
buildRepoSeat(root2, "myseat");
const { configPath: configPath2, dataRoot: dataRoot2 } = buildConfig(root2);
const rRegistered = runCli(["launch", "myseat", "--config", configPath2], {
cwd: root2,
env: cliEnv({ MOSAIC_CONFIG: configPath2, MOSAIC_LAUNCH_REGISTERED: "/some/path" }),
});
assert.equal(rRegistered.status, 4);
assert.match(rRegistered.stderr, /MOSAIC_LAUNCH_REGISTERED/);
assert.ok(!existsSync(registrationPath(seatsDir(dataRoot2), "myseat", "repo")));
// Launch script that fails to exec: exit 1, no registration left behind.
const root3 = makeRoot();
buildRepoSeat(root3, "myseat", { launchContent: "#!/nonexistent/interp\necho hi\n" });
const { configPath: configPath3, dataRoot: dataRoot3 } = buildConfig(root3);
const rExecFail = runCli(["launch", "myseat", "--config", configPath3], {
cwd: root3,
env: cliEnv({ MOSAIC_CONFIG: configPath3 }),
});
assert.equal(rExecFail.status, 1);
assert.match(rExecFail.stderr, /could not run/);
assert.ok(!existsSync(registrationPath(seatsDir(dataRoot3), "myseat", "repo")));
});
// ---------------------------------------------------------------------------
// 13. samePath
// ---------------------------------------------------------------------------
test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", () => {
const root = makeRoot();
const target = join(root, "target");
mkdirSync(target);
const other = join(root, "other");
mkdirSync(other);
const link = join(root, "link");
symlinkSync(target, link, "dir");
assert.equal(samePath(target, target), true);
assert.equal(samePath(target, link), true);
assert.equal(samePath(target, other), false);
assert.equal(samePath(1, "x"), false);
assert.equal(samePath(null, target), false);
});