Ctrl K
ComunidadGuías…

test/server.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { app, server, startup, sessions, killSession } from '../server.js';
import { pendingMessages } from '../server/messages.js';
import { countInFlightByRepo, selectDispatchableJobs } from '../lib/jobs.js';
import { hashToken, WS_UNAUTHORIZED } from '../server/auth.js';
import { addJob, deleteJob, moveJob, allJobs, updateSettings } from '../server/jobs.js';
import { config, orphans, codenamePool } from '../server/state.js';
import RawWebSocket from 'ws';
import { tmpdir } from 'os';
import { mkdirSync, mkdtempSync, existsSync, writeFileSync, rmSync, realpathSync, readFileSync, lstatSync } from 'fs';
import { join, basename } from 'path';
import { execFileSync } from 'child_process';
import { WebSocketServer } from 'ws';
import { setupWebSocket, ACTIVITY_MS, BOOT_ID } from '../server/ws.js';
import { STORE_FILE, writeStoreState } from '../server/skill-store.js';
import { removeTempDir } from './temp-dir.js';

// The server offers compression, and inflating is async: a compressed socket
// hears the connect-time burst after its test has attached listeners, which
// these tests were written never to see. They speak plain frames; one test
// below checks that a browser-like client does get compression.
class WebSocket extends RawWebSocket {
  constructor(url, opts) { super(url, { perMessageDeflate: false, ...opts }); }
}

const PORT = 17007; // Use non-default port to avoid conflicts
let baseUrl;
let wsUrl;

beforeAll(async () => {
  process.env.PORT = String(PORT);
  // Override the port before startup
  server.listen(PORT, '127.0.0.1');
  await new Promise(r => server.once('listening', r));
  baseUrl = `http://127.0.0.1:${PORT}`;
  wsUrl = `ws://127.0.0.1:${PORT}`;
});

afterAll(async () => {
  // Kill any sessions created during tests
  for (const [id] of sessions) {
    const session = sessions.get(id);
    if (session) {
      clearInterval(session.stateCheckInterval);
      clearTimeout(session.scanTimer);
      try { session.pty.kill(); } catch {}
      sessions.delete(id);
    }
  }
  await new Promise(r => server.close(r));
});

// --- Cross-origin check (HTTP + WS integration) ---

describe('cross-origin origin check', () => {
  // Only the Origin header is under test: an empty folder, not tmpdir(),
  // whose listing grows with every test run (seconds once it holds 100k+).
  const browsed = mkdtempSync(join(tmpdir(), 'a007-origin-'));

  it('rejects an HTTP request from a disallowed origin with 403', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${browsed}`, {
      headers: { Origin: 'http://evil.example.com' },
    });
    expect(res.status).toBe(403);
    const data = await res.json();
    expect(data.error).toMatch(/cross-origin/i);
  });

  it('allows an HTTP request from a localhost origin', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${browsed}`, {
      headers: { Origin: 'http://localhost:3000' },
    });
    expect(res.status).toBe(200);
  });

  it('rejects a WebSocket handshake from a disallowed origin', async () => {
    const result = await new Promise((resolve) => {
      const ws = new WebSocket(wsUrl, { origin: 'http://evil.example.com' });
      ws.on('open', () => { ws.close(); resolve('open'); });
      ws.on('error', () => resolve('rejected'));
    });
    expect(result).toBe('rejected');
  });

  it('accepts a WebSocket handshake from a localhost origin', async () => {
    const ws = await new Promise((resolve, reject) => {
      const s = new WebSocket(wsUrl, { origin: 'http://localhost:3000' });
      s.on('open', () => resolve(s));
      s.on('error', reject);
    });
    expect(ws.readyState).toBe(WebSocket.OPEN);
    ws.close();
  });
});

// --- /api/browse endpoint ---

describe('/api/browse', () => {
  it('should return directory listing for valid path', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`);
    expect(res.status).toBe(200);
    const data = await res.json();
    // Path may differ due to symlink resolution (e.g. /tmp → /private/tmp on macOS)
    expect(data.path).toBeTruthy();
    expect(Array.isArray(data.entries)).toBe(true);
  });

  it('should return 400 for non-existent path', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=/nonexistent/path/xyz123`);
    expect(res.status).toBe(400);
    const data = await res.json();
    expect(data.error).toMatch(/does not exist/i);
  });

  it('should return 400 for a file path (not directory)', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${join(tmpdir(), '..')}/etc/hosts`);
    // This may or may not exist depending on platform
    const data = await res.json();
    if (res.status === 200) {
      // If it resolved to a dir somehow, that's fine
    } else {
      expect([400, 500]).toContain(res.status);
    }
  });

  it('should default to home directory when no path provided', async () => {
    const res = await fetch(`${baseUrl}/api/browse`);
    expect(res.status).toBe(200);
    const data = await res.json();
    expect(data.path).toBeTruthy();
    expect(data.entries).toBeDefined();
  });

  it('should filter hidden directories unless showHidden=1', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${process.env.HOME}`);
    expect(res.status).toBe(200);
    const data = await res.json();
    const hasHidden = data.entries.some(e => e.name.startsWith('.'));
    expect(hasHidden).toBe(false);
  });

  it('should show hidden directories when showHidden=1', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${process.env.HOME}&showHidden=1`);
    expect(res.status).toBe(200);
    const data = await res.json();
    const hasHidden = data.entries.some(e => e.name.startsWith('.'));
    expect(hasHidden).toBe(true);
  });
});

// --- Job attachments ---

describe('/api/jobs/:id/attachments/:name', () => {
  let job;
  beforeAll(() => {
    ({ job } = addJob({
      title: 'Shot', repoPath: tmpdir(),
      attachments: [{ name: 'shot.png', data: Buffer.from('png!').toString('base64') }, { name: '.env', data: Buffer.from('K=1').toString('base64') }],
    }, () => {}));
  });
  afterAll(async () => { await deleteJob(job.id, () => {}); });

  it('serves the file sandboxed, sniff-proof, and without a referrer', async () => {
    const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/shot.png`);
    expect(res.status).toBe(200);
    expect(res.headers.get('content-security-policy')).toBe('sandbox');
    expect(res.headers.get('x-content-type-options')).toBe('nosniff');
    expect(res.headers.get('referrer-policy')).toBe('no-referrer');
    expect(await res.text()).toBe('png!');
  });

  it('serves a dotfile too', async () => {
    const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/.env`);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe('K=1');
  });

  it('404s for a name the card does not have', async () => {
    const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/other.png`);
    expect(res.status).toBe(404);
  });

  it('404s when the file is gone from disk', async () => {
    rmSync(job.attachments[0].path, { force: true });
    const res = await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/shot.png`);
    expect(res.status).toBe(404);
    expect((await res.json()).error).toMatch(/missing on disk/);
  });

  // The one call that connects the form to the disk: job-create and
  // job-update forward `attachments`, and the broadcast carries the result.
  it('carries attachments over the WebSocket on create and update', async () => {
    const ws = await new Promise((resolve, reject) => {
      const s = new WebSocket(wsUrl);
      s.on('open', () => resolve(s));
      s.on('error', reject);
    });
    const next = (pred) => new Promise((resolve) => {
      const h = (d) => { const m = JSON.parse(d.toString()); if (pred(m)) { ws.removeListener('message', h); resolve(m); } };
      ws.on('message', h);
    });
    let list = next(m => m.type === 'jobs-list' && m.jobs.some(j => j.title === 'Wire shot'));
    ws.send(JSON.stringify({ type: 'job-create', title: 'Wire shot', repoPath: tmpdir(), agent: 'codex', attachments: [{ name: 'wire.png', data: Buffer.from('w').toString('base64') }] }));
    const created = (await list).jobs.find(j => j.title === 'Wire shot');
    expect(created.attachments).toEqual([{ name: 'wire.png', path: join(process.env.AGENT007_CONFIG_DIR, 'attachments', created.id, 'wire.png') }]);
    expect(existsSync(created.attachments[0].path)).toBe(true);
    // The agent rides the same two messages.
    expect(created.agent).toBe('codex');
    list = next(m => m.type === 'jobs-list' && m.jobs.find(j => j.id === created.id)?.attachments.length === 0);
    ws.send(JSON.stringify({ type: 'job-update', jobId: created.id, agent: 'claude', attachments: [] }));
    expect((await list).jobs.find(j => j.id === created.id).agent).toBe('claude');
    expect(existsSync(created.attachments[0].path)).toBe(false);
    ws.close();
    await deleteJob(created.id, () => {});
  });
});

// --- WebSocket connection ---

describe('WebSocket', () => {
  function connectWs() {
    return new Promise((resolve, reject) => {
      const ws = new WebSocket(wsUrl);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });
  }

  function receiveMessages(ws, count, timeoutMs = 5000) {
    return new Promise((resolve) => {
      const messages = [];
      const handler = (data) => {
        messages.push(JSON.parse(data.toString()));
        if (messages.length >= count) {
          clearTimeout(timer);
          ws.removeListener('message', handler);
          resolve(messages);
        }
      };
      const timer = setTimeout(() => {
        ws.removeListener('message', handler);
        resolve(messages);
      }, timeoutMs);
      ws.on('message', handler);
    });
  }

  it('should accept WebSocket connections', async () => {
    const ws = await connectWs();
    expect(ws.readyState).toBe(WebSocket.OPEN);
    ws.close();
  });

  it('should send repos-list and orphans-list on connect', async () => {
    // Collect messages from the moment the WS is created (before open fires)
    const ws = new WebSocket(wsUrl);
    const messages = [];
    ws.on('message', (data) => messages.push(JSON.parse(data.toString())));
    await new Promise((resolve, reject) => {
      ws.on('open', resolve);
      ws.on('error', reject);
    });
    // Wait for initial messages to arrive
    await new Promise(r => setTimeout(r, 500));
    const types = messages.map(m => m.type);
    expect(types).toContain('repos-list');
    expect(types).toContain('orphans-list');
    ws.close();
  }, 10000);

  it('should handle invalid JSON gracefully', async () => {
    const ws = await connectWs();
    // Drain initial messages (repos-list + orphans-list + any sessions)
    await new Promise(r => setTimeout(r, 500));
    // Clear any pending listeners
    ws.removeAllListeners('message');
    // Send garbage
    ws.send('not json at all');
    // Should not crash — wait briefly and verify connection is still open
    await new Promise(r => setTimeout(r, 200));
    expect(ws.readyState).toBe(WebSocket.OPEN);
    ws.close();
  }, 10000);

  it('should handle unknown message type gracefully', async () => {
    const ws = await connectWs();
    await new Promise(r => setTimeout(r, 500));
    ws.removeAllListeners('message');
    ws.send(JSON.stringify({ type: 'nonexistent-type', data: 'test' }));
    await new Promise(r => setTimeout(r, 200));
    expect(ws.readyState).toBe(WebSocket.OPEN);
    ws.close();
  }, 10000);
});

// --- PTY lifecycle (smoke test with real PTY) ---

describe('PTY lifecycle', () => {
  it('should spawn a session with echo command and receive output', async () => {
    const ws = await new Promise((resolve, reject) => {
      const ws = new WebSocket(wsUrl);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });

    // Drain initial messages
    const initMsgs = [];
    await new Promise(resolve => {
      const timer = setTimeout(resolve, 1000);
      ws.on('message', (data) => {
        initMsgs.push(JSON.parse(data.toString()));
        if (initMsgs.length >= 2) { clearTimeout(timer); resolve(); }
      });
    });

    // Spawn a simple echo command (no repo needed)
    ws.send(JSON.stringify({ type: 'spawn', command: 'echo hello-agent-007' }));

    // Collect until session-ended itself: ConPTY on a slow Windows runner can
    // take longer than any fixed wait to report echo's exit. The bound is only
    // there so a real hang fails here, inside the test's own timeout.
    // Output goes to windows showing the terminal, so this one shows it; echo
    // may well be done by then, which the replay on watching covers.
    const messages = [];
    const said = () => messages.filter(m => m.type === 'pty-output').map(m => Buffer.from(m.data, 'base64').toString()).join('');
    try {
      await new Promise(resolve => {
        const timer = setTimeout(resolve, 25000);
        ws.on('message', (data) => {
          const msg = JSON.parse(data.toString());
          messages.push(msg);
          if (msg.type === 'session-created') ws.send(JSON.stringify({ type: 'pty-resize', sessionId: msg.sessionId, cols: 80, rows: 24 }));
          if (messages.some(m => m.type === 'session-ended') && said().includes('hello-agent-007')) { clearTimeout(timer); resolve(); }
        });
      });
    } finally {
      // An open socket would hold server.close() in afterAll past its timeout.
      ws.close();
    }

    const types = messages.map(m => m.type);
    expect(types).toContain('session-created');

    // Should have received PTY output containing our echo string
    const outputMsgs = messages.filter(m => m.type === 'pty-output');
    expect(outputMsgs.length).toBeGreaterThan(0);

    // Decode base64 output and check for our string
    const allOutput = outputMsgs.map(m => Buffer.from(m.data, 'base64').toString()).join('');
    expect(allOutput).toContain('hello-agent-007');

    // Should have gotten session-ended (echo exits immediately)
    expect(types).toContain('session-ended');

    // Get the session ID for cleanup
    const created = messages.find(m => m.type === 'session-created');
    expect(created.sessionId).toBeTruthy();
    expect(created.name).toBeTruthy();
    expect(created.color).toBeTruthy();
  }, 30000);

  // On macOS and Linux node-pty "starts" a missing command, leaving a dead,
  // empty tab. It must come back as a spawn error that says what is missing.
  it('refuses a command that is not installed, with a readable error', async () => {
    const ws = await new Promise((resolve, reject) => {
      const ws = new WebSocket(wsUrl);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });
    const messages = [];
    ws.on('message', (data) => messages.push(JSON.parse(data.toString())));
    ws.send(JSON.stringify({ type: 'spawn', command: 'a007-no-such-cli --flag' }));
    await vi.waitFor(() => expect(messages.find(m => m.type === 'spawn-error')).toBeTruthy(), { timeout: 5000 });
    expect(messages.find(m => m.type === 'spawn-error').error).toMatch(/^"a007-no-such-cli" is not installed/);
    expect(messages.find(m => m.type === 'session-created')).toBeUndefined();
    ws.close();
  });

  it('should kill a session on request', async () => {
    const ws = await new Promise((resolve, reject) => {
      const ws = new WebSocket(wsUrl);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });

    // Drain initial messages
    await new Promise(r => setTimeout(r, 500));

    // Spawn a long-running command
    ws.send(JSON.stringify({ type: 'spawn', command: 'cat' })); // cat waits for input forever

    // Wait for session-created
    const sessionId = await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Timeout waiting for session-created')), 5000);
      ws.on('message', (data) => {
        const msg = JSON.parse(data.toString());
        if (msg.type === 'session-created') {
          clearTimeout(timer);
          resolve(msg.sessionId);
        }
      });
    });

    // Kill it
    ws.send(JSON.stringify({ type: 'kill', sessionId }));

    // Wait for session-ended
    await new Promise((resolve, reject) => {
      const timer = setTimeout(() => reject(new Error('Timeout waiting for session-ended')), 5000);
      ws.on('message', (data) => {
        const msg = JSON.parse(data.toString());
        if (msg.type === 'session-ended' && msg.sessionId === sessionId) {
          clearTimeout(timer);
          resolve();
        }
      });
    });

    ws.close();
  }, 15000);
});

describe('pty size with two windows', () => {
  const open = () => new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl);
    ws.on('open', () => resolve(ws));
    ws.on('error', reject);
  });
  const next = (ws, pred) => new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('Timeout')), 5000);
    const on = (data) => {
      const msg = JSON.parse(data.toString());
      if (pred(msg)) { clearTimeout(timer); ws.off('message', on); resolve(msg); }
    };
    ws.on('message', on);
  });

  it('tells only the spawning window to switch to the new tab', async () => {
    const a = await open();
    const b = await open();
    const mine = next(a, m => m.type === 'session-created');
    const theirs = next(b, m => m.type === 'session-created');
    a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
    expect((await mine).focus).toBe(true);
    expect((await theirs).focus).toBeUndefined();
    a.close(); b.close();
  });

  it('takes the smallest of the windows showing it', async () => {
    const a = await open();
    const b = await open();
    const created = next(a, m => m.type === 'session-created');
    a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
    const { sessionId } = await created;
    const show = (ws, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));
    // Every window hears every size, so wait for the one expected; a wrong
    // size never arriving fails on the timeout.
    const size = (ws, cols, rows) => next(ws, m => m.type === 'pty-size' && m.sessionId === sessionId && m.cols === cols && m.rows === rows);

    let got = size(b, 100, 30);
    show(a, 100, 30);
    await got;

    // A smaller second window shrinks it for both.
    got = size(a, 60, 30);
    show(b, 60, 40);
    await got;

    // A bigger one can't grow it past the smaller window still showing it.
    got = size(b, 60, 40);
    show(a, 200, 50);
    await got;

    // Once the smaller window closes, it grows back to the one left.
    got = size(a, 200, 50);
    b.close();
    await got;

    const ended = next(a, m => m.type === 'session-ended' && m.sessionId === sessionId);
    a.send(JSON.stringify({ type: 'kill', sessionId }));
    await ended;
    a.close();
  }, 15000);

  it('tells every browser a closed agent is gone, but not a crashed one', async () => {
    const a = await open();
    const b = await open();
    const created = next(a, m => m.type === 'session-created');
    a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
    const { sessionId } = await created;

    const removed = next(b, m => m.type === 'session-removed' && m.sessionId === sessionId);
    a.send(JSON.stringify({ type: 'kill', sessionId }));
    await removed;

    // A real exit sends session-ended and no removal: the tab stays readable.
    const msgs = [];
    b.on('message', d => msgs.push(JSON.parse(d.toString())));
    const crashed = next(b, m => m.type === 'session-ended');
    a.send(JSON.stringify({ type: 'spawn', command: 'echo bye' }));
    await crashed;
    await new Promise(r => setTimeout(r, 200));
    expect(msgs.some(m => m.type === 'session-removed')).toBe(false);
    a.close(); b.close();
  }, 15000);

  it('ignores bad sizes and lets go of a terminal a window switches away from', async () => {
    const a = await open();
    const b = await open();
    const spawn = async () => {
      const created = next(a, m => m.type === 'session-created');
      a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
      return created;
    };
    const first = await spawn();
    // The xterm is built at the pty's size before any resize arrives.
    expect([first.cols, first.rows]).toEqual([120, 30]);
    const s1 = first.sessionId;
    const { sessionId: s2 } = await spawn();
    const show = (ws, sessionId, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));
    const anySize = (ws, sessionId) => next(ws, m => m.type === 'pty-size' && m.sessionId === sessionId);

    // Bad sizes are dropped: the first size heard is the valid one after them.
    let got = anySize(a, s1);
    show(a, s1, 0, 30);
    show(a, s1, '100', 30);
    show(a, s1, 100, 1.5);
    show(a, s1, 1e9, 1e9);   // every browser would build an xterm this big
    show(a, s1, 100, 30);
    expect(await got).toMatchObject({ cols: 100, rows: 30 });

    got = next(a, m => m.type === 'pty-size' && m.sessionId === s1 && m.cols === 60);
    show(b, s1, 60, 30);
    await got;

    // b switches to s2, so s1 grows back to a's window alone.
    got = next(a, m => m.type === 'pty-size' && m.sessionId === s1 && m.cols === 100);
    show(b, s2, 80, 24);
    await got;

    // A window showing no terminal at all (job board, hidden tab) lets go too.
    got = next(b, m => m.type === 'pty-size' && m.sessionId === s2 && m.cols === 100);
    show(a, s2, 100, 30);   // a is bigger, so b still holds s2 at 80x24
    b.send(JSON.stringify({ type: 'pty-resize', sessionId: null }));
    await got;

    for (const sessionId of [s1, s2]) {
      const ended = next(a, m => m.type === 'session-ended' && m.sessionId === sessionId);
      a.send(JSON.stringify({ type: 'kill', sessionId }));
      await ended;
    }
    a.close();
    b.close();
  }, 15000);
});

// A slow link: a window gets a terminal's output only while it shows that
// terminal, the rest a throttled pty-activity, and its scrollback on switching.
describe('terminal output on a slow link', () => {
  const open = (opts) => new Promise((resolve, reject) => {
    const ws = new WebSocket(wsUrl, opts);
    const seen = [];
    ws.seen = seen;
    ws.on('message', (data) => seen.push(JSON.parse(data.toString())));
    ws.on('open', () => resolve(ws));
    ws.on('error', reject);
  });
  const text = (ws, sessionId) => ws.seen.filter(m => m.type === 'pty-output' && m.sessionId === sessionId)
    .map(m => Buffer.from(m.data, 'base64').toString()).join('');

  it('sends output only to the window showing it, activity to the rest, and the scrollback on switching', async () => {
    const a = await open();
    const b = await open();
    a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
    await vi.waitFor(() => expect(a.seen.find(m => m.type === 'session-created' && m.command === 'cat' && m.focus)).toBeTruthy());
    const { sessionId } = a.seen.find(m => m.type === 'session-created' && m.focus);
    a.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 }));
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_ONE\n' }));
    await vi.waitFor(() => expect(text(a, sessionId)).toContain('MARK_ONE'));
    expect(text(b, sessionId)).toBe('');
    await vi.waitFor(() => expect(b.seen.some(m => m.type === 'pty-activity' && m.sessionId === sessionId)).toBe(true), { timeout: ACTIVITY_MS + 2000 });
    expect(b.seen.filter(m => m.type === 'pty-activity' && m.sessionId === sessionId).length).toBe(1);   // throttled, not per chunk

    // b switches to it: the scrollback, marked so its copy is cleared first.
    b.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 }));
    await vi.waitFor(() => expect(text(b, sessionId)).toContain('MARK_ONE'));
    expect(b.seen.find(m => m.type === 'pty-output')).toMatchObject({ replay: true, reset: true });
    // A resize of the same terminal is not a switch: no second replay.
    b.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 70, rows: 24 }));
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_TWO\n' }));
    await vi.waitFor(() => expect(text(b, sessionId)).toContain('MARK_TWO'));
    expect(b.seen.filter(m => m.type === 'pty-output' && m.reset)).toHaveLength(1);

    // Leaving it (job board, hidden tab) stops the stream.
    b.send(JSON.stringify({ type: 'pty-resize', sessionId: null }));
    await new Promise(r => setTimeout(r, 100));
    const before = b.seen.length;
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'MARK_THREE\n' }));
    await vi.waitFor(() => expect(text(a, sessionId)).toContain('MARK_THREE'));
    expect(b.seen.slice(before).some(m => m.type === 'pty-output')).toBe(false);

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('compresses for a client that offers it', async () => {
    const ws = await open({ perMessageDeflate: true });
    expect(ws.extensions).toMatch(/permessage-deflate/);
    ws.close();
  });

  it('cuts a socket that stops answering pings, and keeps one that answers', async () => {
    const wss = new WebSocketServer({ port: 0 });
    await new Promise(r => wss.once('listening', r));
    setupWebSocket(wss, { heartbeatMs: 100 });
    const url = `ws://127.0.0.1:${wss.address().port}`;
    const connect = () => new Promise((resolve, reject) => {
      const ws = new WebSocket(url);
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });
    const alive = await connect();
    const dead = await connect();
    dead._socket.pause();   // a dead link: nothing read, so no pong goes back
    await vi.waitFor(() => expect(wss.clients.size).toBe(1), { timeout: 3000 });
    expect([...wss.clients][0].readyState).toBe(1);
    expect(alive.readyState).toBe(1);
    alive.close();
    dead.terminate();
    await new Promise(r => wss.close(r));
  });

  // A proxy or a suspended tab can lose protocol pongs; the page's own ping
  // (public/modules/ws.js) still counts.
  it('keeps a socket whose protocol pongs go missing while the page still pings', async () => {
    const wss = new WebSocketServer({ port: 0 });
    await new Promise(r => wss.once('listening', r));
    setupWebSocket(wss, { heartbeatMs: 100 });
    const url = `ws://127.0.0.1:${wss.address().port}`;
    const connect = () => new Promise((resolve, reject) => {
      const ws = new WebSocket(url, { autoPong: false });
      ws.on('open', () => resolve(ws));
      ws.on('error', reject);
    });
    const pinging = await connect();
    const silent = await connect();
    const pinger = setInterval(() => pinging.send('{"type":"ping"}'), 50);
    await vi.waitFor(() => expect(wss.clients.size).toBe(1), { timeout: 3000 });
    await new Promise(r => setTimeout(r, 400));   // several heartbeats on
    expect(pinging.readyState).toBe(1);
    expect(silent.readyState).not.toBe(1);
    clearInterval(pinger);
    pinging.close();
    await new Promise(r => wss.close(r));
  });
});

// --- One skill store (server/skill-store.js) ---
// Value: protects=Settings' skill-store routes (preview is a dry run, the switch validates, turning it on syncs and persists, off leaves files); fails_when=preview writes files, a non-boolean enabled is accepted, enabled:true skips the sync or the state file; why_new=test/skill-store.test.js calls the module directly and never the routes; seam=none
// The routes act on the owner's homes, so HOME (homedir) and the CLI dirs
// point at a temp folder for the test and are put back after it.
describe('/api/skill-store', () => {
  const keys = ['HOME', 'USERPROFILE', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME'];
  let home, saved;
  beforeAll(() => {
    saved = Object.fromEntries(keys.map(k => [k, process.env[k]]));
    home = mkdtempSync(join(tmpdir(), 'a007-store-home-'));
    Object.assign(process.env, { HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEX_HOME: join(home, '.codex') });
    mkdirSync(join(home, '.claude', 'skills', 'ads'), { recursive: true });
    writeFileSync(join(home, '.claude', 'skills', 'ads', 'SKILL.md'), '---\nname: ads\n---\nbody\n');
  });
  afterAll(() => {
    for (const k of keys) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
    // Off again, so a later spawn in this file never syncs the real homes.
    writeStoreState({ enabled: false, last: null });
    removeTempDir(home);
  });
  const post = (path, body) => fetch(`${baseUrl}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
  const skillPath = () => join(home, '.claude', 'skills', 'ads');
  const isLink = () => { try { return lstatSync(skillPath()).isSymbolicLink(); } catch { return false; } };

  it('previews without changing anything, refuses a non-boolean switch, then syncs and remembers when turned on', async () => {
    writeStoreState({ enabled: false, last: null });
    const preview = await (await post('/api/skill-store/preview', {})).json();
    expect(preview).toMatchObject({ enabled: false, last: null, preview: { dryRun: true, moved: ['ads'], linked: ['ads'] } });
    expect(preview.preview.summary).toMatch(/^Would move 1 skill into the store/);
    expect(isLink()).toBe(false);
    expect(existsSync(join(home, '.agents', 'skills', 'ads'))).toBe(false);

    const bad = await post('/api/skill-store', { enabled: 'yes' });
    expect(bad.status).toBe(400);
    expect(JSON.parse(readFileSync(STORE_FILE, 'utf8')).enabled).toBe(false);

    const on = await (await post('/api/skill-store', { enabled: true })).json();
    expect(on).toMatchObject({ enabled: true, last: { moved: ['ads'] }, preview: null });
    expect(on.summary).toMatch(/^Moved 1 skill into the store/);
    expect(isLink()).toBe(true);
    expect(existsSync(join(home, '.agents', 'skills', 'ads', 'SKILL.md'))).toBe(true);
    expect((await (await fetch(`${baseUrl}/api/skill-store`)).json())).toMatchObject({ enabled: true, last: { moved: ['ads'] } });

    const off = await (await post('/api/skill-store', { enabled: false })).json();
    expect(off).toMatchObject({ enabled: false, last: { moved: ['ads'] } });
    expect(isLink()).toBe(true);
  });
});

// --- Auth enforcement (phase 1) ---
// Runs LAST: writes a user to the hermetic users path so the running server
// (which started auth-disabled) picks it up live, then removes it so nothing
// after this block is affected.

describe('auth enforcement (live enable)', () => {
  const usersPath = process.env.AGENT007_USERS_PATH;
  const token = 'tok_' + Math.random().toString(36).slice(2, 12);

  beforeAll(() => {
    writeFileSync(usersPath, JSON.stringify([
      { id: 'u_test', displayName: 'Tester', color: '#d4a847', tokenHash: hashToken(token) },
    ]));
  });
  afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });

  it('rejects an /api request with no token (401)', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`);
    expect(res.status).toBe(401);
  });

  it('accepts an /api request with a valid Bearer token (200)', async () => {
    const res = await fetch(`${baseUrl}/api/browse?path=${tmpdir()}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    expect(res.status).toBe(200);
  });

  // A card's attachment link is a plain <a href>, which cannot send a header,
  // so the token rides in the query for exactly this route.
  it('gates an attachment link on the token, in the query', async () => {
    const { job } = addJob({ title: 'Gated', repoPath: tmpdir(), attachments: [{ name: 'g.txt', data: Buffer.from('g').toString('base64') }] }, () => {});
    try {
      expect((await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/g.txt`)).status).toBe(401);
      expect((await fetch(`${baseUrl}/api/jobs/${job.id}/attachments/g.txt?token=${token}`)).status).toBe(200);
    } finally {
      await deleteJob(job.id, () => {});
    }
  });

  // Update restarts the server: the owner's call, which nobody has with user accounts on.
  it('refuses Update to a signed-in user and to another origin', async () => {
    const auth = { Authorization: `Bearer ${token}` };
    expect((await fetch(`${baseUrl}/api/update`, { method: 'POST', headers: auth })).status).toBe(403);
    expect((await fetch(`${baseUrl}/api/update`, { headers: auth })).status).toBe(403);
    expect((await fetch(`${baseUrl}/api/update`, { method: 'POST', headers: { ...auth, Origin: 'https://evil.example' } })).status).toBe(403);
  });

  // Value: protects=the skill store's routes stay owner-only; fails_when=ownerOnly is dropped from any of the three routes, letting a signed-in user move the owner's skill folders; why_new=only /api/update's 403 was tested; seam=none
  it('refuses the skill store routes to a signed-in user', async () => {
    const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
    expect((await fetch(`${baseUrl}/api/skill-store`, { headers })).status).toBe(403);
    expect((await fetch(`${baseUrl}/api/skill-store`, { method: 'POST', headers, body: '{"enabled":true}' })).status).toBe(403);
    expect((await fetch(`${baseUrl}/api/skill-store/preview`, { method: 'POST', headers, body: '{}' })).status).toBe(403);
  });

  it('closes a WS handshake without a token (code 4401)', async () => {
    // The server accepts the socket then closes it with 4401 in the connection
    // handler, so the client sees a brief open followed by a 4401 close.
    const code = await new Promise((resolve) => {
      const ws = new WebSocket(wsUrl);
      ws.on('close', (c) => resolve(c));
      ws.on('error', () => {});
    });
    expect(code).toBe(4401);
  });

  it('accepts a WS handshake with a valid token and sends welcome', async () => {
    const welcome = await new Promise((resolve, reject) => {
      const s = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(token)}`);
      s.on('message', (d) => {
        const m = JSON.parse(d);
        if (m.type === 'welcome') { resolve(m); s.close(); }
      });
      s.on('close', () => reject(new Error('closed before welcome')));
      s.on('error', reject);
    });
    expect(welcome.authEnabled).toBe(true);
    expect(welcome.user.displayName).toBe('Tester');
    expect(welcome.platform).toBe(process.platform);
    // The same boot for every socket, so a reconnecting page keeps its state.
    expect(welcome.bootId).toBe(BOOT_ID);
    expect(welcome.sessionIds).toEqual([...sessions.keys()]);
  });
});

// --- Auth revocation (phase 1 hardening) ---
// A user removed from users.json must lose access on their LIVE socket, not just
// on the next reconnect. Two users so auth stays enabled after removing one.

describe('auth revocation (live socket)', () => {
  const usersPath = process.env.AGENT007_USERS_PATH;
  const tokenA = 'tokA_' + Math.random().toString(36).slice(2, 10);
  const tokenB = 'tokB_' + Math.random().toString(36).slice(2, 10);

  beforeAll(() => {
    writeFileSync(usersPath, JSON.stringify([
      { id: 'u_a', displayName: 'A', color: '#d4a847', tokenHash: hashToken(tokenA) },
      { id: 'u_b', displayName: 'B', color: '#58a6ff', tokenHash: hashToken(tokenB) },
    ]));
  });
  afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });

  it('closes a live socket with 4401 when its user is removed', async () => {
    const ws = await new Promise((resolve, reject) => {
      const s = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(tokenA)}`);
      s.on('message', (d) => { if (JSON.parse(d).type === 'welcome') resolve(s); });
      s.on('error', reject);
    });
    // Revoke A, keep B so auth remains enabled.
    writeFileSync(usersPath, JSON.stringify([
      { id: 'u_b', displayName: 'B', color: '#58a6ff', tokenHash: hashToken(tokenB) },
    ]));
    const code = await new Promise((resolve) => {
      ws.on('close', (c) => resolve(c));
      ws.send(JSON.stringify({ type: 'refresh-tree', sessionId: 'none' }));
    });
    expect(code).toBe(WS_UNAUTHORIZED);
  });
});

// --- Ownership authorization (phase 2) ---
// With auth on, only the owner may control a session. A non-owner's kill is
// rejected with a notification and the session survives; the owner's kill works.

describe('ownership authorization', () => {
  const usersPath = process.env.AGENT007_USERS_PATH;
  const tokenA = 'ownA_' + Math.random().toString(36).slice(2, 10);
  const tokenB = 'ownB_' + Math.random().toString(36).slice(2, 10);

  beforeAll(() => {
    writeFileSync(usersPath, JSON.stringify([
      { id: 'u_a', displayName: 'Aowner', color: '#d4a847', tokenHash: hashToken(tokenA) },
      { id: 'u_b', displayName: 'Bviewer', color: '#58a6ff', tokenHash: hashToken(tokenB) },
    ]));
  });
  afterAll(() => { try { rmSync(usersPath, { force: true }); } catch {} });

  const connect = (token) => new Promise((resolve, reject) => {
    const ws = new WebSocket(`${wsUrl}/?token=${encodeURIComponent(token)}`);
    ws.on('open', () => resolve(ws));
    ws.on('error', reject);
  });
  const nextMatching = (ws, pred, timeoutMs = 4000) => new Promise((resolve) => {
    const to = setTimeout(() => { ws.off('message', h); resolve(null); }, timeoutMs);
    const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); resolve(m); } };
    ws.on('message', h);
  });

  it('tags a spawned session with its owner and blocks a non-owner kill', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
    const payload = await created;
    expect(payload).toBeTruthy();
    expect(payload.ownerId).toBe('u_a');
    expect(payload.ownerName).toBe('Aowner');
    const sessionId = payload.sessionId;

    // B (non-owner) tries to kill -> gets a read-only notification, session survives.
    const b = await connect(tokenB);
    const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'kill', sessionId }));
    const note = await denied;
    expect(note).toBeTruthy();
    expect(note.message).toMatch(/owned by Aowner/);
    expect(sessions.has(sessionId)).toBe(true);

    // A (owner) kills successfully.
    const ended = nextMatching(a, (m) => m.type === 'session-ended' && m.sessionId === sessionId);
    a.send(JSON.stringify({ type: 'kill', sessionId }));
    expect(await ended).toBeTruthy();

    a.close(); b.close();
  }, 15000);

  it('refuses Restart of an exited agent to a non-owner', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && m.command === 'true');
    const ended = nextMatching(a, (m) => m.type === 'session-ended');
    a.send(JSON.stringify({ type: 'spawn', command: 'true' }));
    const { sessionId } = await created;
    expect((await ended).closed).toBe(false);   // exited by itself, so Restart is offered

    const b = await connect(tokenB);
    const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'restart', sessionId }));
    expect((await denied).message).toMatch(/owned by Aowner/);
    expect(sessions.get(sessionId).exited).toBe(true);   // not restarted

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('lets only the owner rename a session and broadcasts the new name', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 7/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 7' }));
    const { sessionId, name: oldName } = await created;
    const b = await connect(tokenB);

    const denied = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'nope' }));
    expect(await denied).toBeTruthy();
    expect(sessions.get(sessionId).name).toBe(oldName);

    const seenByB = nextMatching(b, (m) => m.type === 'session-renamed' && m.sessionId === sessionId);
    a.send(JSON.stringify({ type: 'rename-session', sessionId, name: '  viper  ' }));
    const renamed = await seenByB;
    expect(renamed.name).toBe('viper');
    expect(sessions.get(sessionId).name).toBe('viper');

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('silently drops pty-input from a non-owner but forwards the owner\'s', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /cat/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'cat' }));
    const { sessionId } = await created;
    a.send(JSON.stringify({ type: 'pty-resize', sessionId, cols: 80, rows: 24 }));   // output goes to watchers
    const b = await connect(tokenB);

    const marker = 'BINTRUDER_' + Math.random().toString(36).slice(2, 8);
    const echoOfB = nextMatching(a, (m) => m.type === 'pty-output'
      && Buffer.from(m.data || '', 'base64').toString().includes(marker), 1500);
    b.send(JSON.stringify({ type: 'pty-input', sessionId, data: marker + '\n' }));
    expect(await echoOfB).toBeNull(); // dropped server-side; cat never saw it

    const own = 'AOWNER_' + Math.random().toString(36).slice(2, 8);
    const echoOfA = nextMatching(a, (m) => m.type === 'pty-output'
      && Buffer.from(m.data || '', 'base64').toString().includes(own), 3000);
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: own + '\n' }));
    expect(await echoOfA).toBeTruthy();

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it("sizes a terminal to its owner's windows only, never a viewer's", async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 11/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 11' }));
    const { sessionId } = await created;
    const b = await connect(tokenB);
    const show = (ws, cols, rows) => ws.send(JSON.stringify({ type: 'pty-resize', sessionId, cols, rows }));

    const owners = nextMatching(b, (m) => m.type === 'pty-size' && m.sessionId === sessionId);
    show(a, 100, 30);
    expect(await owners).toMatchObject({ cols: 100, rows: 30 });

    // B's smaller window is a viewer's: it renders at 100x30, it doesn't shrink it.
    // A's own copy of the 100x30 broadcast may still be in flight, so only a
    // different size counts as a shrink.
    const shrunk = nextMatching(a, (m) => m.type === 'pty-size' && m.sessionId === sessionId
      && (m.cols !== 100 || m.rows !== 30), 1500);
    show(b, 50, 20);
    expect(await shrunk).toBeNull();
    expect([sessions.get(sessionId).pty.cols, sessions.get(sessionId).pty.rows]).toEqual([100, 30]);

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('marks a terminal as being typed in only for real keystrokes from its owner', async () => {
    // lastUserInputAt holds agent messages back (server/messages.js). A focus
    // report arrives every time the tab is clicked; counting it would stall
    // messages for 30 s on every glance at the terminal.
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep 9/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 9' }));
    const { sessionId } = await created;
    const b = await connect(tokenB);
    const session = sessions.get(sessionId);
    // Messages on one socket are handled in order: a later rename landing
    // means every earlier pty-input on that socket has been processed.
    const settled = async (ws, name) => {
      const done = nextMatching(a, (m) => m.type === 'session-renamed' && m.name === name);
      ws.send(JSON.stringify({ type: 'rename-session', sessionId, name }));
      await done;
    };

    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: '\x1b[I' }));
    await settled(a, 'focus');
    expect(session.lastUserInputAt).toBeUndefined();

    // A viewer's keystrokes never reach the pty, so they hold nothing back.
    const refused = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'x' }));
    b.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'nope' }));
    expect(await refused).toBeTruthy();
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: '\x1b[O' }));
    await settled(a, 'blur');
    expect(session.lastUserInputAt).toBeUndefined();

    const before = Date.now();
    a.send(JSON.stringify({ type: 'pty-input', sessionId, data: 'x' }));
    await settled(a, 'typed');
    expect(session.lastUserInputAt).toBeGreaterThanOrEqual(before);

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('rejects refresh-tree and upload-file from a non-owner', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
    const { sessionId } = await created;
    const b = await connect(tokenB);

    const t = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'refresh-tree', sessionId }));
    expect(await t).toBeTruthy();

    const u = nextMatching(b, (m) => m.type === 'notification' && /read-only/i.test(m.message || ''));
    b.send(JSON.stringify({ type: 'upload-file', sessionId, filename: 'x.txt', data: Buffer.from('hi').toString('base64') }));
    expect((await u).message).toMatch(/owned by Aowner/);

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);

  it('keeps read paths (get-diff) open to a non-owner', async () => {
    const a = await connect(tokenA);
    const created = nextMatching(a, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
    a.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
    const { sessionId } = await created;
    const b = await connect(tokenB);

    // Non-owner get-diff must return a file-diff response, not a read-only notice.
    const resp = nextMatching(b, (m) =>
      (m.type === 'file-diff' && m.sessionId === sessionId) ||
      (m.type === 'notification' && /read-only/i.test(m.message || '')));
    b.send(JSON.stringify({ type: 'get-diff', sessionId, filePath: 'anything', status: 'M' }));
    expect((await resp).type).toBe('file-diff');

    a.send(JSON.stringify({ type: 'kill', sessionId }));
    a.close(); b.close();
  }, 15000);
});

describe('ownership is inert when auth is disabled', () => {
  // No users file here (default hermetic state) => authEnabled() false.
  const open = () => new Promise((res, rej) => {
    const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
  });
  const next = (ws, pred, ms = 4000) => new Promise((res) => {
    const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
    const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
    ws.on('message', h);
  });

  it('lets any socket control any session in single-player mode', async () => {
    const w1 = await open();
    const created = next(w1, (m) => m.type === 'session-created' && /sleep/.test(m.command || ''));
    w1.send(JSON.stringify({ type: 'spawn', command: 'sleep 5' }));
    const { sessionId, ownerId } = await created;
    expect(ownerId ?? null).toBeNull(); // unowned in single-player

    const w2 = await open();
    const ended = next(w2, (m) => m.type === 'session-ended' && m.sessionId === sessionId);
    w2.send(JSON.stringify({ type: 'kill', sessionId }));
    expect(await ended).toBeTruthy(); // second socket kills it — no ownership block

    w1.close(); w2.close();
  }, 15000);

  it('spawn refuses a custom name that is already held', async () => {
    const w = await open();
    const created = next(w, (m) => m.type === 'session-created' && m.name === 'dup-name-test');
    w.send(JSON.stringify({ type: 'spawn', command: 'sleep 4', name: 'dup-name-test' }));
    const { sessionId } = await created;
    const err = next(w, (m) => m.type === 'spawn-error' && /already exists/.test(m.error || ''));
    w.send(JSON.stringify({ type: 'spawn', command: 'sleep 4', name: 'dup-name-test' }));
    expect(await err).toBeTruthy();
    w.send(JSON.stringify({ type: 'kill', sessionId }));
    w.close();
  }, 15000);

  it('rename-session ignores bad input, rejects a taken name, and truncates to 40', async () => {
    const w = await open();
    const spawn = async (cmd) => {
      const created = next(w, (m) => m.type === 'session-created' && m.command === cmd);
      w.send(JSON.stringify({ type: 'spawn', command: cmd }));
      return created;
    };
    const one = await spawn('sleep 6');
    const two = await spawn('sleep 8');

    // A missing session, a non-string or missing name, a whitespace name, a
    // name with no letters or digits, and the current name are all silent no-ops.
    w.send(JSON.stringify({ type: 'rename-session', sessionId: 'nope-999', name: 'ghost' }));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 42 }));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId }));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: '   ' }));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: '...' }));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: one.name }));
    // A name another live session holds is refused with an error notification.
    const dup = next(w, (m) => m.type === 'notification' && /already exists/.test(m.message || ''));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: two.name }));
    expect((await dup).level).toBe('error');
    expect(sessions.get(one.sessionId).name).toBe(one.name);
    // So is a name an orphan still holds: the pool reserves those too.
    orphans.set('orphan-rename-test', { id: 'orphan-rename-test', name: 'orphan-ghost', worktreePath: '/nonexistent/orphan-ghost' });
    codenamePool.addUsed('orphan-ghost');
    const dupOrphan = next(w, (m) => m.type === 'notification' && /already exists/.test(m.message || ''));
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 'orphan-ghost' }));
    expect((await dupOrphan).level).toBe('error');
    orphans.delete('orphan-rename-test');
    codenamePool.recycle('orphan-ghost');

    // Over-long names are cut to 40 characters.
    const renamed = next(w, (m) => m.type === 'session-renamed' && m.sessionId === one.sessionId);
    w.send(JSON.stringify({ type: 'rename-session', sessionId: one.sessionId, name: 'x'.repeat(41) }));
    expect((await renamed).name).toBe('x'.repeat(40));

    w.send(JSON.stringify({ type: 'kill', sessionId: one.sessionId }));
    w.send(JSON.stringify({ type: 'kill', sessionId: two.sessionId }));
    w.close();
  }, 15000);

  it('rename-session carries the new name into the restart record and the linked job card', async () => {
    const w = await open();
    const created = next(w, (m) => m.type === 'session-created' && m.command === 'sleep 9');
    w.send(JSON.stringify({ type: 'spawn', command: 'sleep 9' }));
    const { sessionId, name: oldName } = await created;
    const session = sessions.get(sessionId);
    // A bare spawn has no worktree; give it one named after the codename, the
    // way createWorktree lays them out, so the config record is findable.
    session.worktreePath = join(tmpdir(), oldName);
    config.activeSessions.push({ name: oldName, worktreePath: session.worktreePath });
    const { job } = addJob({ title: 'Rename me', repoPath: tmpdir() }, () => {});
    Object.assign(job, { agentSessionId: sessionId, agentName: oldName });
    // Every card linked to this session follows, whatever label it showed before.
    const { job: other } = addJob({ title: 'Stale label', repoPath: tmpdir() }, () => {});
    Object.assign(other, { agentSessionId: sessionId, agentName: 'someone-else' });

    try {
      const list = next(w, (m) => m.type === 'jobs-list' && m.jobs.find(j => j.id === job.id)?.agentName === 'mamba');
      w.send(JSON.stringify({ type: 'rename-session', sessionId, name: 'mamba' }));
      expect((await list).jobs.find(j => j.id === other.id).agentName).toBe('mamba');
      expect(config.activeSessions.find(s => s.worktreePath === session.worktreePath).name).toBe('mamba');
      // The old codename still names the worktree directory on disk, so it stays
      // reserved: a later spawn must not try to create the same path.
      expect(codenamePool.has(oldName)).toBe(true);
      expect(codenamePool.has('mamba')).toBe(true);
      // Renaming back to the directory's own codename is allowed, and frees the label.
      const back = next(w, (m) => m.type === 'session-renamed' && m.name === oldName);
      w.send(JSON.stringify({ type: 'rename-session', sessionId, name: oldName }));
      expect(await back).toBeTruthy();
      expect(codenamePool.has('mamba')).toBe(false);
    } finally {
      config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== session.worktreePath);
      session.worktreePath = null; // keep the kill path from touching a worktree that never existed
      codenamePool.recycle(oldName);
      await deleteJob(job.id, () => {});
      await deleteJob(other.id, () => {});
      w.send(JSON.stringify({ type: 'kill', sessionId }));
      w.close();
    }
  }, 15000);

  // Closing a tab whose worktree cannot be cleaned up parks it as an orphan, and
  // the explorer's Re-spawn brings it back. What Re-spawn runs must be the CLI
  // the tab ran: `claude --continue` in a Codex worktree dies at once.
  //
  // A fake `codex` first on PATH stands in for the real one, which would try to
  // resume a real session; the PTY inherits process.env at spawn time. The
  // worktree is a bare directory with an unreadable .git, which the close path
  // treats like uncommitted work (git status fails) and the re-adopt path
  // accepts as a worktree (.git is there).
  function fakeOrphanWorktree() {
    const worktreePath = mkdtempSync(join(tmpdir(), 'a007-orphan-wt-'));
    writeFileSync(join(worktreePath, '.git'), 'not a gitfile\n');
    return worktreePath;
  }

  // A fake `codex` first on PATH, as a posix shell script: the Windows leg has
  // neither the shebang nor the `:` PATH delimiter, so these two skip there.
  function fakeCodexOnPath() {
    const bin = mkdtempSync(join(tmpdir(), 'a007-bin-'));
    writeFileSync(join(bin, 'codex'), '#!/bin/sh\nsleep 5\n', { mode: 0o755 });
    const savedPath = process.env.PATH;
    process.env.PATH = `${bin}:${savedPath}`;
    return { bin, restore: () => { process.env.PATH = savedPath; rmSync(bin, { recursive: true, force: true }); } };
  }

  it.skipIf(process.platform === 'win32')('closing a tab notes its CLI on the orphan, and Re-spawn resumes with that CLI', async () => {
    const { bin, restore } = fakeCodexOnPath();
    const worktreePath = fakeOrphanWorktree();
    // Started with a permission flag, which the re-spawn must carry back.
    const command = `${join(bin, 'codex')} --model o3 --dangerously-bypass-approvals-and-sandbox`;
    const w = await open();
    let name, back, other;
    try {
      const created = next(w, (m) => m.type === 'session-created' && m.command === command);
      w.send(JSON.stringify({ type: 'spawn', command }));
      const { sessionId, name: spawned } = await created;
      name = spawned;
      // A bare spawn has no worktree; give it one, as createWorktree would have.
      Object.assign(sessions.get(sessionId), { repoPath: tmpdir(), worktreePath, branchName: 'b/orphan-test' });

      const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
      w.send(JSON.stringify({ type: 'kill', sessionId }));
      const orphan = (await parked).orphans.find(o => o.name === name);
      expect(orphan.agent).toBe('codex');   // read off the command, path and flags stripped
      expect(orphans.get(orphan.id).agent).toBe('codex');
      expect(orphan.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);

      const isBack = (m) => m.type === 'session-created' && m.name === name && m.sessionId !== sessionId;
      const readopted = next(w, isBack);
      other = await open();
      const theirs = next(other, isBack);
      w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
      back = await readopted;
      expect(back.command).toBe('codex resume --dangerously-bypass-approvals-and-sandbox');
      // Only the window that clicked Re-spawn switches to the revived tab.
      expect(back.focus).toBe(true);
      expect((await theirs).focus).toBeUndefined();
      expect(orphans.has(orphan.id)).toBe(false);
      // The record for the next restart carries the CLI and the flags too.
      const rec = config.activeSessions.find(s => s.worktreePath === worktreePath);
      expect(rec.agent).toBe('codex');
      expect(rec.permissionFlags).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
    } finally {
      restore();
      config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
      for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
      if (back) {
        // Keep the kill path from orphaning the fake worktree a second time.
        Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null });
        w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
      }
      if (name) codenamePool.recycle(name);
      rmSync(worktreePath, { recursive: true, force: true });
      w.close();
      other?.close();
    }
  }, 15000);

  // Restart of a session whose CLI quit by itself (Codex's update, a crash, a
  // stray /exit): back in place under the same id and tab, resuming its own
  // conversation with its model and permission flags, and never twice. The
  // fake CLI logs its argv, exits on its first run and stays up after.
  function fakeCliOnPath(cli) {
    const bin = mkdtempSync(join(tmpdir(), 'a007-bin-'));
    const log = join(bin, 'argv.log');
    writeFileSync(join(bin, cli), `#!/bin/sh\necho "$@" >> "${log}"\n[ -f "${bin}/ran" ] && exec sleep 5\ntouch "${bin}/ran"\n`, { mode: 0o755 });
    const savedPath = process.env.PATH;
    process.env.PATH = `${bin}:${savedPath}`;
    return { bin, log, restore: () => { process.env.PATH = savedPath; rmSync(bin, { recursive: true, force: true }); } };
  }
  async function exitedSession(w, command) {
    const created = next(w, (m) => m.type === 'session-created' && m.command === command);
    w.send(JSON.stringify({ type: 'spawn', command }));
    const { sessionId, name } = await created;
    await vi.waitFor(() => expect(sessions.get(sessionId).exited).toBe(true));
    return { sessionId, name };
  }

  it.skipIf(process.platform === 'win32')('Restart brings an exited Codex agent back in place, resuming its own session, and never twice', async () => {
    const { bin, log, restore } = fakeCliOnPath('codex');
    const worktreePath = fakeOrphanWorktree();
    const codexHome = mkdtempSync(join(tmpdir(), 'a007-codex-home-'));
    const day = join(codexHome, 'sessions', '2026', '10', '08');
    mkdirSync(day, { recursive: true });
    const id = '0199c0de-0000-7000-8000-00000000c0de';
    writeFileSync(join(day, 'rollout-r.jsonl'), JSON.stringify({ type: 'session_meta', payload: { id, cwd: realpathSync.native(worktreePath), source: 'cli', thread_source: 'user' } }) + '\n');
    const savedHome = process.env.CODEX_HOME;
    process.env.CODEX_HOME = codexHome;
    const w = await open();
    let sessionId;
    try {
      const command = `${join(bin, 'codex')} --model o3 --dangerously-bypass-approvals-and-sandbox`;
      ({ sessionId } = await exitedSession(w, command));
      Object.assign(sessions.get(sessionId), { repoPath: tmpdir(), worktreePath, branchName: 'b/restart-test' });

      const back = next(w, (m) => m.type === 'session-created' && m.sessionId === sessionId);
      const refused = next(w, (m) => m.type === 'spawn-error');
      w.send(JSON.stringify({ type: 'restart', sessionId }));
      w.send(JSON.stringify({ type: 'restart', sessionId }));   // a second click
      const msg = await back;
      expect(msg.command).toBe(`codex resume ${id} --dangerously-bypass-approvals-and-sandbox -m "o3"`);
      expect(msg.focus).toBe(true);
      expect((await refused).error).toMatch(/already (running|restarting)/);
      expect(sessions.get(sessionId).exited).toBe(false);
      // Codex's update prompt is off for every Codex Agent 007 starts.
      await vi.waitFor(() => expect(readFileSync(log, 'utf8').trim().split('\n')).toHaveLength(2));
      for (const argv of readFileSync(log, 'utf8').trim().split('\n')) expect(argv).toContain('-c check_for_update_on_startup=false');
      expect(readFileSync(log, 'utf8').trim().split('\n')[1]).toMatch(new RegExp(`resume ${id} `));
    } finally {
      restore();
      if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
      config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
      if (sessionId && sessions.has(sessionId)) {
        Object.assign(sessions.get(sessionId), { repoPath: null, worktreePath: null });
        w.send(JSON.stringify({ type: 'kill', sessionId }));
      }
      rmSync(worktreePath, { recursive: true, force: true });
      rmSync(codexHome, { recursive: true, force: true });
      w.close();
    }
  }, 15000);

  it.skipIf(process.platform === 'win32')('Restart resumes an exited Claude Code agent by its exact conversation', async () => {
    const { bin, log, restore } = fakeCliOnPath('claude');
    const w = await open();
    let sessionId;
    try {
      ({ sessionId } = await exitedSession(w, `${join(bin, 'claude')} --model opus`));
      const conversation = sessions.get(sessionId).claudeSessionId;
      expect(conversation).toMatch(/^[0-9a-f-]{36}$/);
      const back = next(w, (m) => m.type === 'session-created' && m.sessionId === sessionId);
      w.send(JSON.stringify({ type: 'restart', sessionId }));
      expect((await back).command).toBe(`claude --resume ${conversation} --model "opus"`);
      await vi.waitFor(() => expect(readFileSync(log, 'utf8')).toContain(`--resume ${conversation}`));
      // Running now: a Restart is refused rather than starting a second one.
      const refused = next(w, (m) => m.type === 'spawn-error');
      w.send(JSON.stringify({ type: 'restart', sessionId }));
      expect((await refused).error).toMatch(/already running/);
    } finally {
      restore();
      if (sessionId) w.send(JSON.stringify({ type: 'kill', sessionId }));
      w.close();
    }
  }, 15000);

  it.skipIf(process.platform === 'win32')('Restart refuses an exited agent that is being closed, and closing one tells every window', async () => {
    const { bin, restore } = fakeCliOnPath('claude');
    const w = await open();
    try {
      const { sessionId } = await exitedSession(w, `${join(bin, 'claude')}`);
      sessions.get(sessionId).closing = true;   // killSession is mid-way through its git work
      const refused = next(w, (m) => m.type === 'spawn-error');
      w.send(JSON.stringify({ type: 'restart', sessionId }));
      expect((await refused).error).toBe('Agent not found');
      sessions.get(sessionId).closing = false;
      const closed = next(w, (m) => m.type === 'session-ended' && m.sessionId === sessionId && m.closed);
      w.send(JSON.stringify({ type: 'kill', sessionId }));
      expect(await closed).toBeTruthy();
    } finally {
      restore();
      w.close();
    }
  }, 15000);

  // An orphan with no note (a record written before the CLI was noted, or a
  // worktree discovered on disk) resumes by whatever its job card or the
  // transcripts say — but that answer is a guess, and the new session must
  // NOT record it as fact, or a wrong one could never be corrected. The card,
  // when there is one, also lends the resume its permission mode.
  it.skipIf(process.platform === 'win32')('re-adopting an orphan with no note resumes by card and transcript, under the card\'s mode, and records nothing', async () => {
    const { bin, restore } = fakeCodexOnPath();
    const worktreePath = fakeOrphanWorktree();
    const repoPath = tmpdir();
    const branchName = 'b/no-note-test';
    // A Codex rollout whose cwd is the worktree, under a fake CODEX_HOME.
    const codexHome = mkdtempSync(join(tmpdir(), 'a007-codex-home-'));
    const day = join(codexHome, 'sessions', '2026', '09', '16');
    mkdirSync(day, { recursive: true });
    writeFileSync(join(day, 'rollout-x.jsonl'), JSON.stringify({ type: 'session_meta', payload: { id: 'x', cwd: realpathSync.native(worktreePath), source: 'cli', thread_source: 'user' } }) + '\n');
    const savedHome = process.env.CODEX_HOME;
    process.env.CODEX_HOME = codexHome;
    const savedRepos = config.repos;
    config.repos = [...config.repos, { path: repoPath }];
    const command = `${join(bin, 'codex')} --model o3`;
    const w = await open();
    let name, back, jobId;
    try {
      const created = next(w, (m) => m.type === 'session-created' && m.command === command);
      w.send(JSON.stringify({ type: 'spawn', command }));
      const { sessionId, name: spawned } = await created;
      name = spawned;
      Object.assign(sessions.get(sessionId), { repoPath, worktreePath, branchName });
      const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
      w.send(JSON.stringify({ type: 'kill', sessionId }));
      const orphan = (await parked).orphans.find(o => o.name === name);
      orphans.get(orphan.id).agent = null;   // as a record from before the note, or a discovered worktree

      // No card yet: the transcript answers, bare, and the session keeps no note.
      let readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== sessionId);
      w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
      back = await readopted;
      expect(back.command).toBe('codex resume');
      expect(sessions.get(back.sessionId).agent).toBeNull();
      expect(config.activeSessions.find(s => s.worktreePath === worktreePath).agent).toBeNull();

      // Park it again; this time a read-only card on the branch is waiting.
      const reparked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
      w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
      const again = (await reparked).orphans.find(o => o.name === name);
      expect(again.agent).toBeNull();   // still no note: nothing was learned for certain
      const { job } = addJob({ title: 'read only codex', repoPath, agent: 'codex', permissionMode: 'plan' }, () => {});
      jobId = job.id;
      Object.assign(job, { state: 'in-progress', branchName, agentSessionId: null });
      readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== back.sessionId);
      w.send(JSON.stringify({ type: 'restart', orphanId: again.id }));
      back = await readopted;
      expect(back.command).toBe('codex resume --sandbox read-only');
      expect(allJobs().find(j => j.id === jobId).agentSessionId).toBe(back.sessionId);   // relinked to its card
      const rec = config.activeSessions.find(s => s.worktreePath === worktreePath);
      expect(rec.agent).toBeNull();
      // Under a card's mode the session records no flags of its own — the
      // card, or the board once the card is gone, decides again next time —
      // even though its resume command carries `--sandbox read-only`.
      expect(rec.permissionFlags).toEqual([]);
      expect(sessions.get(back.sessionId).permissionFlags).toEqual([]);
    } finally {
      restore();
      if (savedHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = savedHome;
      config.repos = savedRepos;
      config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
      for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
      if (back && sessions.has(back.sessionId)) {
        Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null, jobId: null });
        w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
      }
      if (jobId) await deleteJob(jobId, () => {});
      if (name) codenamePool.recycle(name);
      rmSync(worktreePath, { recursive: true, force: true });
      rmSync(codexHome, { recursive: true, force: true });
      w.close();
    }
  }, 20000);

  // A board worker parked by a restart (or a close) keeps its card. Re-spawned,
  // it is that card's worker again: a board tab, counted toward the cap, told
  // once to carry on while its card is In progress, and retired like any other.
  it.skipIf(process.platform === 'win32')('a re-spawned board worker keeps its card, counts toward the cap, is nudged once, and leaves as a board worker', async () => {
    const { bin, restore } = fakeCodexOnPath();
    const worktreePath = fakeOrphanWorktree();
    const repoPath = tmpdir();
    const branchName = 'b/respawn-board-test';
    const savedRepos = config.repos;
    config.repos = [...config.repos, { path: repoPath }];
    const command = `${join(bin, 'codex')} --model o3`;
    const w = await open();
    let name, back, jobId, queuedId;
    const park = async (sessionId) => {
      const parked = next(w, (m) => m.type === 'orphans-list' && m.orphans.some(o => o.name === name));
      w.send(JSON.stringify({ type: 'kill', sessionId }));
      return (await parked).orphans.find(o => o.name === name);
    };
    const respawn = async (orphan, from) => {
      const readopted = next(w, (m) => m.type === 'session-created' && m.name === name && m.sessionId !== from);
      w.send(JSON.stringify({ type: 'restart', orphanId: orphan.id }));
      return readopted;
    };
    try {
      const { job } = addJob({ title: 'respawn board card', repoPath, agent: 'codex' }, () => {});
      jobId = job.id;
      const created = next(w, (m) => m.type === 'session-created' && m.command === command);
      w.send(JSON.stringify({ type: 'spawn', command }));
      const { sessionId, name: spawned } = await created;
      name = spawned;
      // As the dispatcher would have made it.
      Object.assign(sessions.get(sessionId), { repoPath, worktreePath, branchName, spawnedBy: 'board', jobId, origin: 'board' });
      Object.assign(job, { state: 'review', branchName, agentSessionId: sessionId });

      let orphan = await park(sessionId);
      expect(orphan.jobId).toBe(jobId);
      expect(orphan.origin).toBe('board');

      // Card in Review: a board worker again, but nothing to nudge.
      back = await respawn(orphan, sessionId);
      expect(back.spawnedBy).toBe('board');
      expect(back.jobId).toBe(jobId);
      expect(pendingMessages(back.sessionId)).toBe(0);

      // Card in progress, and an older record with no jobId: found by branch.
      job.state = 'in-progress';
      orphan = await park(back.sessionId);
      orphans.get(orphan.id).jobId = null;
      const before = back.sessionId;
      back = await respawn(orphan, before);
      const session = sessions.get(back.sessionId);
      expect(session.spawnedBy).toBe('board');
      expect(session.jobId).toBe(jobId);
      expect(job.agentSessionId).toBe(back.sessionId);
      expect(config.activeSessions.find(s => s.worktreePath === worktreePath).jobId).toBe(jobId);
      // One line queued, typed once it rests at its prompt.
      expect(pendingMessages(back.sessionId)).toBe(1);
      // It holds its repo's slot: a queued card there waits.
      const live = new Set([...sessions].filter(([, s]) => !s.exited).map(([id]) => id));
      expect(countInFlightByRepo(allJobs(), live).get(repoPath)).toBe(1);
      const { job: queued } = addJob({ title: 'respawn queued card', repoPath, agent: 'codex' }, () => {});
      queuedId = queued.id;
      expect(selectDispatchableJobs(allJobs(), { maxPerRepo: 1, liveSessionIds: live }).map(j => j.id)).not.toContain(queuedId);

      // Filed as done: it ends as a board worker, so its tab and desk go.
      const ended = next(w, (m) => m.type === 'session-ended' && m.sessionId === back.sessionId);
      await moveJob(jobId, 'done', () => {}, { killSession });
      expect(await ended).toMatchObject({ spawnedBy: 'board', jobId });
      back = null;
    } finally {
      restore();
      config.repos = savedRepos;
      config.activeSessions = config.activeSessions.filter(s => s.worktreePath !== worktreePath);
      for (const o of [...orphans.values()]) if (o.worktreePath === worktreePath) orphans.delete(o.id);
      if (back && sessions.has(back.sessionId)) {
        Object.assign(sessions.get(back.sessionId), { repoPath: null, worktreePath: null, jobId: null });
        w.send(JSON.stringify({ type: 'kill', sessionId: back.sessionId }));
      }
      for (const id of [jobId, queuedId]) if (id && allJobs().some(j => j.id === id)) await deleteJob(id, () => {});
      if (name) codenamePool.recycle(name);
      rmSync(worktreePath, { recursive: true, force: true });
      w.close();
    }
  }, 20000);
});

// #137: `git worktree remove` under a 5 s timeout was killed mid-delete on a
// node_modules-sized tree, leaving the orphan listed and its folder half gone.
describe('delete-orphan', () => {
  const open = () => new Promise((res, rej) => {
    const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
  });
  const next = (ws, pred, ms = 8000) => new Promise((res) => {
    const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
    const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
    ws.on('message', h);
  });
  const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
  function orphanWorktree(name) {
    const repo = mkdtempSync(join(tmpdir(), 'a007-del-orphan-'));
    git('init', '-q', repo);
    git('-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'base');
    const worktreePath = join(process.env.AGENT007_WORKTREE_DIR, 'del-orphan', name);
    git('-C', repo, 'worktree', 'add', '-q', worktreePath, '-b', `bill10/${name}`);
    const id = `orphan-${name}`;
    orphans.set(id, { id, name, repoPath: repo, worktreePath, branchName: `bill10/${name}` });
    return { id, repo, worktreePath };
  }
  async function deleteOrphan(id) {
    const w = await open();
    const done = next(w, (m) => m.type === 'notification' && /orphan/i.test(m.message || ''));
    w.send(JSON.stringify({ type: 'delete-orphan', orphanId: id }));
    const note = await done;
    w.close();
    return note;
  }

  it('removes a worktree full of ignored files completely', async () => {
    const { id, repo, worktreePath } = orphanWorktree('del-heavy');
    writeFileSync(join(worktreePath, '.gitignore'), 'node_modules\n');
    for (let d = 0; d < 20; d++) {
      mkdirSync(join(worktreePath, 'node_modules', `p${d}`), { recursive: true });
      for (let f = 0; f < 100; f++) writeFileSync(join(worktreePath, 'node_modules', `p${d}`, `f${f}.js`), 'x');
    }
    expect((await deleteOrphan(id)).level).toBe('info');
    expect(orphans.has(id)).toBe(false);
    expect(existsSync(worktreePath)).toBe(false);
    expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-heavy');
    expect(git('-C', repo, 'branch', '--list')).not.toContain('del-heavy');
  }, 20000);

  it('finishes a worktree whose folder is already gone', async () => {
    const { id, repo, worktreePath } = orphanWorktree('del-half');
    rmSync(worktreePath, { recursive: true, force: true });
    expect((await deleteOrphan(id)).level).toBe('info');
    expect(orphans.has(id)).toBe(false);
    expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-half');
    expect(git('-C', repo, 'branch', '--list')).not.toContain('del-half');
  }, 20000);

  it('finishes a worktree that is partly deleted but still on disk', async () => {
    const { id, repo, worktreePath } = orphanWorktree('del-partial');
    rmSync(join(worktreePath, '.git'));
    expect((await deleteOrphan(id)).level).toBe('info');
    expect(orphans.has(id)).toBe(false);
    expect(existsSync(worktreePath)).toBe(false);
    expect(git('-C', repo, 'worktree', 'list')).not.toContain('del-partial');
  }, 20000);
});

describe('remove-repo', () => {
  const open = () => new Promise((res, rej) => {
    const s = new WebSocket(wsUrl); s.on('open', () => res(s)); s.on('error', rej);
  });
  const next = (ws, pred, ms = 15000) => new Promise((res) => {
    const to = setTimeout(() => { ws.off('message', h); res(null); }, ms);
    const h = (d) => { const m = JSON.parse(d); if (pred(m)) { clearTimeout(to); ws.off('message', h); res(m); } };
    ws.on('message', h);
  });
  const git = (...args) => execFileSync('git', args, { encoding: 'utf8' });
  function makeRepo() {
    const repo = realpathSync(mkdtempSync(join(tmpdir(), 'a007-rm-repo-')));
    git('init', '-q', repo);
    git('-C', repo, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'base');
    git('-C', repo, 'config', 'user.name', 'bill10');
    git('-C', repo, 'config', 'user.email', 't@t');
    return repo;
  }
  async function setup() {
    const repo = makeRepo();
    const w = await open();
    w.send(JSON.stringify({ type: 'add-repo', path: repo }));
    await next(w, m => m.type === 'repos-list' && m.repos.some(r => r.path === repo));
    const spawn = async () => {
      const created = next(w, m => m.type === 'session-created' && m.repoPath === repo);
      w.send(JSON.stringify({ type: 'spawn', command: 'cat', repoPath: repo }));
      return created;
    };
    return { repo, w, spawn };
  }

  it('previews who is lost, and cancelling changes nothing', async () => {
    const { repo, w, spawn } = await setup();
    const a = await spawn(), b = await spawn();
    const wtA = sessions.get(a.sessionId).worktreePath;
    writeFileSync(join(wtA, 'scratch.txt'), 'x');                       // uncommitted
    const wtB = sessions.get(b.sessionId).worktreePath;
    writeFileSync(join(wtB, 'f.txt'), 'x');
    git('-C', wtB, 'add', '-A');
    for (const n of [1, 2]) git('-C', wtB, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', `c${n}`);
    try {
      w.send(JSON.stringify({ type: 'remove-repo-check', path: repo }));
      const preview = await next(w, m => m.type === 'repo-removal-preview');
      expect(preview.slug).toBe(basename(repo));
      const byName = Object.fromEntries(preview.agents.map(x => [x.name, x]));
      expect(byName[a.name]).toMatchObject({ kind: 'agent', uncommitted: true });
      expect(byName[b.name]).toMatchObject({ kind: 'agent', uncommitted: false, unpushed: 2 });
      // Cancel sends nothing further: everything is still here.
      expect(config.repos.some(r => r.path === repo)).toBe(true);
      expect(sessions.has(a.sessionId) && sessions.has(b.sessionId)).toBe(true);
      expect(existsSync(wtA) && existsSync(wtB)).toBe(true);
    } finally {
      w.send(JSON.stringify({ type: 'remove-repo', path: repo }));
      await next(w, m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo));
      w.close();
    }
  }, 40000);

  it('closes its agents, deletes worktrees and orphans, returns the card, and tells every client', async () => {
    const { repo, w, spawn } = await setup();
    const other = await open();
    const a = await spawn(), b = await spawn();
    const wtA = sessions.get(a.sessionId).worktreePath, brA = sessions.get(a.sessionId).branchName;
    const wtB = sessions.get(b.sessionId).worktreePath;
    writeFileSync(join(wtA, 'scratch.txt'), 'x');
    git('-C', wtB, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '--allow-empty', '-m', 'unpushed');
    // An orphan of the same repo, and a card on Agent A.
    const orphanWt = join(process.env.AGENT007_WORKTREE_DIR, 'rm-repo', 'ghost');
    git('-C', repo, 'worktree', 'add', '-q', orphanWt, '-b', 'bill10/ghost');
    orphans.set('orphan-rm-ghost', { id: 'orphan-rm-ghost', name: 'Ghost', repoPath: repo, worktreePath: orphanWt, branchName: 'bill10/ghost' });
    const { job } = addJob({ title: 'rm-repo card', repoPath: repo }, () => {});
    Object.assign(job, { state: 'in-progress', branchName: brA, worktreePath: wtA, agentSessionId: a.sessionId });
    try {
      const otherSees = [];
      other.on('message', d => otherSees.push(JSON.parse(d)));
      w.send(JSON.stringify({ type: 'remove-repo', path: repo }));
      await next(w, m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo));

      expect(config.repos.some(r => r.path === repo)).toBe(false);
      expect(sessions.has(a.sessionId) || sessions.has(b.sessionId)).toBe(false);
      expect(orphans.has('orphan-rm-ghost')).toBe(false);
      expect([...orphans.values()].some(o => o.repoPath === repo)).toBe(false);
      for (const p of [wtA, wtB, orphanWt]) expect(existsSync(p)).toBe(false);
      expect(existsSync(repo)).toBe(true);                                // the folder itself is never touched
      expect(git('-C', repo, 'branch', '--list')).not.toMatch(/bill10\/(?!ghost)\S+|ghost/);
      expect(job).toMatchObject({ state: 'todo', agentSessionId: null, lastError: 'repo removed from Agent 007' });
      await new Promise(r => setTimeout(r, 300));
      expect(otherSees.filter(m => m.type === 'session-ended' && m.closed).map(m => m.sessionId).sort())
        .toEqual([a.sessionId, b.sessionId].sort());
      expect(otherSees.some(m => m.type === 'repos-list' && !m.repos.some(r => r.path === repo))).toBe(true);
    } finally {
      await deleteJob(job.id, () => {});
      other.close(); w.close();
    }
  }, 40000);
});