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:
@@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
|
||||
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
|
||||
const TEXT_LIMIT = 240;
|
||||
@@ -243,15 +244,52 @@ function liveness(result) {
|
||||
return { alive: result ?? null, workspace: null };
|
||||
}
|
||||
|
||||
// Registrations written by `mosaic launch <seat>` (packages/seat): one
|
||||
// record per seat under <dataRoot>/seats/<layout>/<seat>/registration.json. Returns
|
||||
// the readable records plus one error line per unreadable one; a bad record
|
||||
// must not take the whole board down, but it is not silently dropped either.
|
||||
export function loadRegistrations(seatsDir) {
|
||||
const registrations = [];
|
||||
const errors = [];
|
||||
if (!seatsDir || !existsSync(seatsDir)) return { registrations, errors };
|
||||
for (const layout of LAYOUTS) {
|
||||
const layoutDir = join(seatsDir, layout);
|
||||
if (!existsSync(layoutDir) || !statSync(layoutDir).isDirectory()) continue;
|
||||
for (const seat of readdirSync(layoutDir).sort()) {
|
||||
if (!statSync(join(layoutDir, seat)).isDirectory()) continue;
|
||||
try {
|
||||
const rec = readRegistration(seatsDir, seat, layout);
|
||||
if (rec) registrations.push(rec);
|
||||
} catch (err) {
|
||||
if (!(err instanceof SeatError)) throw err;
|
||||
errors.push(`seat ${layout}/${seat}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { registrations, errors };
|
||||
}
|
||||
|
||||
// A registration belongs to a row when it names the same sessions directory.
|
||||
// Seat names alone are not enough: the repo and fleet layouts both have a
|
||||
// "darkwing", and they are different seats.
|
||||
export function matchRegistration(spec, registrations) {
|
||||
return registrations.find((r) => r.sessionsDir && samePath(r.sessionsDir, spec.sessionsDir)) ?? null;
|
||||
}
|
||||
|
||||
// One agent -> one status record.
|
||||
//
|
||||
// Three fields answer "what is this seat doing, and where" (Gate A ask,
|
||||
// 2026-09-12). Each is derived, never guessed; null means "unknown".
|
||||
// task the session's first user message. The log has no task
|
||||
// envelope entry, so this is the only assignment signal it holds.
|
||||
// workspace the live pane path from tmux, else the session log's cwd.
|
||||
// activeProject basename of the nearest git repo root above the workspace.
|
||||
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) {
|
||||
// 2026-09-12). Each is derived, never guessed; null means "unknown". A seat
|
||||
// started through `mosaic launch` has a registration, and a registered task,
|
||||
// project or workspace wins over the derived value; the *Source field says
|
||||
// which one the row shows.
|
||||
// task registration.task, else the session's first user message
|
||||
// (the log has no task envelope entry).
|
||||
// workspace registration.workspace, else the live pane path from tmux,
|
||||
// else the session log's cwd.
|
||||
// activeProject registration.project, else the basename of the nearest git
|
||||
// repo root above the workspace.
|
||||
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null } = {}) {
|
||||
const live = liveness(isAlive(spec.tmux));
|
||||
const alive = live.alive;
|
||||
const file = findNewestSession(spec.sessionsDir);
|
||||
@@ -263,8 +301,17 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
const needsYou = state === "waiting" || state === "error";
|
||||
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
|
||||
const cwd = session?.cwd ?? null;
|
||||
const workspace = live.workspace ?? cwd;
|
||||
const repoRoot = workspace ? findRepoRoot(workspace) : null;
|
||||
const reg = registration && typeof registration === "object" ? registration : null;
|
||||
const derivedWorkspace = live.workspace ?? cwd;
|
||||
const workspace = reg?.workspace ?? derivedWorkspace;
|
||||
const workspaceSource = reg?.workspace ? "registration" : live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null;
|
||||
const repoRoot = derivedWorkspace ? findRepoRoot(derivedWorkspace) : null;
|
||||
const derivedProject = repoRoot ? basename(repoRoot) : null;
|
||||
const activeProject = reg?.project ?? derivedProject;
|
||||
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
|
||||
const firstUserText = session?.firstUserText ?? null;
|
||||
const task = reg?.task ? reg.task : firstUserText;
|
||||
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
|
||||
return {
|
||||
agent: spec.agent,
|
||||
project: spec.project,
|
||||
@@ -276,11 +323,15 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
|
||||
sessionFile: file,
|
||||
sessionId: session?.sessionId ?? null,
|
||||
cwd,
|
||||
task: session?.firstUserText ?? null,
|
||||
taskSource: session?.firstUserText ? "first-user-message" : null,
|
||||
task,
|
||||
taskSource,
|
||||
workspace,
|
||||
workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null,
|
||||
activeProject: repoRoot ? basename(repoRoot) : null,
|
||||
workspaceSource,
|
||||
activeProject,
|
||||
activeProjectSource,
|
||||
registered: reg
|
||||
? { startedAt: reg.startedAt, updatedAt: reg.updatedAt, harness: reg.harness, pid: reg.pid, tmux: reg.tmux, layout: reg.layout, launchScript: reg.launchScript }
|
||||
: null,
|
||||
lastActivity,
|
||||
ageSeconds,
|
||||
lastAssistantText: session?.lastAssistantText ?? null,
|
||||
@@ -317,10 +368,13 @@ function writeAtomic(path, data) {
|
||||
}
|
||||
|
||||
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
|
||||
export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
// seatsDir (optional): where `mosaic launch` registrations live; read only.
|
||||
export function scan(specs, { boardDir, isAlive, now, seatsDir = null } = {}) {
|
||||
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
|
||||
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
|
||||
const seen = loadSeen(boardDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen }));
|
||||
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
|
||||
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations) }));
|
||||
for (const rec of records) {
|
||||
const dir = join(boardDir, "sessions", rec.project);
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
@@ -332,6 +386,8 @@ export function scan(specs, { boardDir, isAlive, now } = {}) {
|
||||
counts: Object.fromEntries(STATES.map((s) => [s, records.filter((r) => r.state === s).length])),
|
||||
waitingOnYou: records.filter((r) => r.waitingOnYou).map(seenKey),
|
||||
seen: records.filter((r) => r.seen).map(seenKey),
|
||||
registered: records.filter((r) => r.registered).map(seenKey),
|
||||
registrationErrors,
|
||||
sessions: records,
|
||||
};
|
||||
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
Reference in New Issue
Block a user