import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  acquireRestartLock,
  addAgentToRoster,
  buildAgentSendCommand,
  buildAgentWatchAttachCommand,
  buildAgentWatchCommand,
  buildAgentWatchCreateViewerCommand,
  buildAgentWatchKillViewerCommand,
  buildAgentVerifyAcceptedCommand,
  buildEnableLingerCommand,
  buildFleetServiceCommand,
  buildSystemdEnableCommand,
  buildSystemdDisableCommand,
  socketArgs,
  buildSystemdShowCommand,
  buildTmuxListPanesCommand,
  buildTmuxListSessionsCommand,
  classifyReadiness,
  classifySendResult,
  countOrchestrators,
  countEnhancers,
  detectDrift,
  enableFleetUnits,
  FLEET_PROFILES,
  HEARTBEAT_IDLE_THRESHOLD_SECONDS,
  generateAgentEnv,
  getDefaultOperatorSourceLabel,
  getDefaultTenantAndHost,
  getRosterAgent,
  heartbeatPath,
  idleThresholdSeconds,
  isSendAccepted,
  loadFleetRoster,
  mergeAgentEnv,
  parseHeartbeat,
  parseInitProfile,
  parseSystemdShow,
  parseTmuxListPanes,
  parseTmuxListSessions,
  registerFleetCommand,
  removeAgentFromRoster,
  resolveFleetPaths,
  resolvePresetFilename,
  restartLockPath,
  RESTART_LOCK_STALE_MS,
  RUNTIME_ACCEPTABLE_COMMANDS,
  serializeRosterToYaml,
  VERIFY_DEFAULT_TIMEOUT_MS,
  VERIFY_POLL_INTERVAL_MS,
  type AgentPsRow,
  type CommandRunner,
  type FleetProfile,
  type FleetRoster,
  type InteractiveRunner,
  type SleepFn,
} from './fleet.js';
import { registerAgentCommand } from './agent.js';

function buildProgram(): Command {
  const program = new Command();
  program.exitOverride();
  registerFleetCommand(program);
  registerAgentCommand(program);
  return program;
}

async function tempDir(): Promise<string> {
  return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
}

describe('registerFleetCommand', () => {
  it('registers local canary fleet subcommands', () => {
    const program = buildProgram();
    const fleet = program.commands.find((command) => command.name() === 'fleet');

    expect(fleet).toBeDefined();
    expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
      'add',
      'backlog',
      'init',
      'install',
      'install-systemd',
      'persona',
      'profile',
      'provision',
      'ps',
      'remove',
      'restart',
      'start',
      'status',
      'stop',
      'verify',
    ]);
  });

  it('registers the profile subcommand with list and show', () => {
    const program = buildProgram();
    const fleet = program.commands.find((command) => command.name() === 'fleet');
    const profile = fleet!.commands.find((command) => command.name() === 'profile');

    expect(profile).toBeDefined();
    expect(profile!.commands.map((command) => command.name()).sort()).toEqual(['list', 'show']);
  });

  it('registers the persona subcommand with list, show, and customize', () => {
    const program = buildProgram();
    const fleet = program.commands.find((command) => command.name() === 'fleet');
    const persona = fleet!.commands.find((command) => command.name() === 'persona');

    expect(persona).toBeDefined();
    expect(persona!.commands.map((command) => command.name()).sort()).toEqual([
      'customize',
      'list',
      'show',
    ]);
  });

  it('registers the backlog subcommand with its operations', () => {
    const program = buildProgram();
    const fleet = program.commands.find((command) => command.name() === 'fleet');
    const backlog = fleet!.commands.find((command) => command.name() === 'backlog');

    expect(backlog).toBeDefined();
    expect(backlog!.commands.map((command) => command.name()).sort()).toEqual([
      'block',
      'claim',
      'complete',
      'create',
      'link',
      'list',
      'reclaim',
      'stats',
    ]);
  });

  it('adds fleet-backed agent subcommands without removing existing options', () => {
    const program = buildProgram();
    const agent = program.commands.find((command) => command.name() === 'agent');

    expect(agent).toBeDefined();
    expect(agent!.options.map((option) => option.long)).toContain('--list');
    expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
      'comms-block',
      'reset',
      'roster',
      'send',
      'status',
      'tail',
      'watch',
    ]);
  });
});

describe('fleet roster parsing', () => {
  let cleanup: string | undefined;

  afterEach(async () => {
    if (cleanup) {
      await rm(cleanup, { recursive: true, force: true });
      cleanup = undefined;
    }
  });

  it('defaults a socket-less roster to the literal default tmux socket (empty, no -L)', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.yaml');
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '    class: canary',
      ].join('\n'),
    );

    const roster = await loadFleetRoster(rosterPath);

    expect(roster.tmux.socketName).toBe(''); // absent ⇒ default socket (no -L), not mosaic-fleet
    expect(roster.tmux.holderSession).toBe('_holder');
    expect(roster.agents).toHaveLength(1);
    expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
  });

  it('accepts optional agent alias and provider metadata without requiring them', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.yaml');
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: interaction',
        '    alias: Friday',
        '    provider: openai-codex',
        '    runtime: pi',
        '  - name: worker0',
        '    runtime: codex',
      ].join('\n'),
    );

    const roster = await loadFleetRoster(rosterPath);

    expect(getRosterAgent(roster, 'interaction')).toMatchObject({
      name: 'interaction',
      alias: 'Friday',
      provider: 'openai-codex',
    });
    expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' });
    expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined();
    expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined();
  });

  it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
    expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
    expect(socketArgs('')).toEqual([]);
  });

  it('honors an explicit socket_name (renders -L) — containment for shipped presets', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.yaml');
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'tmux:',
        '  socket_name: mosaic-fleet',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
      ].join('\n'),
    );
    const roster = await loadFleetRoster(rosterPath);
    expect(roster.tmux.socketName).toBe('mosaic-fleet');
    expect(buildTmuxListSessionsCommand(roster.tmux.socketName)).toContain('-L');
  });

  it('maps a per-agent model_hint into MOSAIC_AGENT_MODEL', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'coder0', runtime: 'pi', model_hint: 'openai-codex/gpt-5.5:high' }],
      }),
    );
    const roster = await loadFleetRoster(rosterPath);
    const env = generateAgentEnv(roster, getRosterAgent(roster, 'coder0'));
    expect(env).toContain('MOSAIC_AGENT_MODEL=openai-codex/gpt-5.5:high');
    // socket-less roster ⇒ a bare empty socket (no quotes), so spawn uses no -L
    expect(env).toContain('MOSAIC_TMUX_SOCKET=\n');
  });

  it('generates deterministic per-agent EnvironmentFile content', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        tmux: { socket_name: 'mosaic-fleet' },
        defaults: { working_directory: '/srv/mosaic' },
        agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
      }),
    );
    const roster = await loadFleetRoster(rosterPath);

    expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
      [
        'MOSAIC_AGENT_NAME=coder0',
        // Reflects the roster's non-default `class: implementer` (A3a).
        'MOSAIC_AGENT_CLASS=implementer',
        'MOSAIC_AGENT_RUNTIME=codex',
        'MOSAIC_AGENT_MODEL=',
        'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
        'MOSAIC_TMUX_SOCKET=mosaic-fleet',
        '',
      ].join('\n'),
    );
  });

  it('emits MOSAIC_AGENT_CLASS=worker for an agent that declares no class', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'coder0', runtime: 'codex' }],
      }),
    );
    const roster = await loadFleetRoster(rosterPath);
    expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
      'MOSAIC_AGENT_CLASS=worker\n',
    );
  });

  it('shell-escapes MOSAIC_AGENT_CLASS so a launcher reads it verbatim', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'coder0', runtime: 'codex', class: 'orchestrator' }],
      }),
    );
    const roster = await loadFleetRoster(rosterPath);
    expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
      'MOSAIC_AGENT_CLASS=orchestrator\n',
    );
  });

  it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
    const generated = [
      'MOSAIC_AGENT_NAME=coder0',
      'MOSAIC_AGENT_RUNTIME=codex',
      'MOSAIC_AGENT_WORKDIR=/srv/new',
      'MOSAIC_TMUX_SOCKET=mosaic-fleet',
      '',
    ].join('\n');
    const existing = [
      'MOSAIC_AGENT_NAME=old-name',
      'MOSAIC_AGENT_RUNTIME=old-runtime',
      'MOSAIC_AGENT_WORKDIR=/srv/old',
      'MOSAIC_TMUX_SOCKET=old-socket',
      'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
      '# site note',
      '',
    ].join('\n');

    expect(mergeAgentEnv(generated, existing)).toBe(
      [
        'MOSAIC_AGENT_NAME=coder0',
        'MOSAIC_AGENT_RUNTIME=codex',
        'MOSAIC_AGENT_WORKDIR=/srv/new',
        'MOSAIC_TMUX_SOCKET=mosaic-fleet',
        'MOSAIC_AGENT_COMMAND=/home/jarvis/.config/mosaic/fleet/canary.sh',
        '# site note',
        '',
      ].join('\n'),
    );
  });

  it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
    const generated = [
      'MOSAIC_AGENT_NAME=coder0',
      'MOSAIC_AGENT_CLASS=orchestrator',
      'MOSAIC_AGENT_RUNTIME=codex',
      '',
    ].join('\n');
    const existing = [
      'MOSAIC_AGENT_NAME=coder0',
      'MOSAIC_AGENT_CLASS=worker',
      'MOSAIC_AGENT_RUNTIME=codex',
      '',
    ].join('\n');

    const merged = mergeAgentEnv(generated, existing);
    // mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
    // exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
    expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
    expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
    expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
  });

  it('rejects unknown roster fields instead of silently defaulting', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.yaml');
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'tmux:',
        '  socketNamee: prod-fleet',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
      ].join('\n'),
    );

    await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
      'Fleet roster tmux has unknown field(s): socketNamee.',
    );
  });

  it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        tmux: { socket_name: 123 },
        defaults: { working_directory: '/srv/mosaic' },
        agents: [{ name: 'canary-pi', runtime: 'pi' }],
      }),
    );

    await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
      'Fleet roster tmux socket_name must be a string.',
    );
  });

  it('rejects wrong-typed agent fields', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.json');
    await writeFile(
      rosterPath,
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'canary-pi', runtime: 42 }],
      }),
    );

    await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
      'Fleet roster agent "canary-pi" runtime must be a string.',
    );
  });

  it('rejects duplicate agent names before install can overwrite env files', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'roster.yaml');
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '  - name: canary-pi',
        '    runtime: codex',
      ].join('\n'),
    );

    await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
      'Fleet roster has duplicate agent name: canary-pi.',
    );
  });

  it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
    const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
    const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
    const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
    const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));

    expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
    expect(localCanary.tmux.socketName).toBe('mosaic-fleet');
    expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
    expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
  });
});

describe('fleet command construction', () => {
  it('builds exact systemd user commands for holder and agent operations', () => {
    expect(buildFleetServiceCommand('status')).toEqual([
      'systemctl',
      '--user',
      'status',
      'mosaic-tmux-holder.service',
    ]);
    expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
      'systemctl',
      '--user',
      'restart',
      'mosaic-agent@coder0.service',
    ]);
  });

  it('builds socket-scoped agent send commands', () => {
    const paths = resolveFleetPaths('/home/test/.config/mosaic');
    expect(
      buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-fleet', 'operator:mosaic-cli'),
    ).toEqual([
      '/home/test/.config/mosaic/tools/tmux/agent-send.sh',
      '-L',
      'mosaic-fleet',
      '-S',
      'operator:mosaic-cli',
      '-s',
      'coder0',
      '-m',
      'hello',
    ]);
  });

  it('runs fleet status through injected runner without touching tmux in tests', async () => {
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: 'ok\n', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner });

    await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);

    expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
  });

  it('verifies liveness with tmux has-session and does not trust systemd active exited', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: 'active (exited)\n', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'verify']);
      expect(calls).toEqual([
        // socket-less roster ⇒ default tmux socket (no -L)
        ['tmux', 'has-session', '-t', '=_holder:0.0'],
        ['tmux', 'has-session', '-t', '=coder0:0.0'],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('writes init output to the explicit roster path', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'custom', 'roster.yaml');
    const frameworkRoot = resolve(process.cwd(), 'framework');
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { frameworkRoot, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--profile',
        'minimal',
        '--write',
      ]);

      const content = await readFile(rosterPath, 'utf8');
      expect(content).toContain('name: canary-pi');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('refuses to overwrite an existing roster unless --force is provided', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'custom', 'roster.yaml');
    await mkdir(dirname(rosterPath), { recursive: true });
    await writeFile(rosterPath, 'site-owned: true\n');
    const frameworkRoot = resolve(process.cwd(), 'framework');
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { frameworkRoot, mosaicHome: home });

    try {
      await expect(
        program.parseAsync([
          'node',
          'mosaic',
          'fleet',
          '--roster',
          rosterPath,
          'init',
          '--profile',
          'minimal',
          '--write',
        ]),
      ).rejects.toThrow('Fleet roster already exists');
      expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');

      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--profile',
        'minimal',
        '--write',
        '--force',
      ]);
      expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('rejects unknown init profiles instead of silently falling back', async () => {
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });

    await expect(
      program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
    ).rejects.toThrow('Unsupported fleet profile');
  });

  it('sets process exitCode when status runner fails', async () => {
    const originalExitCode = process.exitCode;
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
    const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
      expect(process.exitCode).toBe(3);
    } finally {
      process.exitCode = originalExitCode;
      stderrSpy.mockRestore();
    }
  });

  it('loads default fleet/roster.json when roster.yaml is absent', async () => {
    const home = await tempDir();
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.json'),
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'json-canary', runtime: 'pi' }],
      }),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
      expect(calls).toEqual([
        ['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('starts the holder before agents and stops agents before the holder', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
      await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);

      expect(calls).toEqual([
        ['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
        ['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
        ['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
        ['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    // Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
    // (recent timestamp, so it is NOT treated as stale) already held.
    const lockPath = restartLockPath(home);
    await mkdir(dirname(lockPath), { recursive: true });
    await writeFile(lockPath, `4242\n${Date.now()}\n`);

    const events: string[] = [];
    const runner: CommandRunner = async (command, args) => {
      events.push(`run:${args[args.length - 1]}`);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    // The injected sleep stands in for time passing while we wait; the in-flight
    // restart "finishes" (releases its lock) after the first poll.
    let sleeps = 0;
    const sleepFn: SleepFn = async () => {
      sleeps += 1;
      events.push(`sleep:${sleeps}`);
      await rm(lockPath, { force: true });
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);

      // It must have waited at least once before issuing any systemctl restart.
      expect(sleeps).toBeGreaterThan(0);
      const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
      const firstRun = events.findIndex((e) => e.startsWith('run:'));
      expect(firstSleep).toBeGreaterThanOrEqual(0);
      expect(firstRun).toBeGreaterThan(firstSleep);

      // And it still performs the full restart once the lock clears.
      expect(events).toContain('run:mosaic-tmux-holder.service');
      expect(events).toContain('run:mosaic-agent@coder0.service');

      // The lock is released after the restart completes.
      await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('breaks a stale restart lock and proceeds without waiting', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    // A lock left behind by a crashed owner: timestamp older than the stale window.
    const lockPath = restartLockPath(home);
    await mkdir(dirname(lockPath), { recursive: true });
    await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const sleepFn = vi.fn<SleepFn>(async () => {});

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);

      // Stale lock is broken immediately — no waiting.
      expect(sleepFn).not.toHaveBeenCalled();
      expect(calls).toEqual([
        ['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
        ['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
      ]);
      // The stale lock is gone once the restart completes.
      await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('releases the restart lock so a subsequent restart is not blocked', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const sleepFn = vi.fn<SleepFn>(async () => {});

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
      await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);

      // Two sequential restarts both run fully and neither has to wait.
      expect(sleepFn).not.toHaveBeenCalled();
      expect(calls).toEqual([
        ['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
        ['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
        ['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
        ['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('guards the single-agent restart path behind the in-flight restart lock', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    // A full restart is mid-flight (lock held); a single-agent restart re-enters.
    const lockPath = restartLockPath(home);
    await mkdir(dirname(lockPath), { recursive: true });
    await writeFile(lockPath, `4242\n${Date.now()}\n`);

    const events: string[] = [];
    const runner: CommandRunner = async (command, args) => {
      events.push(`run:${args[args.length - 1]}`);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    let sleeps = 0;
    const sleepFn: SleepFn = async () => {
      sleeps += 1;
      events.push(`sleep:${sleeps}`);
      await rm(lockPath, { force: true });
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);

      // The single-agent restart waits for the in-flight restart before acting.
      expect(sleeps).toBeGreaterThan(0);
      const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
      const firstRun = events.findIndex((e) => e.startsWith('run:'));
      expect(firstSleep).toBeGreaterThanOrEqual(0);
      expect(firstRun).toBeGreaterThan(firstSleep);
      // Only the named agent is restarted; the holder is untouched.
      expect(events).toContain('run:mosaic-agent@coder0.service');
      expect(events).not.toContain('run:mosaic-tmux-holder.service');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
    const home = await tempDir();
    const runDir = join(home, 'fleet', 'run');
    await mkdir(runDir, { recursive: true });
    const lockPath = restartLockPath(home);
    const tokenOf = async (): Promise<string> => {
      const raw = await readFile(lockPath, 'utf8');
      return raw.split('\n')[2]?.trim() ?? '';
    };
    const sleepFn = vi.fn<SleepFn>(async () => {});

    // R1 acquires the lock and begins a restart that then hangs.
    const r1 = await acquireRestartLock(home, sleepFn);
    const tokenR1 = await tokenOf();
    expect(tokenR1).not.toBe('');

    // The hung R1 leaves a stale lock: rewrite its timestamp into the past while
    // preserving R1's token — exactly the on-disk state a stuck owner leaves.
    await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);

    // R2 re-enters, sees the stale lock, and atomically takes ownership.
    const r2 = await acquireRestartLock(home, sleepFn);
    const tokenR2 = await tokenOf();
    expect(tokenR2).not.toBe(tokenR1);
    expect(sleepFn).not.toHaveBeenCalled();

    // R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
    // a third restart (R3) could acquire and interleave with R2 still running.
    await r1.release();
    expect(await tokenOf()).toBe(tokenR2);

    // R2 releases cleanly and the lock is gone.
    await r2.release();
    await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });

    await rm(home, { recursive: true, force: true });
  });

  it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
    const home = await tempDir();
    const lockPath = restartLockPath(home);
    await mkdir(dirname(lockPath), { recursive: true });

    // A stale lock left by a crashed owner: every concurrent re-entrant restart
    // will judge it stale and try to break it at the same instant. Breaking must
    // NOT grant ownership — only the atomic re-create may — so exactly one
    // contender can ever hold the lock at a time. (The v2 fix wrote our own token
    // during the break and read it back, so two breakers each saw their own token
    // and BOTH proceeded; this guards that regression.)
    await writeFile(
      lockPath,
      `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
    );

    // Yielding sleep so a waiting contender lets the current owner finish and
    // release before it re-contends, instead of spinning the microtask queue.
    const sleepFn: SleepFn = async () => {
      await new Promise((res) => setTimeout(res, 0));
    };

    let active = 0;
    let maxActive = 0;
    const tokens: string[] = [];
    const tokenOf = async (): Promise<string> => {
      const raw = await readFile(lockPath, 'utf8');
      return raw.split('\n')[2]?.trim() ?? '';
    };

    // One "restart" = acquire the lock, do work in the critical section, release.
    const restartOnce = async (): Promise<void> => {
      const guard = await acquireRestartLock(home, sleepFn);
      active += 1;
      maxActive = Math.max(maxActive, active);
      // Record the token we own while we hold it, then yield to interleave with
      // any other contender that might (wrongly) believe it owns the lock too.
      tokens.push(await tokenOf());
      await new Promise((res) => setTimeout(res, 0));
      active -= 1;
      await guard.release();
    };

    try {
      // Three breakers race the single stale lock simultaneously.
      await Promise.all([restartOnce(), restartOnce(), restartOnce()]);

      // Mutual exclusion held: never two owners at once despite concurrent breaks.
      expect(maxActive).toBe(1);
      // Each acquire owned with its own distinct token — no two ever shared it.
      expect(new Set(tokens).size).toBe(3);
      // The lock is fully released at the end.
      await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
    const home = await tempDir();
    const lockPath = restartLockPath(home);
    await mkdir(dirname(lockPath), { recursive: true });

    // A single stale lock both contenders will judge stale at the same instant.
    // Every transition runs under the registry mutex, so only one may take the
    // lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
    // rather than also taking over. (A content-blind clobber let both believe
    // they owned it — this asserts the mutex-gated CAS takeover instead.)
    await writeFile(
      lockPath,
      `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
    );

    // Barrier the winner holds against until the loser has observed the lock
    // fresh and waited at least once — forcing the exact interleaving where one
    // proceeds while the other waits, deterministically rather than by timing.
    let resolveLoserWaited: () => void = () => {};
    const loserWaited = new Promise<void>((res) => {
      resolveLoserWaited = res;
    });
    let sleeps = 0;
    const sleepFn: SleepFn = async () => {
      sleeps += 1;
      resolveLoserWaited();
      await new Promise((res) => setTimeout(res, 0));
    };

    let active = 0;
    let maxActive = 0;
    const tokens: string[] = [];
    const tokenOf = async (): Promise<string> => {
      const raw = await readFile(lockPath, 'utf8');
      return raw.split('\n')[2]?.trim() ?? '';
    };

    let firstOwner = true;
    const restartOnce = async (): Promise<void> => {
      const guard = await acquireRestartLock(home, sleepFn);
      active += 1;
      maxActive = Math.max(maxActive, active);
      tokens.push(await tokenOf());
      if (firstOwner) {
        // Winner: keep holding the lock until the loser has waited once, so the
        // loser is guaranteed to see a FRESH owner (not the stale one) and back
        // off — proving it could not also take over.
        firstOwner = false;
        await loserWaited;
      } else {
        await new Promise((res) => setTimeout(res, 0));
      }
      active -= 1;
      await guard.release();
    };

    try {
      // Exactly two breakers race the single stale lock.
      await Promise.all([restartOnce(), restartOnce()]);

      // Mutual exclusion: never two owners at once (if both took over the stale
      // lock, this would be 2).
      expect(maxActive).toBe(1);
      // Both eventually owned, each with its own distinct token.
      expect(new Set(tokens).size).toBe(2);
      // The loser observed the winner's fresh lock and waited — it did NOT also
      // take over the stale lock.
      expect(sleeps).toBeGreaterThanOrEqual(1);
      // The lock is fully released at the end.
      await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: coder0',
        '    runtime: codex',
        '  - name: reviewer0',
        '    runtime: pi',
      ].join('\n'),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      if (args.includes('mosaic-agent@coder0.service')) {
        return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
        'Fleet stop completed with 1 failure(s)',
      );
      expect(calls).toEqual([
        ['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
        ['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
        ['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
    const home = await tempDir();
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, {
      mosaicHome: home,
      frameworkRoot: resolve(process.cwd(), 'framework'),
    });

    try {
      await expect(
        program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
      ).rejects.toThrow('install-systemd only supports the default Mosaic home');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it.each(['start', 'stop', 'restart', 'status'] as const)(
    'rejects single-agent %s for agents outside the roster',
    async (action) => {
      const home = await tempDir();
      const rosterPath = join(home, 'fleet', 'roster.yaml');
      await mkdir(join(home, 'fleet'), { recursive: true });
      await writeFile(
        rosterPath,
        ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
          '\n',
        ),
      );
      const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
      const program = new Command();
      program.exitOverride();
      registerFleetCommand(program, { runner, mosaicHome: home });

      try {
        await expect(
          program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
        ).rejects.toThrow('Agent "typo" is not in the fleet roster');
        expect(runner).not.toHaveBeenCalled();
      } finally {
        await rm(home, { recursive: true, force: true });
      }
    },
  );

  it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
    const home = await tempDir();
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.json'),
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'json-agent', runtime: 'pi' }],
      }),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
      expect(calls).toEqual([
        ['tmux', 'has-session', '-t', '=json-agent:0.0'], // socket-less ⇒ no -L
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('passes a deterministic operator source label for agent sends', async () => {
    const home = await tempDir();
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'json-agent', runtime: 'pi' }],
      }),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'json-agent',
        '--message',
        'status check',
      ]);
      expect(calls).toEqual([
        [
          join(home, 'tools', 'tmux', 'agent-send.sh'),
          '-S',
          getDefaultOperatorSourceLabel(),
          '-s',
          'json-agent',
          '-m',
          'status check',
        ],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('allows agent sends to override the source label explicitly', async () => {
    const home = await tempDir();
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      JSON.stringify({
        version: 1,
        transport: 'tmux',
        agents: [{ name: 'coder0', runtime: 'codex' }],
      }),
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'handoff',
        '--source-label',
        'lead:manual',
      ]);
      expect(calls).toEqual([
        [
          join(home, 'tools', 'tmux', 'agent-send.sh'),
          '-S',
          'lead:manual',
          '-s',
          'coder0',
          '-m',
          'handoff',
        ],
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('rejects agent status typos before invoking the runner', async () => {
    const home = await tempDir();
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );
    const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await expect(
        program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
      ).rejects.toThrow('Agent "typo" is not in the fleet roster');
      expect(runner).not.toHaveBeenCalled();
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('keeps fleet framework assets in the published package file list', async () => {
    const packageJson = JSON.parse(
      await readFile(resolve(process.cwd(), 'package.json'), 'utf8'),
    ) as {
      files?: string[];
    };

    expect(packageJson.files).toEqual(expect.arrayContaining(['dist', 'framework']));
  });
});

// ---------------------------------------------------------------------------
// Phase-2 observability — unit tests (FR-1, FR-3, FR-5, FR-6)
// ---------------------------------------------------------------------------

describe('fleet ps — command construction', () => {
  it('builds exact systemd show command for an agent unit', () => {
    expect(buildSystemdShowCommand('canary-pi')).toEqual([
      'systemctl',
      '--user',
      'show',
      'mosaic-agent@canary-pi.service',
      '-p',
      'ActiveState',
      '-p',
      'SubState',
      '-p',
      'UnitFileState',
    ]);
  });

  it('builds exact tmux list-panes command with the correct format string', () => {
    expect(buildTmuxListPanesCommand('canary-pi', 'mosaic-fleet')).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'list-panes',
      '-t',
      '=canary-pi:0.0',
      '-F',
      '#{pane_pid} #{pane_current_command} #{pane_dead} #{pane_activity} #{window_activity} #{session_activity}',
    ]);
  });

  it('uses the default tmux socket (no -L) when socket is omitted from list-panes', () => {
    const cmd = buildTmuxListPanesCommand('canary-pi');
    expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
    expect(cmd[0]).toBe('tmux');
    expect(cmd[1]).toBe('list-panes');
  });

  it('derives heartbeat path under ~/.config/mosaic/fleet/run/', () => {
    const home = '/home/test/.config/mosaic';
    expect(heartbeatPath('canary-pi', home)).toBe(
      '/home/test/.config/mosaic/fleet/run/canary-pi.hb',
    );
  });
});

describe('fleet ps — heartbeat parsing', () => {
  const NOW = 1_700_000_000_000; // fixed epoch ms for deterministic tests

  it('parses a healthy heartbeat file', () => {
    const ts = new Date(NOW - 10_000).toISOString(); // 10s ago — within 3×15s = 45s
    const content = `ts=${ts}\npid=12345\nstatus=ok\n`;
    const hb = parseHeartbeat(content, NOW);
    expect(hb.health).toBe('healthy');
    expect(hb.pid).toBe(12345);
    expect(hb.status).toBe('ok');
    expect(hb.ageMs).toBe(10_000);
    // No model= line in a legacy/sidecar heartbeat → model is null.
    expect(hb.model).toBeNull();
  });

  it('parses a self-reported model id from a native heartbeat (model= line)', () => {
    const ts = new Date(NOW - 5_000).toISOString();
    const content = `ts=${ts}\npid=42\nstatus=busy\nmodel=openai-codex/gpt-5.5:high\n`;
    const hb = parseHeartbeat(content, NOW);
    expect(hb.model).toBe('openai-codex/gpt-5.5:high');
    expect(hb.status).toBe('busy');
    expect(hb.health).toBe('healthy');
  });

  it('reports stale when heartbeat is older than 3×interval', () => {
    const ts = new Date(NOW - 60_000).toISOString(); // 60s ago > 45s threshold
    const content = `ts=${ts}\npid=99\nstatus=busy\n`;
    const hb = parseHeartbeat(content, NOW);
    expect(hb.health).toBe('stale');
    expect(hb.status).toBe('busy');
  });

  it('reports unknown when heartbeat file is missing (null input)', () => {
    const hb = parseHeartbeat(null, NOW);
    expect(hb.health).toBe('unknown');
    expect(hb.ts).toBeNull();
    expect(hb.pid).toBeNull();
    expect(hb.ageMs).toBeNull();
  });

  it('tolerates missing fields in heartbeat file', () => {
    const hb = parseHeartbeat('ts=not-a-date\n', NOW);
    expect(hb.health).toBe('unknown');
    expect(hb.ts).toBeNull();
  });

  it('honors MOSAIC_HEARTBEAT_INTERVAL for the freshness threshold', () => {
    const prev = process.env.MOSAIC_HEARTBEAT_INTERVAL;
    try {
      // A 60s-old beat is STALE at the default 15s interval (3x15 = 45s)...
      const ts = new Date(NOW - 60_000).toISOString();
      const content = `ts=${ts}\npid=1\nstatus=ok\n`;
      delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
      expect(parseHeartbeat(content, NOW).health).toBe('stale');
      // ...but HEALTHY when the operator widened the interval to 30s (3x30 = 90s).
      process.env.MOSAIC_HEARTBEAT_INTERVAL = '30';
      expect(parseHeartbeat(content, NOW).health).toBe('healthy');
    } finally {
      if (prev === undefined) delete process.env.MOSAIC_HEARTBEAT_INTERVAL;
      else process.env.MOSAIC_HEARTBEAT_INTERVAL = prev;
    }
  });
});

describe('fleet ps — readiness thresholds', () => {
  const savedIdle = process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD;

  afterEach(() => {
    if (savedIdle === undefined) delete process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD;
    else process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD = savedIdle;
  });

  it('uses the default activity threshold when env is unset', () => {
    delete process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD;

    expect(idleThresholdSeconds()).toBe(HEARTBEAT_IDLE_THRESHOLD_SECONDS);
  });

  it('honors a positive integer activity threshold from env', () => {
    process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD = '120';

    expect(idleThresholdSeconds()).toBe(120);
  });

  it('falls back to the default for invalid activity thresholds', () => {
    process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD = '0';

    expect(idleThresholdSeconds()).toBe(HEARTBEAT_IDLE_THRESHOLD_SECONDS);
  });
});

describe('fleet ps — readiness classification', () => {
  const thresholds = { idleThresholdSeconds: 300 };

  it('reports dead when the pane is not alive', () => {
    expect(
      classifyReadiness(
        { paneAlive: false, hbHealth: 'healthy', hbStatus: 'busy', idleSeconds: 0 },
        thresholds,
      ),
    ).toBe('dead');
  });

  it('reports unknown when heartbeat health is unknown', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'unknown', hbStatus: null, idleSeconds: 0 },
        thresholds,
      ),
    ).toBe('unknown');
  });

  it('reports stale when heartbeat health is stale', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'stale', hbStatus: 'busy', idleSeconds: 1_000 },
        thresholds,
      ),
    ).toBe('stale');
  });

  it('reports working when heartbeat status is busy, even after the activity threshold', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'healthy', hbStatus: 'busy', idleSeconds: 2_000 },
        thresholds,
      ),
    ).toBe('working');
  });

  it('reports working when pane idle seconds are null', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok', idleSeconds: null },
        thresholds,
      ),
    ).toBe('working');
  });

  it('reports working when pane idle seconds are undefined', () => {
    expect(
      classifyReadiness({ paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok' }, thresholds),
    ).toBe('working');
  });

  it('reports working when pane idle seconds are non-finite', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok', idleSeconds: Number.NaN },
        thresholds,
      ),
    ).toBe('working');
  });

  it('reports available at the activity threshold boundary', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok', idleSeconds: 300 },
        thresholds,
      ),
    ).toBe('available');
  });

  it('reports working below the activity threshold', () => {
    expect(
      classifyReadiness(
        { paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok', idleSeconds: 299 },
        thresholds,
      ),
    ).toBe('working');
  });

  it('reports very long idle as available, not stuck', () => {
    const readiness = classifyReadiness(
      { paneAlive: true, hbHealth: 'healthy', hbStatus: 'ok', idleSeconds: 100_000 },
      thresholds,
    );

    expect(readiness).toBe('available');
    expect(readiness).not.toBe('stuck');
  });
});

describe('fleet ps — systemd show parsing', () => {
  it('parses ActiveState, SubState, UnitFileState from systemctl show output', () => {
    const output = 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n';
    expect(parseSystemdShow(output)).toEqual({
      ActiveState: 'active',
      SubState: 'running',
      UnitFileState: 'enabled',
    });
  });

  it('defaults missing keys to "unknown"', () => {
    const result = parseSystemdShow('ActiveState=inactive\n');
    expect(result.SubState).toBe('unknown');
    expect(result.UnitFileState).toBe('unknown');
  });
});

describe('fleet ps — tmux list-panes parsing', () => {
  const NOW_MS = 1_700_000_000_000;

  it('uses pane_activity when present', () => {
    const paneActivityEpoch = Math.floor((NOW_MS - 30_000) / 1000); // 30s ago
    const windowActivityEpoch = Math.floor((NOW_MS - 60_000) / 1000); // 60s ago
    const sessionActivityEpoch = Math.floor((NOW_MS - 90_000) / 1000); // 90s ago
    const output = `12345 claude 0 ${paneActivityEpoch} ${windowActivityEpoch} ${sessionActivityEpoch}\n`;
    const result = parseTmuxListPanes(output, NOW_MS);
    expect(result.pid).toBe(12345);
    expect(result.command).toBe('claude');
    expect(result.dead).toBe(false);
    expect(result.idleSeconds).toBe(30);
  });

  it('uses window_activity when pane_activity is empty', () => {
    const windowActivityEpoch = Math.floor((NOW_MS - 45_000) / 1000); // 45s ago
    const sessionActivityEpoch = Math.floor((NOW_MS - 90_000) / 1000); // 90s ago
    const output = `12345 node 0  ${windowActivityEpoch} ${sessionActivityEpoch}\n`;
    expect(output).toContain('0  '); // empty pane_activity preserves index alignment
    const result = parseTmuxListPanes(output, NOW_MS);
    expect(result.pid).toBe(12345);
    expect(result.command).toBe('node');
    expect(result.dead).toBe(false);
    expect(result.idleSeconds).toBe(45);
  });

  it('uses session_activity when pane_activity and window_activity are empty', () => {
    const sessionActivityEpoch = Math.floor((NOW_MS - 75_000) / 1000); // 75s ago
    const output = `12345 node 0   ${sessionActivityEpoch}\n`;
    const result = parseTmuxListPanes(output, NOW_MS);
    expect(result.idleSeconds).toBe(75);
  });

  it('reports null idleSeconds when all activity sources are empty', () => {
    const output = '12345 node 0   \n';
    const result = parseTmuxListPanes(output, NOW_MS);
    expect(result.idleSeconds).toBeNull();
  });

  it('computes exact idle seconds from now minus epoch seconds', () => {
    const activityEpoch = 1_699_999_877;
    const result = parseTmuxListPanes(`12345 claude 0 ${activityEpoch} 0 0\n`, NOW_MS);
    expect(result.idleSeconds).toBe(123);
  });

  it('clamps future activity epochs to 0 idle seconds', () => {
    const futureActivityEpoch = Math.floor((NOW_MS + 30_000) / 1000);
    const result = parseTmuxListPanes(`12345 claude 0 ${futureActivityEpoch} 0 0\n`, NOW_MS);
    expect(result.idleSeconds).toBe(0);
  });

  it('reports dead pane when pane_dead=1', () => {
    const output = `0 bash 1 0 0 0\n`;
    const result = parseTmuxListPanes(output, NOW_MS);
    expect(result.dead).toBe(true);
  });

  it('returns nulls for empty pane output', () => {
    const result = parseTmuxListPanes('', NOW_MS);
    expect(result.pid).toBeNull();
    expect(result.command).toBeNull();
    expect(result.dead).toBe(true);
    expect(result.idleSeconds).toBeNull();
  });
});

describe('fleet ps — drift detection', () => {
  it('flags drift when roster says pi but pane runs python3', () => {
    expect(detectDrift('pi', 'python3')).toBe(true);
  });

  it('flags drift when roster says claude but pane runs dogfood-agent.py', () => {
    expect(detectDrift('claude', 'dogfood-agent.py')).toBe(true);
  });

  it('does NOT flag drift when pane command matches the roster runtime', () => {
    expect(detectDrift('claude', 'claude')).toBe(false);
    expect(detectDrift('codex', 'codex')).toBe(false);
    expect(detectDrift('pi', 'pi')).toBe(false);
    expect(detectDrift('opencode', 'opencode')).toBe(false);
  });

  it('does NOT flag drift for unknown/custom runtimes (no canonical mapping)', () => {
    expect(detectDrift('custom-runtime', 'anything')).toBe(false);
  });

  it('does NOT flag drift when pane command is null (pane dead)', () => {
    expect(detectDrift('pi', null)).toBe(false);
  });

  it('does NOT flag drift when pane=node for wrapped pi agent (mosaic yolo pi)', () => {
    expect(detectDrift('pi', 'node')).toBe(false);
  });

  it('does NOT flag drift when pane=node for wrapped codex agent (mosaic yolo codex)', () => {
    expect(detectDrift('codex', 'node')).toBe(false);
  });

  it('flags drift when pane=python3 for pi runtime (canary-pi dogfood regression guard)', () => {
    expect(detectDrift('pi', 'python3')).toBe(true);
  });

  it('does NOT flag drift when pane=python3 for dogfood runtime', () => {
    expect(detectDrift('dogfood', 'python3')).toBe(false);
  });

  it('flags drift for unknown pane command on known runtime', () => {
    expect(detectDrift('claude', 'bash')).toBe(true);
  });

  it('RUNTIME_ACCEPTABLE_COMMANDS is exported and contains expected entries', () => {
    expect(RUNTIME_ACCEPTABLE_COMMANDS['pi']).toContain('node');
    expect(RUNTIME_ACCEPTABLE_COMMANDS['pi']).not.toContain('python3');
    expect(RUNTIME_ACCEPTABLE_COMMANDS['dogfood']).toContain('python3');
    expect(RUNTIME_ACCEPTABLE_COMMANDS['codex']).toContain('node');
  });
});

describe('fleet-polish bundle — boot-survival symmetry', () => {
  async function rosterHome(agents: string): Promise<string> {
    const home = await tempDir();
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
    return home;
  }

  it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
    expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
      'systemctl',
      '--user',
      'disable',
      'mosaic-agent@coder0.service',
    ]);
  });

  it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
    const home = await rosterHome(
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: orchestrator',
        '    runtime: pi',
        '    class: orchestrator',
        '  - name: coder0',
        '    runtime: codex',
        '    class: worker',
      ].join('\n') + '\n',
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });
    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
      expect(calls).toContainEqual([
        'systemctl',
        '--user',
        'disable',
        'mosaic-agent@coder0.service',
      ]);
      // stop must still happen too
      expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('fleet add ENABLES the new agent unit for boot-survival', async () => {
    const home = await rosterHome(
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ) + '\n',
    );
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });
    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        'add',
        'coder1',
        '--runtime',
        'codex',
        '--class',
        'worker',
        '--no-start',
      ]);
      expect(calls).toContainEqual([
        'systemctl',
        '--user',
        'enable',
        'mosaic-agent@coder1.service',
      ]);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('fleet init --write enforces the two-agent floor (1 orchestrator + >=1 enhancer)', async () => {
    // The general profile must yield exactly one orchestrator AND at least one
    // enhancer; the guarantee is enforced (not just warned). Happy path writes cleanly.
    const home = await tempDir();
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, {
      runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
      mosaicHome: home,
    });
    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        'init',
        '--profile',
        'general',
        '--write',
      ]);
      const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
      const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
      const enhancers = (written.match(/class:\s*enhancer/g) ?? []).length;
      expect(orchestrators).toBe(1);
      expect(enhancers).toBeGreaterThanOrEqual(1);
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });
});

describe('fleet install — auto-enable units for boot-survival', () => {
  it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
    expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
      'systemctl',
      '--user',
      'enable',
      'mosaic-tmux-holder.service',
    ]);
    expect(buildEnableLingerCommand('testuser')).toEqual(['loginctl', 'enable-linger', 'testuser']);
  });

  it('enables holder and each agent unit via injected runner after install', async () => {
    const minimalRoster: FleetRoster = {
      version: 1,
      transport: 'tmux',
      tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
      defaults: { workingDirectory: '~/src' },
      runtimes: { codex: { resetCommand: '/clear' } },
      agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
    };

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    await enableFleetUnits(runner, minimalRoster, {});

    expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-tmux-holder.service']);
    expect(calls).toContainEqual(['systemctl', '--user', 'enable', 'mosaic-agent@coder0.service']);
  });

  it('install still succeeds when systemctl enable returns non-zero (non-fatal)', async () => {
    const minimalRoster: FleetRoster = {
      version: 1,
      transport: 'tmux',
      tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
      defaults: { workingDirectory: '~/src' },
      runtimes: { codex: { resetCommand: '/clear' } },
      agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
    };

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      // Simulate systemctl enable failure
      if (command === 'systemctl' && args.includes('enable')) {
        return { stdout: '', stderr: 'Unit not found', exitCode: 1 };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    // Must NOT reject/throw even when enable calls fail
    await expect(enableFleetUnits(runner, minimalRoster, {})).resolves.toBeUndefined();

    // The enable attempt must have been made
    expect(calls.some((c) => c.includes('enable'))).toBe(true);
  });

  it('--no-enable skips all systemctl enable and loginctl linger calls', async () => {
    const minimalRoster: FleetRoster = {
      version: 1,
      transport: 'tmux',
      tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
      defaults: { workingDirectory: '~/src' },
      runtimes: { codex: { resetCommand: '/clear' } },
      agents: [{ name: 'coder0', runtime: 'codex', className: 'worker' }],
    };

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    await enableFleetUnits(runner, minimalRoster, { enable: false });

    // No calls should include 'enable'
    expect(calls.every((c) => !c.includes('enable'))).toBe(true);
    // No loginctl calls at all
    expect(calls.every((c) => c[0] !== 'loginctl')).toBe(true);
  });
});

describe('fleet ps — tenant and host', () => {
  it('returns tenant_id and host as non-empty strings', () => {
    const { tenant_id, host } = getDefaultTenantAndHost();
    expect(typeof tenant_id).toBe('string');
    expect(tenant_id.length).toBeGreaterThan(0);
    expect(typeof host).toBe('string');
    expect(host.length).toBeGreaterThan(0);
  });
});

describe('fleet ps — aliases and JSON output shape (FR-6)', () => {
  it('shows an agent alias in table output and falls back to name when absent', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: interaction',
        '    alias: Friday',
        '    runtime: pi',
        '  - name: worker0',
        '    runtime: codex',
      ].join('\n'),
    );

    const runner: CommandRunner = async (command, args) => {
      const fullArgs = [command, ...args].join(' ');
      if (fullArgs.includes('list-sessions')) {
        return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 };
      }
      return {
        stdout: fullArgs.includes('systemctl')
          ? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'
          : '12345 pi 0 0 0 0\n',
        stderr: '',
        exitCode: 0,
      };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }

    const output = lines.join('\n');
    expect(output).toContain('Friday');
    expect(output).toContain('worker0');
  });

  it('produces --json records including tenant_id and host for each agent', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '    class: canary',
      ].join('\n'),
    );

    const nowMs = Date.now();
    const activityEpoch = Math.floor((nowMs - 20_000) / 1000);

    const runner: CommandRunner = async (command, args) => {
      const fullArgs = [command, ...args].join(' ');
      if (fullArgs.includes('systemctl') && fullArgs.includes('show')) {
        return {
          stdout: 'ActiveState=active\nSubState=running\nUnitFileState=disabled\n',
          stderr: '',
          exitCode: 0,
        };
      }
      if (fullArgs.includes('list-panes')) {
        return {
          stdout: `12345 python3 0 ${activityEpoch}\n`,
          stderr: '',
          exitCode: 0,
        };
      }
      if (fullArgs.includes('list-sessions')) {
        // Only the roster agent session on the socket (no unmanaged sessions)
        return { stdout: 'canary-pi\n', stderr: '', exitCode: 0 };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }

    const json = JSON.parse(lines.join('')) as AgentPsRow[];
    expect(Array.isArray(json)).toBe(true);
    expect(json).toHaveLength(1);

    const row = json[0]!;
    // FR-6: tenant_id and host must be present
    expect(typeof row.tenant_id).toBe('string');
    expect(row.tenant_id.length).toBeGreaterThan(0);
    expect(typeof row.host).toBe('string');
    expect(row.host.length).toBeGreaterThan(0);

    // drift: roster says pi, pane runs python3 → drift flag
    expect(row.driftFlag).toBe(true);
    // boot-enable warning: active + disabled
    expect(row.bootEnableWarning).toBe(true);

    // heartbeat missing → unknown readiness preserves existing display semantics
    expect(row.heartbeat.health).toBe('unknown');
    expect(row.readiness).toBe('unknown');

    expect(row.name).toBe('canary-pi');
    expect(row.runtime).toBe('pi');
    expect(row.systemdActive).toBe('active');
    expect(row.systemdEnabled).toBe('disabled');

    // managed/source fields for roster agents
    expect(row.managed).toBe(true);
    expect(row.source).toBe('roster');
  });
});

describe('fleet ps — command sequences issued', () => {
  it('issues systemd show + tmux list-panes per agent, then list-sessions for socket discovery', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      if ([command, ...args].join(' ').includes('list-sessions')) {
        // Only the roster agent — no unmanaged sessions
        return { stdout: 'coder0\n', stderr: '', exitCode: 0 };
      }
      return {
        stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
        stderr: '',
        exitCode: 0,
      };
    };

    // suppress console.log for table output
    const origLog = console.log;
    console.log = () => {};

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
      expect(calls).toEqual([
        buildSystemdShowCommand('coder0'),
        // socket-less roster ⇒ default socket (no -L)
        buildTmuxListPanesCommand('coder0'),
        buildTmuxListSessionsCommand(),
      ]);
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }
  });
});

describe('fleet ps — readiness table output', () => {
  it('renders available in HB column without idle/stuck alarm flags', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    const runDir = join(home, 'fleet', 'run');
    await mkdir(runDir, { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: working-agent',
        '    runtime: pi',
        '  - name: available-agent',
        '    runtime: pi',
      ].join('\n'),
    );

    const nowMs = 1_700_000_000_000;
    const workingActivityEpoch = Math.floor((nowMs - 2_000) / 1000);
    const availableActivityEpoch = Math.floor((nowMs - 40_000) / 1000);
    const hbTs = new Date(nowMs - 1_000).toISOString();
    await writeFile(join(runDir, 'working-agent.hb'), `ts=${hbTs}\npid=111\nstatus=ok\n`);
    await writeFile(join(runDir, 'available-agent.hb'), `ts=${hbTs}\npid=222\nstatus=ok\n`);

    const savedIdle = process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD;
    process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD = '5';

    const dateNow = vi.spyOn(Date, 'now').mockReturnValue(nowMs);
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('list-sessions')) {
        return { stdout: 'working-agent\navailable-agent\n', stderr: '', exitCode: 0 };
      }
      if (full.includes('=working-agent:0.0')) {
        return { stdout: `111 pi 0 ${workingActivityEpoch}\n`, stderr: '', exitCode: 0 };
      }
      if (full.includes('=available-agent:0.0')) {
        return { stdout: `222 pi 0 ${availableActivityEpoch}\n`, stderr: '', exitCode: 0 };
      }
      if (full.includes('systemctl') && full.includes('show')) {
        return {
          stdout: 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n',
          stderr: '',
          exitCode: 0,
        };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
    } finally {
      console.log = origLog;
      dateNow.mockRestore();
      if (savedIdle === undefined) delete process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD;
      else process.env.MOSAIC_HEARTBEAT_IDLE_THRESHOLD = savedIdle;
      await rm(home, { recursive: true, force: true });
    }

    const workingLine = lines.find((line) => line.includes('working-agent'));
    const availableLine = lines.find((line) => line.includes('available-agent'));
    expect(workingLine).toBeDefined();
    expect(workingLine).toContain('1s/working');
    expect(availableLine).toBeDefined();
    expect(availableLine).toContain('1s/available');
    expect(availableLine).not.toMatch(/\bIDLE\b/);
    expect(availableLine).not.toMatch(/\bSTUCK\b/);
  });
});

describe('buildTmuxListSessionsCommand', () => {
  it('builds exact list-sessions command with session_name format', () => {
    expect(buildTmuxListSessionsCommand('mosaic-fleet')).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'list-sessions',
      '-F',
      '#{session_name}',
    ]);
  });

  it('uses the default tmux socket (no -L) when socket is omitted', () => {
    const cmd = buildTmuxListSessionsCommand();
    expect(cmd).not.toContain('-L');
    expect(cmd).toEqual(['tmux', 'list-sessions', '-F', '#{session_name}']);
  });
});

describe('parseTmuxListSessions', () => {
  it('splits newline-delimited session names', () => {
    expect(parseTmuxListSessions('canary-pi\n_holder\nsome-adhoc\n')).toEqual([
      'canary-pi',
      '_holder',
      'some-adhoc',
    ]);
  });

  it('returns empty array for blank output', () => {
    expect(parseTmuxListSessions('')).toEqual([]);
    expect(parseTmuxListSessions('  \n  \n')).toEqual([]);
  });

  it('trims whitespace from each line', () => {
    expect(parseTmuxListSessions('  canary-pi  \n  some-adhoc  \n')).toEqual([
      'canary-pi',
      'some-adhoc',
    ]);
  });
});

describe('fleet ps — unmanaged socket sessions', () => {
  it('includes unmanaged session row flagged UNMANAGED and excludes _holder', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '    class: canary',
      ].join('\n'),
    );

    const nowMs = Date.now();
    const activityEpoch = Math.floor((nowMs - 10_000) / 1000);

    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('list-sessions')) {
        // Socket has: canary-pi (roster), _holder (excluded), some-adhoc (unmanaged)
        return { stdout: 'canary-pi\n_holder\nsome-adhoc\n', stderr: '', exitCode: 0 };
      }
      if (full.includes('list-panes')) {
        return { stdout: `99999 bash 0 ${activityEpoch}\n`, stderr: '', exitCode: 0 };
      }
      if (full.includes('systemctl') && full.includes('show')) {
        return {
          stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
          stderr: '',
          exitCode: 0,
        };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']);
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }

    const json = JSON.parse(lines.join('')) as AgentPsRow[];
    expect(Array.isArray(json)).toBe(true);

    // Should have 2 rows: canary-pi (roster) + some-adhoc (unmanaged); _holder excluded
    expect(json).toHaveLength(2);

    // Roster agent comes first
    const rosterRow = json[0]!;
    expect(rosterRow.name).toBe('canary-pi');
    expect(rosterRow.managed).toBe(true);
    expect(rosterRow.source).toBe('roster');

    // Unmanaged session comes second
    const unmanagedRow = json[1]!;
    expect(unmanagedRow.name).toBe('some-adhoc');
    expect(unmanagedRow.managed).toBe(false);
    expect(unmanagedRow.source).toBe('socket');
    expect(unmanagedRow.runtime).toBe('unknown');

    // _holder must not appear
    expect(json.map((r) => r.name)).not.toContain('_holder');

    // tenant_id and host must be present on unmanaged rows
    expect(typeof unmanagedRow.tenant_id).toBe('string');
    expect(unmanagedRow.tenant_id.length).toBeGreaterThan(0);
    expect(typeof unmanagedRow.host).toBe('string');
    expect(unmanagedRow.host.length).toBeGreaterThan(0);

    // driftFlag must be false for unmanaged (no roster runtime to compare)
    expect(unmanagedRow.driftFlag).toBe(false);
    expect(unmanagedRow.readiness).toBe('unknown');
  });

  it('shows UNMANAGED flag in table output for unmanaged sessions', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '    class: canary',
      ].join('\n'),
    );

    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('list-sessions')) {
        return { stdout: 'canary-pi\nsome-adhoc\n', stderr: '', exitCode: 0 };
      }
      if (full.includes('list-panes')) {
        return { stdout: '0 bash 1 0\n', stderr: '', exitCode: 0 };
      }
      if (full.includes('systemctl') && full.includes('show')) {
        return {
          stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=unknown\n',
          stderr: '',
          exitCode: 0,
        };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }

    const tableOutput = lines.join('\n');
    // some-adhoc row must appear with UNMANAGED flag
    expect(tableOutput).toMatch(/some-adhoc/);
    expect(tableOutput).toMatch(/UNMANAGED/);
    // canary-pi roster row must not have UNMANAGED
    const rosterLine = lines.find((l) => l.includes('canary-pi'));
    expect(rosterLine).toBeDefined();
    expect(rosterLine).not.toMatch(/UNMANAGED/);
  });

  it('gracefully shows only roster rows when list-sessions fails (socket missing)', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(home, 'fleet', 'roster.yaml');
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      rosterPath,
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: canary-pi',
        '    runtime: pi',
        '    class: canary',
      ].join('\n'),
    );

    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('list-sessions')) {
        // Simulate socket missing
        return { stdout: '', stderr: 'no server running on /tmp/...', exitCode: 1 };
      }
      if (full.includes('list-panes')) {
        return { stdout: '12345 pi 0 0\n', stderr: '', exitCode: 0 };
      }
      if (full.includes('systemctl') && full.includes('show')) {
        return {
          stdout: 'ActiveState=inactive\nSubState=dead\nUnitFileState=enabled\n',
          stderr: '',
          exitCode: 0,
        };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const lines: string[] = [];
    const origLog = console.log;
    console.log = (msg: string) => {
      lines.push(msg);
    };

    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      // Must not throw
      await expect(
        program.parseAsync(['node', 'mosaic', 'fleet', 'ps', '--json']),
      ).resolves.toBeDefined();
    } finally {
      console.log = origLog;
      await rm(home, { recursive: true, force: true });
    }

    const json = JSON.parse(lines.join('')) as AgentPsRow[];
    // Only roster agent visible; no crash
    expect(json).toHaveLength(1);
    expect(json[0]!.name).toBe('canary-pi');
    expect(json[0]!.managed).toBe(true);
  });
});

describe('agent watch', () => {
  it('builds exact grouped-viewer creation command', () => {
    expect(
      buildAgentWatchCreateViewerCommand('canary-pi', 'canary-pi-watch-123', 'mosaic-fleet'),
    ).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'new-session',
      '-d',
      '-t',
      '=canary-pi',
      '-s',
      'canary-pi-watch-123',
    ]);
  });

  it('builds exact viewer attach command (read-only)', () => {
    expect(buildAgentWatchAttachCommand('canary-pi-watch-123', 'mosaic-fleet')).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'attach',
      '-r',
      '-t',
      'canary-pi-watch-123',
    ]);
  });

  it('builds exact viewer kill command', () => {
    expect(buildAgentWatchKillViewerCommand('canary-pi-watch-123', 'mosaic-fleet')).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'kill-session',
      '-t',
      'canary-pi-watch-123',
    ]);
  });

  it('buildAgentWatchCommand (deprecated) uses the default tmux socket (no -L) when socket is omitted', () => {
    const cmd = buildAgentWatchCommand('canary-pi');
    expect(cmd).not.toContain('-L'); // omitted socket ⇒ default socket
    expect(cmd[0]).toBe('tmux');
    expect(cmd).toContain('-r');
  });

  it('dispatch: creates grouped viewer session (runner) then attaches -r to viewer session (interactiveRunner), NOT a bare attach to the agent session', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const capturingCalls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      capturingCalls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const interactiveCalls: string[][] = [];
    const interactiveRunner: InteractiveRunner = async (command, args) => {
      interactiveCalls.push([command, ...args]);
      return 0;
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, interactiveRunner, mosaicHome: home });

    try {
      await program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'coder0']);

      // The capturing runner must be used for grouped-session creation and cleanup.
      // It must NOT be used for the interactive attach.
      expect(capturingCalls).toHaveLength(2); // new-session + kill-session
      expect(capturingCalls[0]).toEqual(
        expect.arrayContaining(['new-session', '-d', '-t', '=coder0']),
      );
      // The new-session command must include a viewer session name derived from agent name.
      expect(capturingCalls[0]!.join(' ')).toMatch(/coder0-watch-\d+/);
      // Kill-session must target the same viewer session, not the agent session.
      expect(capturingCalls[1]).toEqual(expect.arrayContaining(['kill-session', '-t']));
      expect(capturingCalls[1]!.join(' ')).toMatch(/coder0-watch-\d+/);
      // The agent session itself must NOT be the attach target.
      expect(capturingCalls[1]!.join(' ')).not.toContain('=coder0');

      // The interactiveRunner must attach -r to the VIEWER session, not the agent session.
      expect(interactiveCalls).toHaveLength(1);
      expect(interactiveCalls[0]).toEqual(expect.arrayContaining(['attach', '-r', '-t']));
      // Target must be the viewer session name (not "=coder0").
      const attachTarget = interactiveCalls[0]![interactiveCalls[0]!.indexOf('-t') + 1]!;
      expect(attachTarget).toMatch(/coder0-watch-\d+/);
      expect(attachTarget).not.toBe('=coder0');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('rejects watch for agents not in the roster', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await expect(
        program.parseAsync(['node', 'mosaic', 'agent', 'watch', 'typo']),
      ).rejects.toThrow('Agent "typo" is not in the fleet roster');
      expect(runner).not.toHaveBeenCalled();
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });
});

describe('agent send --verify', () => {
  it('builds exact verify capture-pane command', () => {
    expect(buildAgentVerifyAcceptedCommand('canary-pi', 'mosaic-fleet', 5)).toEqual([
      'tmux',
      '-L',
      'mosaic-fleet',
      'capture-pane',
      '-t',
      '=canary-pi:0.0',
      '-p',
      '-S',
      '-5',
    ]);
  });

  it('isSendAccepted: returns "accepted" for normal response output', () => {
    expect(isSendAccepted('Some response text\nAnother line\n')).toBe('accepted');
  });

  it('isSendAccepted: returns "draft" when last line starts with "> " (draft pattern)', () => {
    expect(isSendAccepted('> my unsent message')).toBe('draft');
  });

  it('isSendAccepted: returns "unverifiable" for blank/empty pane (full-screen TUI case)', () => {
    expect(isSendAccepted('')).toBe('unverifiable');
    expect(isSendAccepted('   \n  \n')).toBe('unverifiable');
  });

  // ---------------------------------------------------------------------------
  // classifySendResult — BEFORE/AFTER pane-diff classifier (regression suite)
  // ---------------------------------------------------------------------------

  describe('classifySendResult (BEFORE/AFTER pane-diff classifier)', () => {
    it('returns "accepted" when AFTER differs from BEFORE and AFTER has no draft line', () => {
      const before = 'Old content from prior interaction\n';
      const after = 'Old content from prior interaction\nAgent response: task complete.\n';
      expect(classifySendResult(before, after)).toBe('accepted');
    });

    it('returns "draft" when AFTER differs from BEFORE and AFTER ends in a draft line', () => {
      const before = 'Previous output\n';
      const after = 'Previous output\n> unsent message\n';
      expect(classifySendResult(before, after)).toBe('draft');
    });

    it('returns "unverifiable" when AFTER is blank/empty (full-screen TUI blank render)', () => {
      const before = 'Some previous content\n';
      expect(classifySendResult(before, '')).toBe('unverifiable');
      expect(classifySendResult(before, '   \n  \n')).toBe('unverifiable');
    });

    it('returns "unverifiable" when AFTER == BEFORE (stale/wedged pane — no change after send)', () => {
      const staleContent = 'Old non-empty content that never changed\n';
      expect(classifySendResult(staleContent, staleContent)).toBe('unverifiable');
    });

    it('returns "unverifiable" when both BEFORE and AFTER are blank (both blank => no change)', () => {
      expect(classifySendResult('', '')).toBe('unverifiable');
    });

    it('returns "accepted" when BEFORE is blank and AFTER has non-draft content (pane woke up)', () => {
      expect(classifySendResult('', 'Agent is now responding.\n')).toBe('accepted');
    });
  });

  it('issues BEFORE-capture then send then AFTER-capture (3 calls) when --verify is passed and pane changes on first poll', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    // no-op sleep so the test doesn't take VERIFY_DEFAULT_TIMEOUT_MS
    const sleepFn: SleepFn = async () => {};

    let callIndex = 0;
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      const idx = callIndex++;
      if ([command, ...args].join(' ').includes('agent-send.sh')) {
        return { stdout: '', stderr: '', exitCode: 0 };
      }
      // BEFORE capture (idx 0): return old content; first AFTER capture (idx 2): return new content
      const stdout = idx === 0 ? 'Old pane content\n' : 'New response from agent\n';
      return { stdout, stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello world',
        '--verify',
      ]);

      // 3 calls: BEFORE-capture, send, AFTER-capture (pane changed on first poll → accepted immediately)
      expect(calls).toHaveLength(3);
      // socket-less roster ⇒ default socket (no -L)
      expect(calls[0]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
      expect(calls[1]![0]).toContain('agent-send.sh');
      expect(calls[2]).toEqual(buildAgentVerifyAcceptedCommand('coder0', '', 5));
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('does NOT issue capture-pane verify when --verify is not passed', async () => {
    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello world',
      ]);
      // Only 1 call: agent-send.sh (no capture-pane)
      expect(calls).toHaveLength(1);
      expect(calls[0]![0]).toContain('agent-send.sh');
    } finally {
      await rm(home, { recursive: true, force: true });
    }
  });

  it('send --verify: AFTER==BEFORE (stale/wedged pane) sets process.exitCode=1 (unverifiable) after timeout', async () => {
    const originalExitCode = process.exitCode;
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    // Count sleep calls to verify polling happens; use no-op sleep for speed.
    let sleepCalls = 0;
    const sleepFn: SleepFn = async () => {
      sleepCalls++;
    };

    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      // BEFORE and AFTER are identical non-empty stale content — simulates a wedged pane
      return { stdout: 'Stale old content that never changed\n', stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      // Use a short verify-timeout (one poll interval worth) so the loop exits quickly.
      // With a no-op sleep, Date.now() won't advance, so we only get 1 poll before
      // deadline is exceeded. Use --verify-timeout=0 to force single-poll timeout.
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
        '--verify-timeout',
        '0',
      ]);
      expect(process.exitCode).toBe(1);
      // Must mention "no pane change" to distinguish from blank-capture case
      expect(stderrMessages.join('')).toMatch(/no pane change after send/i);
      // At least one poll should have happened
      expect(sleepCalls).toBeGreaterThanOrEqual(1);
    } finally {
      process.exitCode = originalExitCode;
      stderrSpy.mockRestore();
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: blank AFTER capture sets process.exitCode=1 (unverifiable, fails closed) after timeout', async () => {
    const originalExitCode = process.exitCode;
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const sleepFn: SleepFn = async () => {};

    let captureCallCount = 0;
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      captureCallCount++;
      // BEFORE: some content; AFTER: blank (full-screen TUI renders blank after send)
      const stdout = captureCallCount === 1 ? 'Previous content\n' : '';
      return { stdout, stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
        '--verify-timeout',
        '0',
      ]);
      expect(process.exitCode).toBe(1);
      expect(stderrMessages.join('')).toMatch(/could not verify delivery/i);
    } finally {
      process.exitCode = originalExitCode;
      stderrSpy.mockRestore();
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: AFTER differs from BEFORE with draft line sets process.exitCode=1 (returns immediately on first poll)', async () => {
    const originalExitCode = process.exitCode;
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    let sleepCalls = 0;
    const sleepFn: SleepFn = async () => {
      sleepCalls++;
    };

    let captureCallCount = 0;
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      captureCallCount++;
      // BEFORE: old content; AFTER: message appeared but ended as a draft line
      const stdout = captureCallCount === 1 ? 'Previous output\n' : '> unsent message\n';
      return { stdout, stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
      ]);
      expect(process.exitCode).toBe(1);
      expect(stderrMessages.join('')).toMatch(/unsubmitted draft/i);
      // Draft is returned on the first poll — only one sleep call expected
      expect(sleepCalls).toBe(1);
    } finally {
      process.exitCode = originalExitCode;
      stderrSpy.mockRestore();
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: AFTER differs from BEFORE with real response content sets exitCode=0 (accepted on first poll)', async () => {
    const originalExitCode = process.exitCode;

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    const sleepFn: SleepFn = async () => {};

    let captureCallCount = 0;
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      captureCallCount++;
      // BEFORE: old content; AFTER: new response content (pane changed)
      const stdout =
        captureCallCount === 1
          ? 'Old pane content\n'
          : 'Old pane content\nAgent response: task completed.\n';
      return { stdout, stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
      ]);
      // exitCode should remain unchanged (not set to 1)
      expect(process.exitCode).toBe(originalExitCode);
    } finally {
      process.exitCode = originalExitCode;
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  // ---------------------------------------------------------------------------
  // Bounded-polling tests (FR-5 enhancement)
  // ---------------------------------------------------------------------------

  it('send --verify: accepted on 2nd poll (pane slow to respond) => exit 0', async () => {
    // Simulates a slow/loaded TUI that only updates on the 2nd poll.
    const originalExitCode = process.exitCode;

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    let sleepCalls = 0;
    const sleepFn: SleepFn = async (ms) => {
      sleepCalls++;
      expect(ms).toBe(VERIFY_POLL_INTERVAL_MS);
    };

    let captureCallCount = 0;
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      captureCallCount++;
      if (captureCallCount === 1) {
        // BEFORE: old content
        return { stdout: 'Old pane content\n', stderr: '', exitCode: 0 };
      } else if (captureCallCount === 2) {
        // 1st AFTER poll: still unchanged (slow TUI) => unverifiable
        return { stdout: 'Old pane content\n', stderr: '', exitCode: 0 };
      } else {
        // 2nd AFTER poll: pane changed => accepted
        return { stdout: 'Old pane content\nAgent accepted task.\n', stderr: '', exitCode: 0 };
      }
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      // Give enough timeout for at least 2 polls (2 × VERIFY_POLL_INTERVAL_MS).
      // With no-op sleep, Date.now() will advance between polls so we use a generous timeout.
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
        '--verify-timeout',
        String(VERIFY_POLL_INTERVAL_MS * 3),
      ]);
      // Accepted on 2nd poll — exitCode should remain unchanged
      expect(process.exitCode).toBe(originalExitCode);
      // At least 2 sleep calls (one per poll until accepted)
      expect(sleepCalls).toBeGreaterThanOrEqual(2);
    } finally {
      process.exitCode = originalExitCode;
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: accepted on 3rd poll => exit 0', async () => {
    const originalExitCode = process.exitCode;

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    let sleepCalls = 0;
    // Real-time advancing sleep needed so deadline check works correctly.
    // Use a tiny delay (1ms) so the test runs fast but Date.now() still advances.
    const sleepFn: SleepFn = async () => {
      sleepCalls++;
      await new Promise<void>((r) => setTimeout(r, 1));
    };

    let captureCallCount = 0;
    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      captureCallCount++;
      if (captureCallCount === 1) {
        return { stdout: 'Old content\n', stderr: '', exitCode: 0 };
      } else if (captureCallCount <= 3) {
        // Polls 1 and 2: unchanged
        return { stdout: 'Old content\n', stderr: '', exitCode: 0 };
      } else {
        // Poll 3: accepted
        return { stdout: 'Old content\nDone!\n', stderr: '', exitCode: 0 };
      }
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      // Long enough timeout to allow at least 3 polls with 1ms sleeps
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
        '--verify-timeout',
        '500',
      ]);
      expect(process.exitCode).toBe(originalExitCode);
      expect(sleepCalls).toBeGreaterThanOrEqual(3);
    } finally {
      process.exitCode = originalExitCode;
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: pane stays unchanged until timeout => exit 1 (unverifiable)', async () => {
    const originalExitCode = process.exitCode;
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });

    const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    await mkdir(join(home, 'fleet'), { recursive: true });
    await writeFile(
      join(home, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', '  - name: coder0', '    runtime: codex'].join(
        '\n',
      ),
    );

    let sleepCalls = 0;
    const sleepFn: SleepFn = async () => {
      sleepCalls++;
    };

    const runner: CommandRunner = async (command, args) => {
      const full = [command, ...args].join(' ');
      if (full.includes('agent-send.sh')) return { stdout: '', stderr: '', exitCode: 0 };
      // Always the same content — pane never changes
      return { stdout: 'Unchanged pane content\n', stderr: '', exitCode: 0 };
    };

    const program = new Command();
    program.exitOverride();
    registerAgentCommand(program, { runner, sleepFn, mosaicHome: home });

    try {
      // timeout=0 means deadline is immediately exceeded after the first poll
      await program.parseAsync([
        'node',
        'mosaic',
        'agent',
        'send',
        'coder0',
        '--message',
        'hello',
        '--verify',
        '--verify-timeout',
        '0',
      ]);
      expect(process.exitCode).toBe(1);
      expect(stderrMessages.join('')).toMatch(/no pane change after send/i);
      expect(sleepCalls).toBeGreaterThanOrEqual(1);
    } finally {
      process.exitCode = originalExitCode;
      stderrSpy.mockRestore();
      await rm(home, { recursive: true, force: true });
    }
  }, 10_000);

  it('send --verify: VERIFY_POLL_INTERVAL_MS and VERIFY_DEFAULT_TIMEOUT_MS are exported constants', () => {
    expect(typeof VERIFY_POLL_INTERVAL_MS).toBe('number');
    expect(VERIFY_POLL_INTERVAL_MS).toBe(400);
    expect(typeof VERIFY_DEFAULT_TIMEOUT_MS).toBe('number');
    expect(VERIFY_DEFAULT_TIMEOUT_MS).toBe(6_000);
  });
});

// ---------------------------------------------------------------------------
// Fleet Phase F1: config-type presets + AI-free init wizard
// ---------------------------------------------------------------------------

describe('fleet preset rosters', () => {
  const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');

  it.each(['general', 'coding', 'research', 'hybrid'] as FleetProfile[])(
    '%s preset: loads via loadFleetRoster and has exactly one orchestrator',
    async (preset) => {
      const rosterPath = join(examplesDir, `${preset}.yaml`);
      const roster = await loadFleetRoster(rosterPath);
      expect(countOrchestrators(roster)).toBe(1);
      expect(roster.agents.find((a) => a.name === 'orchestrator')).toBeDefined();
    },
  );

  it('general preset: orchestrator + enhancer + one generalist worker', async () => {
    const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
    expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
    expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
    expect(roster.agents.find((a) => a.name === 'enhancer')?.className).toBe('enhancer');
    expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
  });

  it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
    const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
    expect(roster.agents.map((a) => a.name)).toEqual([
      'orchestrator',
      'enhancer',
      'coder0',
      'coder1',
      'reviewer',
    ]);
  });

  it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
    const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
    expect(roster.agents.map((a) => a.name)).toEqual([
      'orchestrator',
      'enhancer',
      'researcher0',
      'researcher1',
      'analyst',
    ]);
  });

  it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
    const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
    expect(roster.agents.map((a) => a.name)).toEqual([
      'orchestrator',
      'enhancer',
      'coder0',
      'researcher0',
      'reviewer',
    ]);
  });

  it('every non-minimal preset carries an enhancer (two-agent floor)', async () => {
    for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
      const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
      expect(countOrchestrators(roster)).toBe(1);
      expect(countEnhancers(roster)).toBeGreaterThanOrEqual(1);
      expect(roster.agents.find((a) => a.className === 'enhancer')?.runtime).toBe('claude');
    }
  });

  it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
    for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
      const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
      // Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
      const workers = roster.agents.filter(
        (a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
      );
      for (const worker of workers) {
        expect(worker.runtime).toBe('pi');
        expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
      }
    }
  });

  it('orchestrator in new presets uses claude runtime with persistent_persona', async () => {
    for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
      const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
      const orch = roster.agents.find((a) => a.name === 'orchestrator');
      expect(orch?.runtime).toBe('claude');
      expect(orch?.persistentPersona).toBe(true);
    }
  });

  it('new presets are sanitized: no operator identity tokens', async () => {
    for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
      const text = await readFile(join(examplesDir, `${preset}.yaml`), 'utf8');
      expect(text).not.toMatch(/jarvis|jason|woltje/i);
      // working_directory must not reference ~/src or /home
      expect(text).not.toMatch(/~\/src|\/home\//);
    }
  });
});

describe('parseInitProfile', () => {
  it('accepts all six fleet profiles', () => {
    expect(parseInitProfile('general')).toBe('general');
    expect(parseInitProfile('coding')).toBe('coding');
    expect(parseInitProfile('research')).toBe('research');
    expect(parseInitProfile('hybrid')).toBe('hybrid');
    expect(parseInitProfile('minimal')).toBe('minimal');
    expect(parseInitProfile('local-canary')).toBe('local-canary');
  });

  it('rejects unknown profiles with a message listing all valid names', () => {
    expect(() => parseInitProfile('typo')).toThrow('Unsupported fleet profile');
    expect(() => parseInitProfile('typo')).toThrow('general');
    expect(() => parseInitProfile('typo')).toThrow('coding');
  });

  it('FLEET_PROFILES contains all six valid profile names', () => {
    expect(FLEET_PROFILES).toContain('general');
    expect(FLEET_PROFILES).toContain('coding');
    expect(FLEET_PROFILES).toContain('research');
    expect(FLEET_PROFILES).toContain('hybrid');
    expect(FLEET_PROFILES).toContain('minimal');
    expect(FLEET_PROFILES).toContain('local-canary');
  });
});

describe('resolvePresetFilename', () => {
  it.each(FLEET_PROFILES)('maps %s to %s.yaml', (profile) => {
    expect(resolvePresetFilename(profile)).toBe(`${profile}.yaml`);
  });
});

// ---------------------------------------------------------------------------
// Fleet Phase F5: orchestrator-mutable fleet — pure helper tests (R9)
// ---------------------------------------------------------------------------

describe('fleet add/remove — pure helpers', () => {
  const baseRoster: FleetRoster = {
    version: 1,
    transport: 'tmux',
    tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
    defaults: { workingDirectory: '~/src' },
    runtimes: { codex: { resetCommand: '/clear' } },
    agents: [
      { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
      { name: 'coder0', runtime: 'codex', className: 'worker' },
    ],
  };

  it('addAgentToRoster appends a new agent and returns a new roster object', () => {
    const newAgent = { name: 'reviewer0', runtime: 'pi', className: 'worker' };
    const updated = addAgentToRoster(baseRoster, newAgent);
    expect(updated.agents).toHaveLength(3);
    expect(updated.agents[2]).toEqual(newAgent);
    // immutable — original unchanged
    expect(baseRoster.agents).toHaveLength(2);
    expect(updated).not.toBe(baseRoster);
  });

  it('addAgentToRoster throws on duplicate name', () => {
    expect(() =>
      addAgentToRoster(baseRoster, { name: 'coder0', runtime: 'claude', className: 'worker' }),
    ).toThrow('Agent "coder0" already exists in the fleet roster.');
  });

  it('addAgentToRoster throws on invalid name (invalid characters)', () => {
    expect(() =>
      addAgentToRoster(baseRoster, { name: 'bad name!', runtime: 'claude', className: 'worker' }),
    ).toThrow('Invalid fleet agent name');
  });

  it('addAgentToRoster throws on empty name', () => {
    expect(() =>
      addAgentToRoster(baseRoster, { name: '', runtime: 'claude', className: 'worker' }),
    ).toThrow('Invalid fleet agent name');
  });

  it('removeAgentFromRoster removes the agent and returns new roster', () => {
    const updated = removeAgentFromRoster(baseRoster, 'coder0');
    expect(updated.agents).toHaveLength(1);
    expect(updated.agents[0]!.name).toBe('orchestrator');
    // immutable
    expect(baseRoster.agents).toHaveLength(2);
    expect(updated).not.toBe(baseRoster);
  });

  it('removeAgentFromRoster throws when agent not found', () => {
    expect(() => removeAgentFromRoster(baseRoster, 'nonexistent')).toThrow(
      'Agent "nonexistent" is not in the fleet roster.',
    );
  });

  it('removeAgentFromRoster throws when removing the sole orchestrator (guard)', () => {
    const rosterWithOnlyOrch: FleetRoster = {
      ...baseRoster,
      agents: [{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' }],
    };
    expect(() => removeAgentFromRoster(rosterWithOnlyOrch, 'orchestrator')).toThrow(
      'sole orchestrator',
    );
  });

  it('removeAgentFromRoster allows removing an orchestrator when another remains', () => {
    const rosterWithTwoOrchs: FleetRoster = {
      ...baseRoster,
      agents: [
        { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
        { name: 'orchestrator2', runtime: 'claude', className: 'orchestrator' },
        { name: 'coder0', runtime: 'codex', className: 'worker' },
      ],
    };
    const updated = removeAgentFromRoster(rosterWithTwoOrchs, 'orchestrator');
    expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
  });

  it('countEnhancers counts enhancer-class agents (two-agent floor)', () => {
    const roster: FleetRoster = {
      ...baseRoster,
      agents: [
        { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
        { name: 'enhancer', runtime: 'claude', className: 'enhancer' },
        { name: 'coder0', runtime: 'codex', className: 'worker' },
      ],
    };
    expect(countEnhancers(roster)).toBe(1);
    expect(countEnhancers(baseRoster)).toBe(0);
  });

  it('removeAgentFromRoster throws when removing the sole enhancer (two-agent floor)', () => {
    const roster: FleetRoster = {
      ...baseRoster,
      agents: [
        { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
        { name: 'enhancer', runtime: 'claude', className: 'enhancer' },
      ],
    };
    expect(() => removeAgentFromRoster(roster, 'enhancer')).toThrow('sole enhancer');
  });

  it('removeAgentFromRoster allows removing an enhancer when another remains', () => {
    const roster: FleetRoster = {
      ...baseRoster,
      agents: [
        { name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
        { name: 'enhancer', runtime: 'claude', className: 'enhancer' },
        { name: 'enhancer2', runtime: 'claude', className: 'enhancer' },
      ],
    };
    const updated = removeAgentFromRoster(roster, 'enhancer');
    expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer2']);
  });

  it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
    const yaml = serializeRosterToYaml(baseRoster);
    expect(typeof yaml).toBe('string');
    expect(yaml).toContain('version: 1');
    expect(yaml).toContain('name: orchestrator');
    expect(yaml).toContain('name: coder0');

    // Round-trip: write to disk and re-load
    const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(dir, 'roster.yaml');
    try {
      await writeFile(rosterPath, yaml);
      const loaded = await loadFleetRoster(rosterPath);
      expect(loaded.agents.map((a) => a.name)).toEqual(['orchestrator', 'coder0']);
      expect(loaded.tmux.socketName).toBe('mosaic-fleet');
      expect(loaded.agents[0]!.className).toBe('orchestrator');
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });

  it('serializeRosterToYaml round-trips optional fields (modelHint, workingDirectory)', async () => {
    const rosterWithOptionals: FleetRoster = {
      ...baseRoster,
      agents: [
        {
          name: 'orchestrator',
          runtime: 'claude',
          className: 'orchestrator',
          modelHint: 'claude-3-5-sonnet',
          workingDirectory: '/tmp/work',
          persistentPersona: true,
          resetBetweenTasks: false,
        },
      ],
    };
    const yaml = serializeRosterToYaml(rosterWithOptionals);
    expect(yaml).toContain('model_hint: claude-3-5-sonnet');
    expect(yaml).toContain('working_directory: /tmp/work');
    expect(yaml).toContain('persistent_persona: true');

    const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
    const rosterPath = join(dir, 'roster.yaml');
    try {
      await writeFile(rosterPath, yaml);
      const loaded = await loadFleetRoster(rosterPath);
      expect(loaded.agents[0]!.modelHint).toBe('claude-3-5-sonnet');
      expect(loaded.agents[0]!.workingDirectory).toBe('/tmp/work');
      expect(loaded.agents[0]!.persistentPersona).toBe(true);
    } finally {
      await rm(dir, { recursive: true, force: true });
    }
  });
});

// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet add command tests
// ---------------------------------------------------------------------------

describe('fleet add command', () => {
  let home: string;

  afterEach(async () => {
    if (home) {
      await rm(home, { recursive: true, force: true });
    }
  });

  async function makeHome(agents = ['orchestrator']): Promise<string> {
    const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-add-'));
    await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
    const agentLines = agents.map((name) => {
      const cls = name === 'orchestrator' ? 'orchestrator' : 'worker';
      return `  - name: ${name}\n    runtime: claude\n    class: ${cls}`;
    });
    await writeFile(
      join(dir, 'fleet', 'roster.yaml'),
      ['version: 1', 'transport: tmux', 'agents:', ...agentLines].join('\n'),
    );
    return dir;
  }

  it('appends agent to roster file and writes env file', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      'add',
      'coder0',
      '--runtime',
      'codex',
      '--class',
      'worker',
    ]);

    const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
    expect(roster.agents.map((a) => a.name)).toContain('coder0');

    const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
    expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
    expect(envContent).toContain('MOSAIC_AGENT_RUNTIME=codex');
  });

  it('--no-start skips the start command', async () => {
    home = await makeHome();
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      'add',
      'coder0',
      '--runtime',
      'codex',
      '--class',
      'worker',
      '--no-start',
    ]);

    // No start command should have been issued
    const startCalls = calls.filter((c) => c.includes('start'));
    expect(startCalls).toHaveLength(0);
  });

  it('without --no-start, issues start command for the new agent', async () => {
    home = await makeHome();
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      'add',
      'coder0',
      '--runtime',
      'codex',
      '--class',
      'worker',
    ]);

    expect(calls).toContainEqual(['systemctl', '--user', 'start', 'mosaic-agent@coder0.service']);
  });

  it('throws when adding a duplicate agent name', async () => {
    home = await makeHome(['orchestrator', 'coder0']);
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await expect(
      program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        'add',
        'coder0',
        '--runtime',
        'codex',
        '--class',
        'worker',
      ]),
    ).rejects.toThrow('already exists');
  });

  it('throws when runtime is invalid', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await expect(
      program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        'add',
        'coder0',
        '--runtime',
        'notaruntime',
        '--class',
        'worker',
      ]),
    ).rejects.toThrow('Invalid runtime');
  });

  it('accepts optional --model and --working-dir options', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      'add',
      'coder0',
      '--runtime',
      'claude',
      '--class',
      'worker',
      '--model',
      'claude-sonnet',
      '--working-dir',
      '/tmp/work',
    ]);

    const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
    const agent = roster.agents.find((a) => a.name === 'coder0');
    expect(agent?.modelHint).toBe('claude-sonnet');
    expect(agent?.workingDirectory).toBe('/tmp/work');
  });
});

// ---------------------------------------------------------------------------
// Fleet Phase F5: fleet remove command tests
// ---------------------------------------------------------------------------

describe('fleet remove command', () => {
  let home: string;

  afterEach(async () => {
    if (home) {
      await rm(home, { recursive: true, force: true });
    }
  });

  async function makeHome(): Promise<string> {
    const dir = await mkdtemp(join(tmpdir(), 'mosaic-fleet-remove-'));
    await mkdir(join(dir, 'fleet', 'agents'), { recursive: true });
    await mkdir(join(dir, 'fleet', 'run'), { recursive: true });
    await writeFile(
      join(dir, 'fleet', 'roster.yaml'),
      [
        'version: 1',
        'transport: tmux',
        'agents:',
        '  - name: orchestrator',
        '    runtime: claude',
        '    class: orchestrator',
        '  - name: coder0',
        '    runtime: codex',
        '    class: worker',
      ].join('\n'),
    );
    // Create env and heartbeat files for coder0
    await writeFile(join(dir, 'fleet', 'agents', 'coder0.env'), 'MOSAIC_AGENT_NAME=coder0\n');
    await writeFile(join(dir, 'fleet', 'run', 'coder0.hb'), 'ts=2026-01-01T00:00:00.000Z\n');
    return dir;
  }

  it('removes agent from roster and writes back', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);

    const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
    expect(roster.agents.map((a) => a.name)).not.toContain('coder0');
    expect(roster.agents.map((a) => a.name)).toContain('orchestrator');
  });

  it('stop is called before roster write (stop is the first runner call)', async () => {
    home = await makeHome();
    const calls: string[][] = [];
    const runner: CommandRunner = async (command, args) => {
      calls.push([command, ...args]);
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);

    expect(calls[0]).toEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
  });

  it('stop failure is non-fatal — warns but still removes from roster', async () => {
    home = await makeHome();
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });

    const runner: CommandRunner = async (command, args) => {
      if (args.includes('stop')) {
        return { stdout: '', stderr: 'unit not found', exitCode: 5 };
      }
      return { stdout: '', stderr: '', exitCode: 0 };
    };
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    try {
      // Must not reject
      await expect(
        program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']),
      ).resolves.toBeDefined();

      // Agent should be removed from roster despite stop failure
      const roster = await loadFleetRoster(join(home, 'fleet', 'roster.yaml'));
      expect(roster.agents.map((a) => a.name)).not.toContain('coder0');

      // Warning must have been emitted
      expect(stderrMessages.join('')).toMatch(/Warning/);
    } finally {
      stderrSpy.mockRestore();
    }
  });

  it('--keep-files skips env file deletion', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0', '--keep-files']);

    // Env file should still exist
    const envContent = await readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8');
    expect(envContent).toContain('MOSAIC_AGENT_NAME=coder0');
  });

  it('env file is removed by default (no --keep-files)', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);

    await expect(readFile(join(home, 'fleet', 'agents', 'coder0.env'), 'utf8')).rejects.toThrow();
  });

  it('removing the sole orchestrator throws with a clear error about the guard', async () => {
    home = await makeHome();
    const runner: CommandRunner = async () => ({ stdout: '', stderr: '', exitCode: 0 });
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { runner, mosaicHome: home });

    // First remove the worker so only the orchestrator remains
    await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);

    // Now attempt to remove the sole orchestrator
    await expect(
      program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'orchestrator']),
    ).rejects.toThrow('sole orchestrator');
  });
});

describe('fleet init wizard', () => {
  let cleanup: string | undefined;

  afterEach(async () => {
    if (cleanup) {
      await rm(cleanup, { recursive: true, force: true });
      cleanup = undefined;
    }
  });

  it('defaults to general when stdin is not a TTY and no --profile is given', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
    const frameworkRoot = resolve(process.cwd(), 'framework');
    const stderrMessages: string[] = [];
    const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((msg) => {
      stderrMessages.push(String(msg));
      return true;
    });
    const program = new Command();
    program.exitOverride();
    // isStdinTTY: false simulates non-interactive environment
    registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--write',
      ]);
      const content = await readFile(rosterPath, 'utf8');
      // Should have written the general preset
      expect(content).toContain('name: orchestrator');
      expect(content).toContain('name: generalist');
      // Stderr should explain the fallback
      expect(stderrMessages.join('')).toMatch(/defaulting to fleet profile "general"/);
    } finally {
      stderrSpy.mockRestore();
    }
  });

  it('uses --profile to select preset without wizard (non-TTY path)', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
    const frameworkRoot = resolve(process.cwd(), 'framework');
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });

    try {
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--profile',
        'coding',
        '--write',
      ]);
      const content = await readFile(rosterPath, 'utf8');
      expect(content).toContain('name: coder0');
      expect(content).toContain('name: reviewer');
    } finally {
      // cleanup handled by afterEach
    }
  });

  it('written roster has exactly one orchestrator agent (countOrchestrators validation)', async () => {
    cleanup = await tempDir();
    const frameworkRoot = resolve(process.cwd(), 'framework');
    for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
      const rosterPath = join(cleanup, `${preset}-roster.yaml`);
      const program = new Command();
      program.exitOverride();
      registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });
      await program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--profile',
        preset,
        '--write',
      ]);
      const roster = await loadFleetRoster(rosterPath);
      expect(countOrchestrators(roster)).toBe(1);
    }
  });

  it('re-init with --write and existing roster requires --force (R8 idempotency)', async () => {
    cleanup = await tempDir();
    const rosterPath = join(cleanup, 'fleet', 'roster.yaml');
    const frameworkRoot = resolve(process.cwd(), 'framework');
    const program = new Command();
    program.exitOverride();
    registerFleetCommand(program, { frameworkRoot, mosaicHome: cleanup, isStdinTTY: false });

    // First write
    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      '--roster',
      rosterPath,
      'init',
      '--profile',
      'general',
      '--write',
    ]);

    // Second write without --force must fail
    await expect(
      program.parseAsync([
        'node',
        'mosaic',
        'fleet',
        '--roster',
        rosterPath,
        'init',
        '--profile',
        'general',
        '--write',
      ]),
    ).rejects.toThrow('Fleet roster already exists');

    // With --force must succeed
    await program.parseAsync([
      'node',
      'mosaic',
      'fleet',
      '--roster',
      rosterPath,
      'init',
      '--profile',
      'coding',
      '--write',
      '--force',
    ]);
    const content = await readFile(rosterPath, 'utf8');
    expect(content).toContain('name: coder0');
  });
});

describe('fleet ps — heartbeat path resolution', () => {
  const savedRunDir = process.env.MOSAIC_HEARTBEAT_RUN_DIR;
  const savedHome = process.env.MOSAIC_HOME;
  afterEach(() => {
    if (savedRunDir === undefined) delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
    else process.env.MOSAIC_HEARTBEAT_RUN_DIR = savedRunDir;
    if (savedHome === undefined) delete process.env.MOSAIC_HOME;
    else process.env.MOSAIC_HOME = savedHome;
  });

  it('honors MOSAIC_HEARTBEAT_RUN_DIR (matches the writer sidecar override)', () => {
    process.env.MOSAIC_HEARTBEAT_RUN_DIR = '/run/hb';
    expect(heartbeatPath('agent-x', '/any/home')).toBe(join('/run/hb', 'agent-x.hb'));
  });

  it('honors MOSAIC_HOME when no explicit mosaicHome is given', () => {
    delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
    process.env.MOSAIC_HOME = '/custom/mhome';
    expect(heartbeatPath('agent-y')).toBe(join('/custom/mhome', 'fleet', 'run', 'agent-y.hb'));
  });

  it('falls back to <mosaicHome>/fleet/run by default', () => {
    delete process.env.MOSAIC_HEARTBEAT_RUN_DIR;
    delete process.env.MOSAIC_HOME;
    expect(heartbeatPath('agent-z', '/home/u/.config/mosaic')).toBe(
      join('/home/u/.config/mosaic', 'fleet', 'run', 'agent-z.hb'),
    );
  });
});
