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:
@@ -67,7 +67,8 @@ USER_FILE="${USER_FILE:-$MOSAIC_DATA_ROOT/user/USER.md}"
|
||||
PI="$REPO/node_modules/.bin/pi"
|
||||
[ -x "$PI" ] || fail 'run npm ci --ignore-scripts --no-audit --no-fund in the repository first'
|
||||
PIN="$(node -p "require('./package.json').dependencies['@earendil-works/pi-coding-agent']")"
|
||||
[ "$("$PI" --version)" = "$PIN" ] || fail "local Pi must match package.json ($PIN); run npm ci"
|
||||
PI_VERSION="$("$PI" --version)"
|
||||
[ "$PI_VERSION" = "$PIN" ] || fail "local Pi is $PI_VERSION, package.json pins $PIN; run npm ci in $REPO"
|
||||
command -v flock >/dev/null || fail 'flock is required'
|
||||
CONTEXT_FILES=("$CONSTITUTION" "$REPO/contracts/STANDARDS.md" "$SOUL" "$USER_FILE" "$REPO/AGENTS.md" "$REPO/agents/$AGENT/CONTEXT.md")
|
||||
for file in "${CONTEXT_FILES[@]}"; do
|
||||
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
|
||||
# with registration for the control board. See packages/seat/README.md.
|
||||
# Not the npm-global `mosaic` CLI from the estate tooling; this one is
|
||||
# repository-local and only reachable as scripts/mosaic.
|
||||
set -euo pipefail
|
||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
@@ -1,9 +1,9 @@
|
||||
// Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, cpSync, existsSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, cpSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join, resolve, basename } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const source = resolve(import.meta.dirname, '..');
|
||||
@@ -23,6 +23,14 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont
|
||||
assert.equal(existsSync(join(root, `agents/${agent}/scratch`)), false);
|
||||
cpSync(join(source, 'scripts/agent-host-dev.sh'), join(root, 'scripts/agent-host-dev.sh'));
|
||||
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
|
||||
// Seat registration (packages/seat): the launch script re-enters itself
|
||||
// through scripts/mosaic, which needs a config naming a data root. The
|
||||
// fixture is its own data root so no registration reaches a real one.
|
||||
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
|
||||
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
|
||||
mkdirSync(join(root, '.git'));
|
||||
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
|
||||
const registration = join(root, `seats/repo/${agent}/registration.json`);
|
||||
cpSync(join(source, 'skills'), join(root, 'skills'), { recursive: true });
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@earendil-works/pi-coding-agent': '0.85.1' } }));
|
||||
for (const file of ['contracts/CONSTITUTION.md', 'contracts/STANDARDS.md', 'user/USER.md', 'AGENTS.md']) {
|
||||
@@ -34,14 +42,15 @@ for (const agent of ['darkwing', 'dewey', 'filbert']) test(`${agent} launch cont
|
||||
writeFileSync(join(root, 'node_modules/.bin/pi'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === '--version') { console.log('0.85.1'); process.exit(0); }
|
||||
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION}));
|
||||
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED}));
|
||||
`, { mode: 0o755 });
|
||||
const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty
|
||||
? ['-q', '-e', '-c', `bash agents/${agent}/launch.sh ${args}`, '/dev/null']
|
||||
: [`agents/${agent}/launch.sh`, ...args.split(' ').filter(Boolean)],
|
||||
{ cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit' }, encoding: 'utf8', timeout: 10000 });
|
||||
{ cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 });
|
||||
const ok = (result) => assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
ok(launch('--check', false));
|
||||
assert.equal(existsSync(join(root, 'seats')), false);
|
||||
const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', agent, '--check'],
|
||||
{ cwd: tmpdir(), encoding: 'utf8', timeout: 10000 });
|
||||
ok(direct);
|
||||
@@ -61,6 +70,18 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag
|
||||
let captured = JSON.parse(readFileSync(join(root, 'capture.json')));
|
||||
assert.equal(captured.incarnation, undefined);
|
||||
assert.equal(captured.agent, agent);
|
||||
// The seat registered itself before Pi started, and Pi saw the record path.
|
||||
assert.equal(captured.registered, registration);
|
||||
const record = JSON.parse(readFileSync(registration, 'utf8'));
|
||||
assert.equal(record.seat, agent);
|
||||
assert.equal(record.harness, 'pi');
|
||||
assert.equal(record.layout, 'repo');
|
||||
assert.equal(record.project, basename(root));
|
||||
assert.equal(record.workspace, root);
|
||||
assert.equal(record.task, '');
|
||||
assert.equal(record.sessionsDir, join(root, `.pi/state/${agent}/sessions`));
|
||||
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
|
||||
const firstStart = record.startedAt;
|
||||
assert.equal(captured.args[captured.args.indexOf('--provider') + 1], agent === 'filbert' ? 'openai-codex' : 'test');
|
||||
assert.equal(captured.args[captured.args.indexOf('--model') + 1], agent === 'filbert' ? 'gpt-6-astra' : 'test-model');
|
||||
if (agent === 'filbert') assert.equal(captured.args[captured.args.indexOf('--thinking') + 1], 'low');
|
||||
@@ -88,6 +109,15 @@ fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), ag
|
||||
assert.notEqual(captured.args[captured.args.indexOf('--append-system-prompt') + 1], firstPrompt);
|
||||
ok(launch('--fresh'));
|
||||
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).args.includes('--continue'), false);
|
||||
// Relaunch rewrites the one record; nothing accumulates.
|
||||
const relaunched = JSON.parse(readFileSync(registration, 'utf8'));
|
||||
assert.ok(Date.parse(relaunched.startedAt) >= Date.parse(firstStart));
|
||||
assert.deepEqual(readdirSync(join(root, `seats/repo/${agent}`)), ['registration.json']);
|
||||
// Launching through scripts/mosaic directly is the same path, with a task.
|
||||
ok(spawnSync('script', ['-q', '-e', '-c', `scripts/mosaic launch --repo "${root}" --task "fixture task" ${agent} -- --fresh`, '/dev/null'],
|
||||
{ cwd: root, env: { ...process.env, MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' }, encoding: 'utf8', timeout: 10000 }));
|
||||
assert.equal(JSON.parse(readFileSync(registration, 'utf8')).task, 'fixture task');
|
||||
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).registered, registration);
|
||||
assert.equal(readFileSync(session, 'utf8'), history);
|
||||
writeFileSync(session, 'broken JSON\n');
|
||||
assert.notEqual(launch().status, 0);
|
||||
|
||||
@@ -16,13 +16,19 @@ test('Rocko uses Sonnet, isolated resume IDs, current context and launch refusal
|
||||
writeFileSync(join(root, file), `fixture ${file}\n`);
|
||||
}
|
||||
writeFileSync(join(root, 'scripts/common.sh'), 'load_config() { MOSAIC_DATA_ROOT="$PWD"; }\n');
|
||||
// Seat registration (packages/seat): the fixture is its own data root.
|
||||
cpSync(join(source, 'scripts/mosaic'), join(root, 'scripts/mosaic'));
|
||||
cpSync(join(source, 'packages/seat/src'), join(root, 'packages/seat/src'), { recursive: true });
|
||||
mkdirSync(join(root, '.git'));
|
||||
writeFileSync(join(root, 'config.json'), JSON.stringify({ configVersion: 1, dataRoot: root }));
|
||||
const registration = join(root, 'seats/repo/rocko/registration.json');
|
||||
writeFileSync(join(root, 'bin/claude'), `#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === '--version') { console.log('fixture Claude'); process.exit(0); }
|
||||
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION}));
|
||||
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), cwd: process.cwd(), agent: process.env.MOSAIC_AGENT_NAME, incarnation: process.env.MOSAIC_LAUNCH_INCARNATION, registered: process.env.MOSAIC_LAUNCH_REGISTERED}));
|
||||
if (process.env.FAKE_CLAUDE_FAIL) process.exit(7);
|
||||
`, { mode: 0o755 });
|
||||
const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit' };
|
||||
const env = { ...process.env, PATH: `${join(root, 'bin')}:${process.env.PATH}`, MOSAIC_LAUNCH_INCARNATION: 'do-not-inherit', MOSAIC_CONFIG: join(root, 'config.json'), MOSAIC_LAUNCH_REGISTERED: '' };
|
||||
const run = (args = '', tty = true, extraEnv = {}) => spawnSync(tty ? 'script' : 'bash', tty
|
||||
? ['-q', '-e', '-c', `bash agents/rocko/launch.sh ${args}`, '/dev/null']
|
||||
: [join(root, 'agents/rocko/launch.sh'), ...args.split(' ').filter(Boolean)],
|
||||
@@ -33,10 +39,19 @@ if (process.env.FAKE_CLAUDE_FAIL) process.exit(7);
|
||||
ok(run('--check', false));
|
||||
assert.equal(existsSync(state), false);
|
||||
assert.equal(existsSync(join(root, 'capture.json')), false);
|
||||
assert.equal(existsSync(join(root, 'seats')), false);
|
||||
ok(run());
|
||||
let captured = capture();
|
||||
assert.equal(captured.cwd, root);
|
||||
assert.equal(captured.agent, 'rocko');
|
||||
// Registered before Claude started; the record names the Claude harness.
|
||||
assert.equal(captured.registered, registration);
|
||||
const record = JSON.parse(readFileSync(registration, 'utf8'));
|
||||
assert.equal(record.seat, 'rocko');
|
||||
assert.equal(record.harness, 'claude-code');
|
||||
assert.equal(record.layout, 'repo');
|
||||
assert.equal(record.workspace, root);
|
||||
assert.ok(Number.isInteger(record.pid) && record.pid > 0);
|
||||
assert.equal(captured.incarnation, undefined);
|
||||
assert.equal(captured.args[captured.args.indexOf('--model') + 1], 'sonnet');
|
||||
assert.equal(captured.args[captured.args.indexOf('--name') + 1], 'Rocko');
|
||||
|
||||
Reference in New Issue
Block a user