fleet: start a roster pane through its seat when one is scaffolded

The roster lane and the harness-homes lane did not touch. start-agent-session.sh
ran `mosaic yolo "$RUNTIME"` with HOME set to the operator's home, so every fleet
seat on a host shared the operator's harness home and, for Claude, the operator's
own ~/.claude credentials. Nothing in framework/ called `mosaic fleet launch` at
all, which meant ~/.mosaic was a directory nothing read.

The pane now runs `mosaic fleet launch "$AGENT_NAME"` when a scaffolded seat
exists at $PANE_HOME/.mosaic/fleet/agents/<name>/profile.json, and the historical
command otherwise. Detection uses $PANE_HOME/.mosaic rather than MOSAIC_DATA_HOME
because the pane environment is cleared with env -i; the composition resolves the
same root from HOME, so the two cannot disagree.

Additive by construction: a host with no scaffolded seats launches exactly as
before, so this can land ahead of any seat being enrolled.

- fleet launch gains --dangerous, threaded to launchFleetRuntime. Without it a
  seat launched from the roster would drop the permissions footing `mosaic yolo`
  gave it and prompt at a pane with nobody at it. The roster launcher asks for it
  explicitly so it stays visible in the process table instead of becoming a
  profile default.
- A caller's --model replaces the profile's instead of being appended after it.
  The roster carries a model per seat and is the surface operators edit; emitting
  both flags would leave the choice to each harness's argument parser.
- Claude workdir trust is written into the seat's .claude.json when the pane will
  run in a seat home. It previously always went to the operator's ~/.claude.json,
  which would leave the seat prompting on its first turn.

Covers Jason's scope amendment for web1: without this seam, "multiple
authentication accounts and agent pegging to auth" cannot be demonstrated on a
roster-managed seat.
This commit is contained in:
terra
2026-08-14 18:35:11 -05:00
parent a12eeb4786
commit c1a42cdb81
5 changed files with 121 additions and 32 deletions
@@ -149,6 +149,7 @@ export interface FleetLaunchCommandDeps {
args: string[],
declaredEnv: Readonly<Record<string, string>>,
context: FleetHarnessContext,
dangerous: boolean,
) => void;
}
@@ -672,7 +673,10 @@ function buildArgv(
passthrough: string[],
): string[] {
const argv: string[] = [profile.harness];
if (profile.model) argv.push('--model', profile.model);
// A caller-supplied --model replaces the profile's rather than being appended after
// it. The fleet roster carries a model per seat and is the surface operators edit, so
// it has to win; emitting both flags would leave that to each harness's arg parser.
if (profile.model && !passthrough.includes('--model')) argv.push('--model', profile.model);
if (profile.harness === 'pi') {
for (const skill of profile.skills) argv.push('--skill', join(seatHome, 'skills', skill));
}
@@ -963,33 +967,43 @@ export function registerFleetLaunchCommand(
.command('launch <name>')
.description('Compose and launch one per-agent harness home')
.option('--dry-run', 'Print the fully resolved composition without writing or launching')
.option('--dangerous', 'Launch the seat in dangerous-permissions mode, as `mosaic yolo` does')
.allowUnknownOption(true)
.allowExcessArguments(true)
.action((name: string, opts: { dryRun?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
.action(
(name: string, opts: { dryRun?: boolean; dangerous?: boolean }, command: Command): void => {
try {
const userHome = deps.userHome ?? defaultFleetDataHome();
const passthrough = command.args.slice(1);
const plan = resolveFleetLaunchComposition(
name,
{ systemHome: systemHomeFor(), userHome },
passthrough,
);
if (opts.dryRun === true) {
process.stdout.write(`${formatFleetLaunchDryRun(plan)}\n`);
return;
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
// Dangerous mode is the caller's to ask for, not the seat's to assume. An
// unattended tmux seat needs it -- a permission prompt with nobody at the pane
// is a hung agent -- so the roster launcher passes the flag explicitly and it
// stays visible in the process table rather than hiding in a profile default.
launcher(
plan.profile.harness,
plan.argv.slice(1),
plan.env,
{ agentDir: plan.agentDir, mosaicHome: plan.systemHome },
opts.dangerous === true,
);
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
applyFleetLaunchComposition(plan);
console.log(`[mosaic] bundle: ${plan.bundle.display}`);
const launcher = deps.launcher ?? launchFleetRuntime;
launcher(plan.profile.harness, plan.argv.slice(1), plan.env, {
agentDir: plan.agentDir,
mosaicHome: plan.systemHome,
});
} catch (error: unknown) {
process.exitCode = 1;
const code = error instanceof FleetLaunchError ? `${error.code}: ` : '';
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`mosaic fleet launch failed: ${code}${message}\n`);
}
});
},
);
}