import os from "node:os";
import path from "node:path";
import fs from "node:fs/promises";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createRuntime, detectTerminalEnvironment, hasMeaningfulUserInput } from "./runtime.js";
import { createSessionId, normalizeState } from "./default-state.js";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createMemoryStore(initialState?: any) {
  let state = normalizeState(initialState);
  let pending = Promise.resolve();
  let saveCalls = 0;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function enqueue(operation: any) {
    const next = pending.then(operation, operation);
    pending = next.catch(() => {});
    return next;
  }

  return {
    getState() {
      return state;
    },
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async replace(nextState: any) {
      return enqueue(async () => {
        state = normalizeState(nextState);
        return state;
      });
    },
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async mutate(mutator: any) {
      return enqueue(async () => {
        const draft = structuredClone(state);
        const result = await mutator(draft);
        state = normalizeState(result || draft);
        return state;
      });
    },
    async save() {
      return enqueue(async () => {
        saveCalls += 1;
        return state;
      });
    },
    get saveCalls() {
      return saveCalls;
    },
  };
}

class FakeSessionManager extends EventEmitter {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare sessions: Map<any, any>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare syncedStates: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare removedProjects: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare closedSessions: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare resizeCalls: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare writeCalls: any[];
  declare stopped: boolean;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare getSessionEnv: any;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  constructor({ getSessionEnv }: any = {}) {
    super();
    this.sessions = new Map();
    this.syncedStates = [];
    this.removedProjects = [];
    this.closedSessions = [];
    this.resizeCalls = [];
    this.writeCalls = [];
    this.stopped = false;
    this.getSessionEnv = typeof getSessionEnv === "function" ? getSessionEnv : null;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  getWorkspace(state: any, projectId = state.activeProjectId) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const project = state.projects.find((item: any) => item.id === projectId) || null;
    if (!project) {
      return null;
    }

    return {
      project,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      sessions: project.panels.map((panel: any) => ({
        sessionId: createSessionId(project.id, panel.id),
        panelId: panel.id,
        title: panel.title,
        command: panel.command,
        launch: panel.launch,
        startup: panel.startup,
        status: this.sessions.get(createSessionId(project.id, panel.id))?.status || "idle",
      })),
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  resolveDefaultSessionId(state: any, projectId = state.activeProjectId) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const project = state.projects.find((item: any) => item.id === projectId) || null;
    if (!project) {
      return null;
    }

    const activePanelId = project.activePanelId || project.panels[0]?.id || null;
    return activePanelId ? createSessionId(project.id, activePanelId) : null;
  }

  // Match the real SessionManager.ensureSession signature: async, returns
  // Promise<session | null>. Production callers chain `.catch()` on the result.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async ensureSession(state: any, sessionId: any) {
    const [projectId, panelId] = String(sessionId).split(":");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const project = state.projects.find((item: any) => item.id === projectId);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const panel = project?.panels.find((item: any) => item.id === panelId);
    if (!project || !panel) {
      return null;
    }

    const session = {
      id: sessionId,
      projectId,
      panelId,
      title: panel.title,
      command: panel.command,
      status: "running",
    };
    this.sessions.set(sessionId, session);
    return session;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async restartSession(state: any, sessionId: any) {
    this.sessions.delete(sessionId);
    this.emit("terminal:exit", { sessionId, exitCode: 0, intentional: true });
    return this.ensureSession(state, sessionId);
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  removeSession(sessionId: any) {
    this.closedSessions.push(sessionId);
    this.sessions.delete(sessionId);
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  removeProjectSessions(projectId: any) {
    this.removedProjects.push(projectId);
    for (const sessionId of [...this.sessions.keys()]) {
      if (sessionId.startsWith(`${projectId}:`)) {
        this.sessions.delete(sessionId);
      }
    }
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  removeWorkspaceSessions(workspaceId: any) {
    return this.removeProjectSessions(workspaceId);
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  resizeSession(sessionId: any, cols: any, rows: any) {
    this.resizeCalls.push({ sessionId, cols, rows });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  writeToSession(sessionId: any, data: any) {
    this.writeCalls.push({ sessionId, data });
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  syncWithState(state: any) {
    this.syncedStates.push(structuredClone(state));
    /* eslint-disable @typescript-eslint/no-explicit-any -- MIGRATION-EXEMPT: test mock state is untyped */
    const validIds = new Set(
      state.projects.flatMap((project: any) =>
        project.panels.map((panel: any) => createSessionId(project.id, panel.id)),
      ),
    );
    /* eslint-enable @typescript-eslint/no-explicit-any */
    for (const sessionId of [...this.sessions.keys()]) {
      if (!validIds.has(sessionId)) {
        this.sessions.delete(sessionId);
      }
    }
  }

  stopAll() {
    this.stopped = true;
    this.sessions.clear();
  }
}

class FakeDockerManager extends EventEmitter {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare snapshot: any;
  declare refreshCount: number;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare actions: any[];

  constructor() {
    super();
    this.snapshot = {
      available: true,
      backend: "host",
      contexts: [],
      containers: [{ ID: "abc123", Names: "api" }],
      lazydocker: { available: true, backend: "host", error: "" },
      error: "",
      lastUpdatedAt: null,
    };
    this.refreshCount = 0;
    this.actions = [];
  }

  getSnapshot() {
    return this.snapshot;
  }

  async refresh() {
    this.refreshCount += 1;
    this.snapshot = { ...this.snapshot, lastUpdatedAt: new Date().toISOString() };
    this.emit("updated", this.snapshot);
    return this.snapshot;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async performAction(action: any, containerId: any) {
    this.actions.push({ action, containerId });
    return this.refresh();
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  findContainer(containerId: any) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    return this.snapshot.containers.find((item: any) => item.ID === containerId) || null;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  createShellLaunch(containerId: any) {
    return { file: "docker", args: ["exec", "-it", containerId, "sh"] };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  createLogsLaunch(containerId: any) {
    return { file: "docker", args: ["logs", "-f", containerId] };
  }

  createLazydockerLaunch() {
    return { file: "lazydocker", args: [] };
  }
}

class FakeGitManager extends EventEmitter {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare snapshots: Map<any, any>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare refreshArgs: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare actions: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare invalidateCalls: any[];

  constructor() {
    super();
    this.snapshots = new Map();
    this.refreshArgs = [];
    this.actions = [];
    this.invalidateCalls = [];
  }

  invalidateSnapshotCache(workspaceId: string | null = null, rootPath: string | null = null) {
    this.invalidateCalls.push({ workspaceId, rootPath });
  }

  getProjectMap() {
    return Object.fromEntries(this.snapshots.entries());
  }

  getSnapshot(projectId: string) {
    return this.snapshots.get(projectId) || null;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async refreshProjects(projects: any[] = []) {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    this.refreshArgs.push(projects.map((project: any) => project.id));
    this.snapshots = new Map(
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      projects.map((project: any) => [
        project.id,
        {
          projectId: project.id,
          cwd: project.cwd || "",
          available: Boolean(project.cwd),
          root: project.cwd || "",
          repository: project.name || project.id,
          branch: "main",
          commitCount: 1,
          dirty: false,
          dirtyCount: 0,
          status: [],
          staged: [],
          unstaged: [],
          untracked: [],
          changes: {
            staged: {
              name: "Staged",
              files: [],
              diffStat: { files: 0, insertions: 0, deletions: 0, renames: 0, deletes: 0 },
            },
            unstaged: {
              name: "Unstaged",
              files: [],
              diffStat: { files: 0, insertions: 0, deletions: 0, renames: 0, deletes: 0 },
            },
            untracked: {
              name: "Untracked",
              files: [],
              diffStat: { files: 0, insertions: 0, deletions: 0, renames: 0, deletes: 0 },
            },
          },
          diffStat: { files: 0, insertions: 0, deletions: 0, renames: 0, deletes: 0 },
          log: [],
          isWorktree: false,
          isMainWorktree: true,
          worktreePath: project.cwd || "",
          mainWorktreePath: project.cwd || "",
          siblingWorktrees: [],
          upstream: "origin/main",
          baseBranch: "main",
          aheadCount: 0,
          behindCount: 0,
          compareWithBase: {
            baseBranch: "main",
            aheadCount: 0,
            behindCount: 0,
            commits: [],
            files: [],
            diffStat: { files: 0, insertions: 0, deletions: 0, renames: 0, deletes: 0 },
          },
          lastFetchAt: null,
          operationState: {
            kind: "idle",
            inProgress: false,
            label: "",
            details: "",
            conflicts: [],
            canContinue: false,
            canAbort: false,
          },
          lazygit: {
            available: project.kind !== "docker",
            backend: project.kind !== "docker" ? "host" : null,
            error: "",
            launch: project.kind !== "docker" ? { file: "lazygit", args: [] } : null,
          },
          error: "",
          lastUpdatedAt: new Date().toISOString(),
        },
      ]),
    );
    this.emit("updated", this.getProjectMap());
    return this.getProjectMap();
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetch(workspace: any, options?: any) {
    this.actions.push({ kind: "fetch", workspaceId: workspace.id, connection: options?.connection ?? null });
    return {
      ok: true,
      summary: "Fetch completed.",
      warnings: [],
      conflicts: [],
      rawOutput: "fetch ok",
      operationState: {
        kind: "idle",
        inProgress: false,
        label: "",
        details: "",
        conflicts: [],
        canContinue: false,
        canAbort: false,
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async mergeIntoCurrent(workspace: any, payload: any) {
    this.actions.push({ kind: "merge", workspaceId: workspace.id, payload });
    return {
      ok: true,
      summary: "Merge completed.",
      warnings: [],
      conflicts: [],
      rawOutput: "merge ok",
      operationState: {
        kind: "idle",
        inProgress: false,
        label: "",
        details: "",
        conflicts: [],
        canContinue: false,
        canAbort: false,
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async rebaseOnto(workspace: any, payload: any) {
    this.actions.push({ kind: "rebase", workspaceId: workspace.id, payload });
    return {
      ok: true,
      summary: "Rebase completed.",
      warnings: [],
      conflicts: [],
      rawOutput: "rebase ok",
      operationState: {
        kind: "idle",
        inProgress: false,
        label: "",
        details: "",
        conflicts: [],
        canContinue: false,
        canAbort: false,
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async continueOperation(workspace: any) {
    this.actions.push({ kind: "continue", workspaceId: workspace.id });
    return {
      ok: true,
      summary: "Continue completed.",
      warnings: [],
      conflicts: [],
      rawOutput: "continue ok",
      operationState: {
        kind: "idle",
        inProgress: false,
        label: "",
        details: "",
        conflicts: [],
        canContinue: false,
        canAbort: false,
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async abortOperation(workspace: any) {
    this.actions.push({ kind: "abort", workspaceId: workspace.id });
    return {
      ok: true,
      summary: "Abort completed.",
      warnings: [],
      conflicts: [],
      rawOutput: "abort ok",
      operationState: {
        kind: "idle",
        inProgress: false,
        label: "",
        details: "",
        conflicts: [],
        canContinue: false,
        canAbort: false,
      },
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async diffPreview(workspace: any, payload: any) {
    this.actions.push({ kind: "diff", workspaceId: workspace.id, payload });
    return {
      ok: true,
      scope: payload.scope || "unstaged",
      path: payload.path,
      diff: "diff --git a/file b/file",
      summary: "",
    };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  createLazygitLaunch(projectId: any) {
    const launch = this.snapshots.get(projectId)?.lazygit?.launch;
    return launch ? { file: launch.file, args: [...launch.args] } : null;
  }
}

class FakeTunnelManager extends EventEmitter {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare snapshot: any;
  declare refreshCount: number;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare startCalls: any[];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  declare stopCalls: any[];

  constructor() {
    super();
    this.snapshot = {
      available: true,
      status: "idle",
      mode: "quick",
      publicUrl: "",
      localUrl: "",
      error: "",
      startedAt: null,
    };
    this.refreshCount = 0;
    this.startCalls = [];
    this.stopCalls = [];
  }

  getSnapshot() {
    return structuredClone(this.snapshot);
  }

  async refreshAvailability() {
    this.refreshCount += 1;
    this.snapshot = { ...this.snapshot, available: true };
    this.emit("updated", this.getSnapshot());
    return this.getSnapshot();
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async startQuickTunnel(localUrl: any) {
    this.startCalls.push(localUrl);
    this.snapshot = {
      ...this.snapshot,
      available: true,
      status: "connected",
      localUrl,
      publicUrl: "https://termhub.example.com",
      error: "",
      startedAt: new Date().toISOString(),
    };
    this.emit("updated", this.getSnapshot());
    return this.getSnapshot();
  }

  async stop(options = {}) {
    this.stopCalls.push(options);
    this.snapshot = {
      ...this.snapshot,
      status: "idle",
      publicUrl: "",
      localUrl: "",
      error: "",
    };
    this.emit("updated", this.getSnapshot());
    return this.getSnapshot();
  }

  applyExternalError(message: string) {
    this.snapshot = { ...this.snapshot, status: "idle", error: message || "" };
    this.emit("updated", this.getSnapshot());
  }

  applyExternalConnecting() {
    this.snapshot = { ...this.snapshot, status: "connecting", error: "" };
    this.emit("updated", this.getSnapshot());
  }
}

function createPluginManagerStub() {
  return Promise.resolve({
    getPlugins() {
      return [
        {
          id: "system-monitor",
          name: "System Monitor",
          kind: "terminal",
          icon: "SM",
          error: null,
          workspaceDefaults: {
            name: "System Monitor",
            icon: "SM",
            kind: "terminal",
            panels: [{ id: "monitor", title: "Monitor", command: "" }],
          },
        },
      ];
    },
    getWorkspaceTemplate() {
      return null;
    },
    async stopAll() {},
  });
}

async function createFixture({
  initialState,
  execFileTextImpl,
  dependencies = {},
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MIGRATION-EXEMPT: test fixture accepts open-ended initial state and dependencies
}: { initialState?: any; execFileTextImpl?: any; dependencies?: any } = {}) {
  const userDataPath = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-runtime-"));
  const store = createMemoryStore(initialState);
  const sessionManager = new FakeSessionManager();
  const docker = new FakeDockerManager();
  const git = new FakeGitManager();
  const tunnel = new FakeTunnelManager();
  const reviewBridgeStore = {
    getPullRequestContext: vi.fn(() => null),
    saveDraftResponse: vi.fn().mockResolvedValue(null),
    queueDraftResponse: vi.fn().mockResolvedValue(null),
    syncPendingDrafts: vi.fn().mockResolvedValue(null),
    close: vi.fn().mockResolvedValue(undefined),
  };
  const execFileText = execFileTextImpl || vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
  const checkRemoteOrigin = vi.fn().mockResolvedValue(undefined);

  const runtime = await createRuntime({
    userDataPath,
    builtinPluginsDir: null as unknown as string,
    getThemeSource: () => "light",
    dependencies: {
      createStore: async () => store,

      SessionManager: class extends FakeSessionManager {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        constructor(opts: any) {
          super();
          sessionManager.getSessionEnv = typeof opts?.getSessionEnv === "function" ? opts.getSessionEnv : null;
          return sessionManager;
        }
      },
      DockerManager: class extends FakeDockerManager {
        constructor() {
          super();
          return docker;
        }
      },
      GitManager: class extends FakeGitManager {
        constructor() {
          super();
          return git;
        }
      },
      CloudflareTunnelManager: class extends FakeTunnelManager {
        constructor() {
          super();
          return tunnel;
        }
      },
      createPluginManager: createPluginManagerStub,
      createReviewBridgeStore: async () => reviewBridgeStore,
      execFileText,
      checkRemoteOrigin,
      ...dependencies,
    },
  });

  return {
    runtime,
    store,
    sessionManager,
    docker,
    git,
    tunnel,
    reviewBridgeStore,
    execFileText,
    checkRemoteOrigin,
    userDataPath,
  };
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fixtures: any[] = [];
const tempPaths: string[] = [];

afterEach(async () => {
  await Promise.all(
    fixtures.splice(0).map(async (fixture) => {
      await fixture.runtime.stop();
      await fs.rm(fixture.userDataPath, { recursive: true, force: true });
    }),
  );
  await Promise.all(tempPaths.splice(0).map((targetPath) => fs.rm(targetPath, { recursive: true, force: true })));
});

describe("detectTerminalEnvironment", () => {
  test("reports conpty for supported Windows builds", () => {
    expect(detectTerminalEnvironment({ platform: "win32", release: "10.0.26100" })).toEqual({
      platform: "win32",
      windowsPty: {
        backend: "conpty",
        buildNumber: 26100,
      },
    });
  });

  test("reports winpty for legacy Windows builds", () => {
    expect(detectTerminalEnvironment({ platform: "win32", release: "10.0.17763" })).toEqual({
      platform: "win32",
      windowsPty: {
        backend: "winpty",
        buildNumber: 17763,
      },
    });
  });

  test("omits windowsPty outside Windows", () => {
    expect(detectTerminalEnvironment({ platform: "linux", release: "6.8.0" })).toEqual({
      platform: "linux",
    });
  });
});

describe("hasMeaningfulUserInput", () => {
  test("returns false for empty/undefined input", () => {
    expect(hasMeaningfulUserInput("")).toBe(false);
    expect(hasMeaningfulUserInput(null)).toBe(false);
    expect(hasMeaningfulUserInput(undefined)).toBe(false);
  });

  test("returns false for SGR mouse events (click, drag, wheel)", () => {
    expect(hasMeaningfulUserInput("\x1b[<0;40;12M")).toBe(false);
    expect(hasMeaningfulUserInput("\x1b[<0;40;12m")).toBe(false);
    expect(hasMeaningfulUserInput("\x1b[<64;10;5M")).toBe(false); // wheel up
  });

  test("returns false for X10 mouse events", () => {
    expect(hasMeaningfulUserInput("\x1b[M   ")).toBe(false);
  });

  test("returns false for focus in/out", () => {
    expect(hasMeaningfulUserInput("\x1b[I")).toBe(false);
    expect(hasMeaningfulUserInput("\x1b[O")).toBe(false);
  });

  test("returns false for concatenated passive sequences", () => {
    expect(hasMeaningfulUserInput("\x1b[I\x1b[<0;40;12M\x1b[<0;40;12m\x1b[O")).toBe(false);
  });

  test("returns true for typed characters", () => {
    expect(hasMeaningfulUserInput("a")).toBe(true);
    expect(hasMeaningfulUserInput("hello")).toBe(true);
  });

  test("returns true for Enter and control chars", () => {
    expect(hasMeaningfulUserInput("\r")).toBe(true);
    expect(hasMeaningfulUserInput("\x03")).toBe(true); // Ctrl+C
  });

  test("returns true for arrow keys and function keys", () => {
    expect(hasMeaningfulUserInput("\x1b[A")).toBe(true); // up arrow
    expect(hasMeaningfulUserInput("\x1b[B")).toBe(true); // down arrow
    expect(hasMeaningfulUserInput("\x1bOP")).toBe(true); // F1
  });

  test("returns true when mouse event is followed by typed text", () => {
    expect(hasMeaningfulUserInput("\x1b[<0;40;12Mabc")).toBe(true);
  });
});

describe("runtime integration", () => {
  test("includes terminal environment in payload", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    expect(fixture.runtime.getPayload().environment).toMatchObject(detectTerminalEnvironment());
  });

  test("keeps a bounded terminal replay tail for renderer startup attach", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "hello " });
    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "prompt\r\n" });

    expect(fixture.runtime.getTerminalReplay("backend:shell")).toEqual({ data: "hello prompt\r\n" });

    await fixture.runtime.restartSession("backend:shell");
    expect(fixture.runtime.getTerminalReplay("backend:shell")).toEqual({ data: "" });
  });

  test("closes the review bridge store on shutdown", async () => {
    const fixture = await createFixture();
    await fixture.runtime.stop();

    expect(fixture.reviewBridgeStore.close).toHaveBeenCalledTimes(1);
    await fs.rm(fixture.userDataPath, { recursive: true, force: true });
  });

  test("includes review bridge context for active azure review workspaces", async () => {
    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "workspace-review",
        workspaces: [
          {
            id: "workspace-review",
            name: "Review",
            kind: "terminal",
            cwd: "/tmp/review",
            activePanelId: "shell",
            review: {
              provider: "azure-devops",
              prKey: "ado-main:repo-1:123",
            },
            panels: [{ id: "shell", title: "Shell", command: "", startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    (fixture.reviewBridgeStore.getPullRequestContext as any).mockReturnValue({
      prKey: "ado-main:repo-1:123",
      briefMarkdownPath: "/tmp/review/agent-brief.md",
      databasePath: "/tmp/review/review-bridge.db",
      tasks: [{ taskKey: "ado-main:repo-1:123:thread:10" }],
      drafts: [],
      syncQueue: [],
    });

    const payload = fixture.runtime.getPayload();

    expect(payload.reviewBridge.pullRequests["ado-main:repo-1:123"]).toMatchObject({
      briefMarkdownPath: "/tmp/review/agent-brief.md",
      databasePath: "/tmp/review/review-bridge.db",
    });
  });

  test("repairs persisted azure review workspaces that lost review metadata", async () => {
    class FakeAzureManager extends EventEmitter {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      declare snapshot: any;

      constructor() {
        super();
        this.snapshot = {
          connections: [],
          inbox: {
            needsMyReview: [],
            myPullRequests: [],
            recentlyUpdated: [],
            needsAttention: [],
          },
          trackedPullRequests: {},
          pullRequests: {
            "ado-main:repo-1:123": {
              prKey: "ado-main:repo-1:123",
              connectionId: "ado-main",
              orgUrl: "https://dev.azure.com/acme",
              project: { id: "project-1", name: "Platform" },
              repository: {
                id: "repo-1",
                name: "web-app",
                remoteUrl: "https://dev.azure.com/acme/Platform/_git/web-app",
              },
              pullRequest: {
                id: 123,
                title: "Fix login redirect",
                status: "active",
                sourceRefName: "refs/heads/feature/login-fix",
                targetRefName: "refs/heads/main",
              },
              role: "reviewer",
            },
          },
          sync: { running: false, lastStartedAt: null, lastCompletedAt: null },
        };
      }

      getSnapshot() {
        return structuredClone(this.snapshot);
      }

      async sync() {
        return this.getSnapshot();
      }

      stopPolling() {}

      configurePolling() {}

      findConnection() {
        return { id: "ado-main", reviewRoot: "C:/reviews", login: "me@example.com", tokenRef: "cred:ado-main" };
      }

      buildManagedReviewPaths() {
        return {
          parentWorkspaceId: "azure-root",
          reviewRoot: "C:/reviews",
          cacheRepoPath: "C:/reviews/repos/ado-main/repo-1",
          rootPath: "C:/reviews/reviews/ado-main/pr-123",
        };
      }

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      buildReviewMetadata(summary: any, checkout: any, mode: any, extra: any = {}) {
        return {
          provider: "azure-devops",
          prKey: summary.prKey,
          connectionId: summary.connectionId,
          orgUrl: summary.orgUrl,
          parentWorkspaceId: extra.parentWorkspaceId || "",
          project: summary.project,
          repository: summary.repository,
          pullRequest: summary.pullRequest,
          role: summary.role,
          checkout: {
            mode,
            rootPath: checkout.rootPath,
            cacheRepoPath: checkout.cacheRepoPath,
          },
        };
      }
    }

    const azureReviewStore = {
      getState: () => ({ trackedPullRequests: {}, connections: {} }),
      getTrackedPullRequest: () => null,
      upsertTrackedPullRequest: vi.fn().mockResolvedValue(undefined),
      upsertConnectionState: vi.fn().mockResolvedValue(undefined),
    };

    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "workspace-review",
        workspaces: [
          {
            id: "azure-root",
            name: "Azure DevOps",
            kind: "azure",
            cwd: "C:/reviews",
            profileId: "default",
            panels: [],
          },
          {
            id: "workspace-review",
            name: "web-app PR #123",
            kind: "terminal",
            cwd: "C:/reviews/reviews/ado-main/pr-123",
            notes: "Azure DevOps review workspace for web-app PR #123",
            profileId: "default",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", startup: "default" }],
          },
        ],
      },
      dependencies: {
        AzureDevOpsManager: FakeAzureManager,
        createAzureReviewStore: async () => azureReviewStore,
      },
    });
    fixtures.push(fixture);

    const repairedWorkspace = fixture.runtime
      .getPayload()
      .appState.workspaces.find((workspace) => workspace.id === "workspace-review");
    expect(repairedWorkspace?.review).toMatchObject({
      provider: "azure-devops",
      parentWorkspaceId: "azure-root",
    });
    expect(azureReviewStore.upsertTrackedPullRequest).toHaveBeenCalledWith("ado-main:repo-1:123", {
      reviewWorkspaceId: "workspace-review",
    });
  });

  test("repairs persisted inactive azure review workspaces from tracked pull requests", async () => {
    class FakeAzureManager extends EventEmitter {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      declare snapshot: any;

      constructor() {
        super();
        this.snapshot = {
          connections: [],
          inbox: {
            needsMyReview: [],
            myPullRequests: [],
            recentlyUpdated: [],
            needsAttention: [],
          },
          trackedPullRequests: {},
          pullRequests: {},
          sync: { running: false, lastStartedAt: null, lastCompletedAt: null },
        };
      }

      getSnapshot() {
        return structuredClone(this.snapshot);
      }

      async sync() {
        return this.getSnapshot();
      }

      stopPolling() {}

      configurePolling() {}
    }

    const azureReviewStore = {
      getState: () => ({
        trackedPullRequests: {
          "ado-main:repo-1:123": {
            key: "ado-main:repo-1:123",
            connectionId: "ado-main",
            pullRequestId: 123,
            repositoryId: "repo-1",
            repositoryName: "web-app",
            projectName: "Platform",
          },
        },
        connections: {},
      }),
      getTrackedPullRequest: () => null,
      upsertTrackedPullRequest: vi.fn().mockResolvedValue(undefined),
      upsertConnectionState: vi.fn().mockResolvedValue(undefined),
    };

    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "workspace-review",
        settings: {
          integrations: {
            azureDevops: {
              enabled: true,
              reviewRoot: "C:/reviews",
              defaultPollSeconds: 120,
              connections: [
                {
                  id: "ado-main",
                  enabled: true,
                  orgUrl: "https://dev.azure.com/acme",
                  login: "me@example.com",
                  tokenRef: "cred:ado-main",
                  reviewRoot: "C:/reviews",
                },
              ],
            },
          },
        },
        workspaces: [
          {
            id: "azure-root",
            name: "Azure DevOps",
            kind: "azure",
            cwd: "C:/reviews",
            profileId: "default",
            panels: [],
          },
          {
            id: "workspace-review",
            name: "web-app PR #123",
            kind: "terminal",
            cwd: "C:/reviews/reviews/ado-main-a2ae23c8c9/pr-123",
            notes: "Azure DevOps review workspace for web-app PR #123",
            profileId: "default",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", startup: "default" }],
          },
        ],
      },
      dependencies: {
        AzureDevOpsManager: FakeAzureManager,
        createAzureReviewStore: async () => azureReviewStore,
      },
    });
    fixtures.push(fixture);

    const repairedWorkspace = fixture.runtime
      .getPayload()
      .appState.workspaces.find((workspace) => workspace.id === "workspace-review");
    expect(repairedWorkspace?.review).toMatchObject({
      provider: "azure-devops",
      prKey: "ado-main:repo-1:123",
      parentWorkspaceId: "azure-root",
    });
  });

  test("raises and clears project alerts for background terminal exits", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            kind: "terminal",
            cwd: "/tmp/frontend",
            activePanelId: "claude",
            panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
          },
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            activePanelId: "shell",
            panels: [
              { id: "shell", title: "Shell", command: "", shell: true, startup: "default" },
              // alertsForceOn: shell-completion alerts are globally suppressed by
              // notifications.agentsOnly (default true). This panel opts back in
              // explicitly because the test asserts the exit-alert fires.
              {
                id: "tests",
                title: "Tests",
                command: "npm test",
                shell: true,
                startup: "manual",
                alertsForceOn: true,
              },
            ],
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });

    // Create signal and mark session as user-interactive
    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:tests", data: "$ " });
    fixture.runtime.writeToSession("backend:tests", "npm test\r");

    fixture.sessionManager.emit("terminal:exit", {
      sessionId: "backend:tests",
      exitCode: 2,
      intentional: false,
    });

    expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({
      count: 1,
    });
    expect(fixture.runtime.getPayload().attention.byProject.backend.alerts[0]).toMatchObject({
      panelId: "tests",
      exitCode: 2,
    });

    // Switching to the tab should NOT clear the alert — it stays until user types
    const payload = await fixture.runtime.activateSession("backend:tests");

    expect(payload.attention.byProject.backend).toMatchObject({ count: 1 });
    expect(payload.appState.activeProjectId).toBe("backend");
    expect(payload.workspace.project.activePanelId).toBe("tests");

    // Typing before 15s should NOT clear the alert
    fixture.runtime.writeToSession("backend:tests", "x");
    expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

    // Typing after 15s should clear the alert
    const originalNow = Date.now;
    Date.now = () => originalNow() + 16_000;
    try {
      fixture.runtime.writeToSession("backend:tests", "x");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      Date.now = originalNow;
    }
  });

  test("agentsOnly suppresses shell-exit alert (default behaviour)", async () => {
    // No alertsForceOn on the panel; default settings have agentsOnly=true.
    // A non-agent shell session that exits should NOT raise a project alert.
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            activePanelId: "tests",
            panels: [{ id: "tests", title: "Tests", command: "npm test", shell: true, startup: "manual" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.syncAttentionContext({ visibleSessionIds: [] });

    // Mark session as user-interactive (alerts only fire for sessions the user touched).
    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:tests", data: "$ " });
    fixture.runtime.writeToSession("backend:tests", "npm test\r");
    fixture.sessionManager.emit("terminal:exit", { sessionId: "backend:tests", exitCode: 1, intentional: false });

    expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
  });

  test("agentsOnly=false (legacy) still raises shell-exit alert", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        settings: { notifications: { agentsOnly: false } },
        projects: [
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            activePanelId: "tests",
            panels: [{ id: "tests", title: "Tests", command: "npm test", shell: true, startup: "manual" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.syncAttentionContext({ visibleSessionIds: [] });

    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:tests", data: "$ " });
    fixture.runtime.writeToSession("backend:tests", "npm test\r");
    fixture.sessionManager.emit("terminal:exit", { sessionId: "backend:tests", exitCode: 1, intentional: false });

    expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
  });

  test("does not raise exit alerts for sessions without user input", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });

    // Shell produces output but user never types
    fixture.sessionManager.emit("terminal:data", { sessionId: "backend:tests", data: "$ " });

    fixture.sessionManager.emit("terminal:exit", {
      sessionId: "backend:tests",
      exitCode: 0,
      intentional: false,
    });

    expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
  });

  test("does not raise alerts for terminals that are currently visible", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["backend:tests"] });
    fixture.sessionManager.emit("terminal:exit", {
      sessionId: "backend:tests",
      exitCode: 2,
      intentional: false,
    });

    expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
  });

  test("does not raise alerts for plugin-backed terminal projects", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            icon: "FE",
            color: "#ffa424",
            kind: "terminal",
            source: "manual",
            pluginId: "",
            cwd: "/tmp/frontend",
            activePanelId: "dev",
            panels: [{ id: "dev", title: "Dev", command: "npm run dev", shell: true, startup: "default" }],
          },
          {
            id: "system-monitor",
            name: "System Monitor",
            icon: "SM",
            color: "#e0a040",
            kind: "terminal",
            source: "plugin",
            pluginId: "system-monitor",
            cwd: "/tmp/system-monitor",
            activePanelId: "monitor",
            panels: [{ id: "monitor", title: "Monitor", command: "btm", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    fixture.sessionManager.emit("terminal:exit", {
      sessionId: "system-monitor:monitor",
      exitCode: 2,
      intentional: false,
    });

    expect(fixture.runtime.getPayload().attention.byProject["system-monitor"]).toBeUndefined();
  });

  test("raises waiting alerts when an agent terminal returns to prompt after work", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              icon: "FE",
              color: "#ffa424",
              kind: "terminal",
              source: "manual",
              pluginId: "",
              cwd: "/tmp/frontend",
              activePanelId: "codex",
              panels: [{ id: "codex", title: "Codex", command: "codex", shell: true, startup: "default" }],
            },
          ],
          settings: {
            notifications: { agentHook: false },
          },
        },
      });
      fixtures.push(fixture);

      // Advance past the per-session cooldown (15s) — the signal is created
      // with lastAlertAt=Date.now() to suppress alerts from buffer replay.
      // Send a dummy event first to create the signal, then advance time.
      fixture.sessionManager.emit("terminal:data", { sessionId: "frontend:codex", data: "" });
      await vi.advanceTimersByTimeAsync(16_000);

      // Simulate user input so hasUserInput gate is satisfied
      fixture.runtime.writeToSession("frontend:codex", "fix the bug\r");

      // Agent output must contain a keyword from AGENT_OUTPUT_RE to set agentLike=true
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:codex",
        data: "Codex is planning changes\r\nApplying patch\r\n",
      });
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:codex",
        data: "PS C:\\repo> ",
      });

      // Agent sessions use a longer quiet period before raising prompt-returned
      // alerts. Default after Phase 1 is 45s. Advance past it.
      await vi.advanceTimersByTimeAsync(46_000);

      expect(fixture.runtime.getPayload().attention.byProject.frontend).toMatchObject({
        count: 1,
      });
      expect(fixture.runtime.getPayload().attention.byProject.frontend.alerts[0]).toMatchObject({
        panelId: "codex",
        kind: "waiting",
        detail: "prompt-returned",
        tier: 3,
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("does not raise waiting alerts for plugin-backed terminal projects", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "system-monitor",
          projects: [
            {
              id: "system-monitor",
              name: "System Monitor",
              icon: "SM",
              color: "#e0a040",
              kind: "terminal",
              source: "plugin",
              pluginId: "system-monitor",
              cwd: "/tmp/system-monitor",
              activePanelId: "monitor",
              panels: [{ id: "monitor", title: "Monitor", command: "codex", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      fixture.sessionManager.emit("terminal:data", {
        sessionId: "system-monitor:monitor",
        data: "Working...\r\n",
      });
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "system-monitor:monitor",
        data: "PS C:\\repo> ",
      });

      await vi.advanceTimersByTimeAsync(950);

      expect(fixture.runtime.getPayload().attention.byProject["system-monitor"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("respects custom agentQuietMs from notification settings", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
          ],
          settings: {
            notifications: {
              agentHook: false,
              agentQuietMs: 30_000,
              agentQuietFastMs: 15_000,
              alertCooldownMs: 15_000,
            },
          },
        },
      });
      fixtures.push(fixture);

      fixture.sessionManager.emit("terminal:data", { sessionId: "frontend:claude", data: "" });
      await vi.advanceTimersByTimeAsync(16_000);

      fixture.runtime.writeToSession("frontend:claude", "fix the bug\r");
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:claude",
        data: "Claude is working\r\n",
      });
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:claude",
        data: "> ",
      });

      // Default 20s would have fired — but custom setting is 30s
      await vi.advanceTimersByTimeAsync(21_000);
      expect(fixture.runtime.getPayload().attention.byProject.frontend).toBeUndefined();

      // After 30s total silence, alert should fire
      await vi.advanceTimersByTimeAsync(10_000);
      expect(fixture.runtime.getPayload().attention.byProject.frontend).toMatchObject({ count: 1 });
    } finally {
      vi.useRealTimers();
    }
  });

  test("agent silence timeout requires idle-looking last line to raise alert", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      fixture.sessionManager.emit("terminal:data", { sessionId: "frontend:claude", data: "" });
      await vi.advanceTimersByTimeAsync(16_000);

      fixture.runtime.writeToSession("frontend:claude", "fix the bug\r");
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:claude",
        data: "Claude is working on something complex\r\n",
      });
      // Last line looks like mid-stream output, not an idle prompt
      fixture.sessionManager.emit("terminal:data", {
        sessionId: "frontend:claude",
        data: "Processing file src/app.js...\r\n",
      });

      await vi.advanceTimersByTimeAsync(21_000);
      // Should NOT raise alert because last line doesn't look like idle prompt
      expect(fixture.runtime.getPayload().attention.byProject.frontend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("notification settings are included in default state", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    const state = fixture.store.getState();
    expect(state.settings.notifications).toBeDefined();
    // Phase 1 raised defaults — 900→2500 and 20s→45s.
    expect(state.settings.notifications.promptQuietMs).toBe(2500);
    expect(state.settings.notifications.agentQuietMs).toBe(45_000);
    expect(state.settings.notifications.agentQuietFastMs).toBe(25_000);
    expect(state.settings.notifications.alertCooldownMs).toBe(15_000);
    expect(state.settings.notifications.shellIntegration).toBe(true);
    expect(state.settings.notifications.agentHook).toBe(true);
    expect(state.settings.notifications.userInteractionGraceMs).toBe(10_000);
    expect(state.settings.notifications.debug).toBe(false);
  });

  test("agentNotifyHook info is included in payload", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    const payload = fixture.runtime.getPayload();
    expect(payload.agentNotifyHook).toBeDefined();
    expect(payload.agentNotifyHook.enabled).toBe(true);
    expect(payload.agentNotifyHook.port).toBeGreaterThan(0);
  });

  test("notify server is not started when agentHook is disabled", async () => {
    const fixture = await createFixture({
      initialState: {
        settings: {
          notifications: { agentHook: false },
        },
      },
    });
    fixtures.push(fixture);

    const payload = fixture.runtime.getPayload();
    expect(payload.agentNotifyHook.enabled).toBe(false);
    expect(payload.agentNotifyHook.port).toBeNull();
  });

  test("notifyAgentHook raises instant alert for idle_prompt", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      // Make the backend tab NOT visible, frontend IS visible
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });

      // Create signal for backend:shell, advance past initial cooldown
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);

      // User input arms the alert system
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // Simulate Claude Code finishing — calls the hook
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");

      const payload = fixture.runtime.getPayload();
      expect(payload.attention.byProject.backend).toMatchObject({ count: 1 });
      expect(payload.attention.byProject.backend.alerts[0]).toMatchObject({
        panelId: "shell",
        kind: "waiting",
        detail: "hook:Notification:idle_prompt",
        tier: 1,
        urgency: "normal",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook raises alert with permission detail for permission_prompt", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");

      expect(fixture.runtime.getPayload().attention.byProject.backend.alerts[0]).toMatchObject({
        detail: "hook:Notification:permission_prompt",
        tier: 1,
        urgency: "urgent",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook does not alert without user input", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      // Create signal but NO user input
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");

      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook does not alert for visible sessions", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      // Session IS visible
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "frontend:claude", data: "" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("frontend:claude", "fix bug\r");

      fixture.runtime.notifyAgentHook("frontend:claude", "idle_prompt");

      expect(fixture.runtime.getPayload().attention.byProject.frontend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook respects alert cooldown", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      // Signal starts with lastAlertAt = Date.now() (initial cooldown), so advance past it
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // First alert succeeds
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      // Clear the alert, simulate user responding
      fixture.runtime.clearAllAttention();
      fixture.runtime.writeToSession("backend:shell", "yes\r");

      // Second alert within cooldown — should be suppressed
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();

      // After cooldown passes, alert should work again
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAllAttention does not silence fresh sessions that never alerted", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      // Advance past the initial-warmup cooldown.
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // User clicks "Clear all" before any alert ever fired on this session.
      // Previously this seeded lastAlertAt=now and silenced the next ~15s
      // worth of valid hooks. With the fix, fresh signals are unaffected.
      fixture.runtime.clearAllAttention();

      // A hook arriving immediately after clear should still raise an alert.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook ignores irrelevant notification types", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
            {
              id: "backend",
              name: "Backend",
              kind: "terminal",
              cwd: "/tmp/backend",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      fixture.runtime.notifyAgentHook("backend:shell", "auth_success");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("notifyAgentHook ignores unknown session IDs", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    // Should not throw
    fixture.runtime.notifyAgentHook("nonexistent:panel", "idle_prompt");
    expect(fixture.runtime.getPayload().attention.byProject.nonexistent).toBeUndefined();
  });

  test("updateSettings toggles notify server on and off", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    // Initially enabled
    expect(fixture.runtime.getPayload().agentNotifyHook.enabled).toBe(true);
    const initialPort = fixture.runtime.getPayload().agentNotifyHook.port;
    expect(initialPort).toBeGreaterThan(0);

    // Disable
    await fixture.runtime.updateSettings({
      notifications: { agentHook: false },
    });
    expect(fixture.runtime.getPayload().agentNotifyHook.enabled).toBe(false);
    expect(fixture.runtime.getPayload().agentNotifyHook.port).toBeNull();

    // Re-enable
    await fixture.runtime.updateSettings({
      notifications: { agentHook: true },
    });
    expect(fixture.runtime.getPayload().agentNotifyHook.enabled).toBe(true);
    expect(fixture.runtime.getPayload().agentNotifyHook.port).toBeGreaterThan(0);
  });

  test("STRIDETERM_NOTIFY_URL is injected into session env when notify server is running", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "proj",
        projects: [
          {
            id: "proj",
            name: "Proj",
            kind: "terminal",
            cwd: "/tmp/proj",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    // Notify server should be running
    const payload = fixture.runtime.getPayload();
    expect(payload.agentNotifyHook.enabled).toBe(true);

    // getSessionEnv callback should be captured by FakeSessionManager
    expect(fixture.sessionManager.getSessionEnv).toBeTypeOf("function");

    const env = fixture.sessionManager.getSessionEnv({
      workspace: { id: "proj", cwd: "/tmp/proj" },
      sessionId: "proj:shell",
    });
    expect(env.STRIDETERM_NOTIFY_URL).toBeDefined();
    expect(env.STRIDETERM_NOTIFY_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/notify\?sid=proj%3Ashell&secret=.+$/);
  });

  test("STRIDETERM_NOTIFY_URL is not injected when notify server is disabled", async () => {
    const fixture = await createFixture({
      initialState: {
        settings: {
          notifications: { agentHook: false },
        },
        activeProjectId: "proj",
        projects: [
          {
            id: "proj",
            name: "Proj",
            kind: "terminal",
            cwd: "/tmp/proj",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    expect(fixture.runtime.getPayload().agentNotifyHook.enabled).toBe(false);
    expect(fixture.sessionManager.getSessionEnv).toBeTypeOf("function");

    const env = fixture.sessionManager.getSessionEnv({
      workspace: { id: "proj", cwd: "/tmp/proj" },
      sessionId: "proj:shell",
    });
    expect(env.STRIDETERM_NOTIFY_URL).toBeUndefined();
  });

  test("restarts cloudflare tunnel and emits remote config changes when settings change", async () => {
    // remoteAccess defaults to disabled — this test covers the running-server
    // restart path, so seed the fixture with an enabled connection.
    const fixture = await createFixture({
      initialState: {
        settings: {
          remoteAccess: { enabled: true, host: "127.0.0.1", port: 43123, token: "abc" },
        },
      },
    });
    fixtures.push(fixture);
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const configChanges: any[] = [];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    fixture.runtime.on("remote:config-changed", (payload: any) => configChanges.push(payload));

    fixture.tunnel.snapshot.status = "connected";
    const result = await fixture.runtime.updateSettings({
      remoteAccess: {
        port: 49999,
      },
    });

    expect(result.remoteAccessChanged).toBe(true);
    expect(fixture.checkRemoteOrigin).toHaveBeenCalledWith("http://127.0.0.1:49999");
    expect(fixture.tunnel.startCalls).toEqual(["http://127.0.0.1:49999"]);
    expect(configChanges).toHaveLength(1);
    expect(configChanges[0].port).toBe(49999);

    await fixture.runtime.updateSettings({
      remoteAccess: {
        enabled: false,
      },
    });

    expect(fixture.tunnel.stopCalls.at(-1)).toMatchObject({
      preserveAvailability: true,
      quiet: true,
    });
  });

  test("targets the configured remote host when creating a cloudflare tunnel", async () => {
    const fixture = await createFixture({
      initialState: {
        settings: {
          remoteAccess: {
            enabled: true,
            host: "192.168.1.25",
            port: 43123,
            token: "abc",
          },
        },
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.createCloudflareTunnel();

    expect(fixture.checkRemoteOrigin).toHaveBeenCalledWith("http://192.168.1.25:43123");
    expect(fixture.tunnel.startCalls).toEqual(["http://192.168.1.25:43123"]);
  });

  test("opens docker session as a new panel and ensures a terminal session exists", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "docker",
        projects: [
          {
            id: "docker",
            name: "Docker",
            kind: "docker",
            cwd: "/tmp/docker",
            panels: [],
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.openDockerSession({
      projectId: "docker",
      containerId: "abc123",
      mode: "logs",
    });

    expect(payload.appState.activeProjectId).toBe("docker");
    expect(payload.workspace.project.activePanelId).toBe("logs-abc123");
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    expect(payload.workspace.project.panels.some((panel: any) => panel.id === "logs-abc123")).toBe(true);
    expect(fixture.sessionManager.sessions.has("docker:logs-abc123")).toBe(true);
    expect(fixture.sessionManager.syncedStates).not.toHaveLength(0);
  });

  test("creates git worktree, updates gitignore, and adds a cloned project", async () => {
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-project-"));
    tempPaths.push(projectRoot);
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            icon: "FE",
            color: "#ffa424",
            kind: "terminal",
            cwd: projectRoot,
            activePanelId: "dev",
            panels: [
              { id: "dev", title: "Dev", command: "npm run dev", shell: true, startup: "default" },
              { id: "tests", title: "Tests", command: "npm test", shell: true, startup: "manual" },
            ],
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.createWorktree({
      projectId: "frontend",
      name: "feature-x",
    });

    expect(fixture.execFileText).toHaveBeenCalledWith(
      "git",
      ["worktree", "add", path.join(projectRoot, ".strideterm", "tree", "feature-x"), "-b", "feature-x"],
      { cwd: projectRoot },
    );
    const gitignore = await fs.readFile(path.join(projectRoot, ".gitignore"), "utf8");
    expect(gitignore).toContain(".strideterm/");
    const projectWorkspaces = payload.appState.projects!.filter((project) => project.kind !== "azure");
    expect(projectWorkspaces).toHaveLength(2);
    expect(payload.appState.activeProjectId).toBe(projectWorkspaces[1].id);
    expect(projectWorkspaces[1].cwd).toBe(path.join(projectRoot, ".strideterm", "tree", "feature-x"));
  });

  test("createWorktree mirrors active workspace into the caller's window slot", async () => {
    // Guards the slot-mirroring fix for the UI-flicker bug: without this,
    // creating a worktree only writes global activeWorkspaceId, the
    // per-window slot stays on the parent workspace, and the frontend
    // selector (slot-first) keeps showing the old workspace as active.
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-worktree-slot-"));
    tempPaths.push(projectRoot);
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            kind: "terminal",
            cwd: projectRoot,
            profileId: "default",
            activePanelId: "dev",
            panels: [{ id: "dev", title: "Dev", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "default",
            activeWorkspaceId: "frontend",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.createWorktree({ projectId: "frontend", name: "feature-slot" }, "win-1");

    const child = payload.appState.projects!.find((p) => p.name === "Frontend / feature-slot");
    expect(child).toBeDefined();
    expect(payload.appState.activeProjectId).toBe(child!.id);
    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.activeWorkspaceId).toBe(child!.id);
  });

  test("createWorktree leaves window slot untouched when no windowId is given", async () => {
    // Legacy callers (and the few server paths that can't resolve a bound
    // window) must keep working — slot-aware mirroring is opt-in via the
    // windowId parameter, not a behavior change for global activation.
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-worktree-noslot-"));
    tempPaths.push(projectRoot);
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            kind: "terminal",
            cwd: projectRoot,
            profileId: "default",
            activePanelId: "dev",
            panels: [{ id: "dev", title: "Dev", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "default",
            activeWorkspaceId: "frontend",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.createWorktree({ projectId: "frontend", name: "feature-noslot" });

    const child = payload.appState.projects!.find((p) => p.name === "Frontend / feature-noslot");
    expect(child).toBeDefined();
    expect(payload.appState.activeProjectId).toBe(child!.id);
    // Slot was NOT updated — this matches the legacy behavior the bug
    // reproduces; the slot-aware fix opts into the new behavior via windowId.
    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.activeWorkspaceId).toBe("frontend");
  });

  test("discovers worktrees per active profile when parent cwd is shared", async () => {
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-shared-project-"));
    tempPaths.push(projectRoot);
    const worktreePath = path.join(projectRoot, ".strideterm", "tree", "feature-x");
    await fs.mkdir(worktreePath, { recursive: true });

    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-b",
        activeProjectId: "shared-b",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "shared-default",
            name: "Shared",
            kind: "terminal",
            profileId: "default",
            cwd: projectRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "shared-b",
            name: "Shared",
            kind: "terminal",
            profileId: "profile-b",
            cwd: projectRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "existing-default-worktree",
            name: "Shared / feature-x",
            kind: "terminal",
            profileId: "default",
            cwd: worktreePath,
            notes: "Worktree of Shared",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    const matchingWorktrees = fixture.runtime
      .getPayload()
      .appState.projects!.filter((project) => project.cwd === worktreePath);

    expect(matchingWorktrees.map((project) => project.profileId).sort()).toEqual(["default", "profile-b"]);
  });

  test("pruneOrphanedWorkspaces clears per-slot activeWorkspaceId using each slot's own profile", async () => {
    // Multi-window scenario: two profiles, each open in its own window. An
    // orphaned workspace lives in profile B. When pruneOrphans removes it,
    // every slot whose activeWorkspaceId pointed at the removed entry must
    // fall back to a sibling IN THAT SLOT'S PROFILE — not to whatever
    // windowSlots[0] happens to be (that's the bug; it would silently push
    // the wrong-profile workspace into profile B's window).
    const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-prune-real-"));
    tempPaths.push(realRoot);
    const orphanRoot = path.join(realRoot, "gone");
    // orphanRoot intentionally never created — that's what makes it an orphan.
    const realRootB = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-prune-real-b-"));
    tempPaths.push(realRootB);

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-b-orphan",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-default-keep",
            name: "Default Keep",
            kind: "terminal",
            profileId: "default",
            cwd: realRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b-orphan",
            name: "B Orphan",
            kind: "terminal",
            profileId: "profile-b",
            cwd: orphanRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b-keep",
            name: "B Keep",
            kind: "terminal",
            profileId: "profile-b",
            cwd: realRootB,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-default",
            profileId: "default",
            activeWorkspaceId: "ws-default-keep",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b-orphan",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 2000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    const removed = await fixture.runtime.pruneOrphanedWorkspaces();
    expect(removed).toBe(1);

    const state = fixture.runtime.getPayload().appState;
    expect(state.projects!.map((p) => p.id).sort()).toEqual(["ws-b-keep", "ws-default-keep"]);

    const slotDefault = state.windowSlots!.find((s) => s.id === "win-default")!;
    const slotB = state.windowSlots!.find((s) => s.id === "win-b")!;
    expect(slotDefault.activeWorkspaceId).toBe("ws-default-keep");
    // The buggy behavior would either leave this as the stale orphan id or
    // pick a workspace from windowSlots[0]'s profile (ws-default-keep), both
    // wrong. The fix must pick a sibling in profile-b.
    expect(slotB.activeWorkspaceId).toBe("ws-b-keep");
  });

  test("resolveGitConnection uses the workspace's own profile, not windowSlots[0]", async () => {
    // Two profiles each open in their own window, each owning their own
    // Azure/GitHub connection. Without the fix, getAllProviderConnections
    // filters by windowSlots[0]?.profileId, so a git operation in window B
    // (profile-b) looks up its workspace's connectionId in a list that only
    // contains profile-default's connections — finds nothing — and falls
    // back to "no connection" / system git credentials, silently breaking
    // authenticated git ops in the non-primary window.
    const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-provider-resolve-"));
    tempPaths.push(realRoot);

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-b",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        settings: {
          integrations: {
            azureDevops: {
              enabled: true,
              reviewRoot: realRoot,
              defaultPollSeconds: 120,
              connections: [
                {
                  id: "azure-default",
                  enabled: true,
                  orgUrl: "https://dev.azure.com/acme",
                  tokenRef: "cred:azure-default",
                  profileId: "default",
                },
              ],
            },
            github: {
              enabled: true,
              reviewRoot: realRoot,
              defaultPollSeconds: 120,
              connections: [
                {
                  id: "github-b",
                  enabled: true,
                  hostUrl: "https://github.com",
                  tokenRef: "cred:github-b",
                  profileId: "profile-b",
                },
              ],
            },
          },
        },
        projects: [
          {
            id: "ws-default",
            name: "Default Repo",
            kind: "terminal",
            profileId: "default",
            cwd: realRoot,
            connectionId: "azure-default",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b",
            name: "B Repo",
            kind: "terminal",
            profileId: "profile-b",
            cwd: realRoot,
            connectionId: "github-b",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-default",
            profileId: "default",
            activeWorkspaceId: "ws-default",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 2000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    // Clear any actions captured during init (refreshGit etc).
    fixture.git.actions.length = 0;

    await fixture.runtime.gitFetch({ workspaceId: "ws-b" });

    const action = fixture.git.actions.find((a: { kind: string }) => a.kind === "fetch");
    expect(action).toBeDefined();
    // The bug: action.connection would be null because resolveGitConnection
    // filters by windowSlots[0] = "default" and never finds "github-b".
    expect(action.connection).not.toBeNull();
    expect(action.connection.id).toBe("github-b");
  });

  test("payload.git.connections includes connections from every open profile", async () => {
    // The renderer (any window) receives the same payload. The connections
    // list must include every open profile's connections so each window can
    // surface its own profile's connections in the UI. Filtering this list
    // by windowSlots[0]?.profileId hides connections in non-primary windows.
    const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-provider-payload-"));
    tempPaths.push(realRoot);

    const fixture = await createFixture({
      initialState: {
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        settings: {
          integrations: {
            azureDevops: {
              enabled: true,
              reviewRoot: realRoot,
              defaultPollSeconds: 120,
              connections: [
                {
                  id: "azure-default",
                  enabled: true,
                  orgUrl: "https://dev.azure.com/acme",
                  tokenRef: "cred:azure-default",
                  profileId: "default",
                },
                {
                  id: "azure-b",
                  enabled: true,
                  orgUrl: "https://dev.azure.com/beta",
                  tokenRef: "cred:azure-b",
                  profileId: "profile-b",
                },
              ],
            },
          },
        },
        windowSlots: [
          {
            id: "win-default",
            profileId: "default",
            activeWorkspaceId: "",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 2000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = fixture.runtime.getPayload();
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const connections = (payload.git as any).connections as Array<{ id: string; profileId?: string }>;
    expect(connections.map((c) => c.id).sort()).toEqual(["azure-b", "azure-default"]);
    // profileId must be exposed so the renderer can filter by the window's
    // own profile when populating the connection picker.
    const byId = new Map(connections.map((c) => [c.id, c]));
    expect(byId.get("azure-default")?.profileId).toBe("default");
    expect(byId.get("azure-b")?.profileId).toBe("profile-b");
  });

  test("syncWorktrees removal rewires each slot to a sibling in the slot's own profile", async () => {
    // When a worktree disappears from disk, syncWorktrees removes its
    // workspace entries from every profile that had one. The fix must
    // re-point each window's slot.activeWorkspaceId at a sibling in
    // THAT slot's profile — not at a workspace from windowSlots[0]'s
    // profile (which would silently swap the wrong workspace into the
    // other window's pane).
    const parentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-syncwt-prune-"));
    tempPaths.push(parentRoot);
    // Worktree dir intentionally never created on disk → triggers removal path.
    const missingWorktreePath = path.join(parentRoot, ".strideterm", "tree", "gone");

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "parent-default",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "parent-default",
            name: "Shared",
            kind: "terminal",
            profileId: "default",
            cwd: parentRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "parent-b",
            name: "Shared",
            kind: "terminal",
            profileId: "profile-b",
            cwd: parentRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "wt-default",
            name: "Shared / gone",
            kind: "terminal",
            profileId: "default",
            cwd: missingWorktreePath,
            notes: "Worktree of Shared",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "wt-b",
            name: "Shared / gone",
            kind: "terminal",
            profileId: "profile-b",
            cwd: missingWorktreePath,
            notes: "Worktree of Shared",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-default",
            profileId: "default",
            activeWorkspaceId: "wt-default",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "wt-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 2000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    // createFixture's runtime init calls syncWorktrees, which detects both
    // worktrees as missing on disk and removes them.
    const state = fixture.runtime.getPayload().appState;
    expect(state.projects!.map((p) => p.id).sort()).toEqual(["parent-b", "parent-default"]);

    const slotDefault = state.windowSlots!.find((s) => s.id === "win-default")!;
    const slotB = state.windowSlots!.find((s) => s.id === "win-b")!;
    expect(slotDefault.activeWorkspaceId).toBe("parent-default");
    expect(slotB.activeWorkspaceId).toBe("parent-b");
  });

  test("activateWorkspaceInWindow refuses cross-profile activation", async () => {
    // A remote client bound to profile B (or a stale UI request) must not
    // be able to point window-B's slot at a workspace that lives in
    // profile A. The runtime previously logged this as "crossProfile" but
    // honoured the request, silently swapping the user out of their
    // profile. The guard makes the misuse an explicit error.
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-a",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-a",
            name: "A",
            kind: "terminal",
            profileId: "default",
            cwd: "/tmp/a",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b",
            name: "B",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    await expect(fixture.runtime.activateWorkspaceInWindow("ws-a", "win-b")).rejects.toThrow(
      /does not belong to window win-b's profile/i,
    );

    const slot = fixture.runtime.getPayload().appState.windowSlots!.find((s) => s.id === "win-b")!;
    expect(slot.activeWorkspaceId).toBe("ws-b"); // unchanged
  });

  test("setGridCell REFUSES placing a cross-profile workspace into another profile's grid", async () => {
    // Crafted/stale remote payload places `workspaceId` from profile A
    // into the grid resolved for profile B. The grid then renders cards
    // with cwds from the wrong profile in the user's window.
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-b",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-a",
            name: "A",
            kind: "terminal",
            profileId: "default",
            cwd: "/tmp/a",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b",
            name: "B",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    // Enable a grid first (otherwise setGridCell is a no-op anyway).
    await fixture.runtime.enableWorkspaceGrid("cols", ["ws-b", null], "win-b");

    await expect(fixture.runtime.setGridCell(0, "ws-a", "win-b")).rejects.toThrow(/Cross-profile refused/i);
  });

  test("reorderWorkspaces preserves workspaces in other profiles when caller is profile-scoped", async () => {
    // A profile-scoped frontend / mobile client sends only its profile's
    // workspace IDs. Old behavior replaced the entire array with that
    // list — silently dropping every workspace in OTHER profiles. Fix:
    // reorder within caller's profile, preserve others in place.
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-b1",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-default-1",
            name: "D1",
            kind: "terminal",
            profileId: "default",
            cwd: "/tmp/d1",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b1",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b2",
            name: "B2",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b2",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-default-2",
            name: "D2",
            kind: "terminal",
            profileId: "default",
            cwd: "/tmp/d2",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b1",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    // Profile-b client sends only profile-b IDs in swapped order.
    await fixture.runtime.reorderWorkspaces(["ws-b2", "ws-b1"], "win-b");

    const ids = fixture.runtime.getPayload().appState.projects!.map((p) => p.id);
    // Profile-default workspaces remain in their original positions; only
    // profile-b workspaces are reordered (swapped) within their slots.
    expect(ids).toEqual(["ws-default-1", "ws-b2", "ws-b1", "ws-default-2"]);
  });

  test("createWorktree REFUSES when the parent workspace lives in another profile (no on-disk side effect)", async () => {
    // Remote/mobile client bound to profile B asks to create a worktree
    // under a profile-A parent. Previously only the slot mirror was
    // skipped — the worktree still got created on disk and as a workspace
    // entry in profile A. That's a cross-profile write the caller had no
    // business doing. The fix refuses at entry.
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-wt-refuse-"));
    tempPaths.push(projectRoot);
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "ws-a",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-a",
            name: "A",
            kind: "terminal",
            profileId: "default",
            cwd: projectRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b",
            name: "B",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/some-other-b",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
        ],
      },
    });
    fixtures.push(fixture);

    await expect(fixture.runtime.createWorktree({ projectId: "ws-a", name: "feat-noop" }, "win-b")).rejects.toThrow(
      /Cross-profile refused/i,
    );

    // No worktree workspace was inserted, no git command was issued.
    const wsAfter = fixture.runtime
      .getPayload()
      .appState.projects!.map((p) => p.id)
      .sort();
    expect(wsAfter).toEqual(["ws-a", "ws-b"]);
    expect(
      fixture.execFileText.mock.calls.some(
        (call: unknown[]) => Array.isArray(call[1]) && (call[1] as unknown[])[0] === "worktree",
      ),
    ).toBe(false);
  });

  test("discovers worktrees for every profile sharing the parent cwd, regardless of which window is focused", async () => {
    // Profiles are independent observers of the filesystem: when two profiles
    // each have a workspace pointing at the same repo, a worktree that exists
    // on disk should surface in BOTH profiles' sidebars, not just the one in
    // windowSlots[0]'s profile. This is the symmetric case that the original
    // "active-profile-wins" logic in syncWorktreesImpl quietly broke — the
    // non-active profile silently lost its worktree entries on every sync.
    const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-shared-symmetric-"));
    tempPaths.push(projectRoot);
    const worktreePath = path.join(projectRoot, ".strideterm", "tree", "feature-y");
    await fs.mkdir(worktreePath, { recursive: true });

    const fixture = await createFixture({
      initialState: {
        // Active profile is "default"; without the fix, "profile-b" never
        // discovers its worktree because parentByTreeDir is overridden by
        // the active-profile parent.
        activeProfileId: "default",
        activeProjectId: "parent-default",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "parent-default",
            name: "Shared",
            kind: "terminal",
            profileId: "default",
            cwd: projectRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "parent-b",
            name: "Shared",
            kind: "terminal",
            profileId: "profile-b",
            cwd: projectRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    const matchingWorktrees = fixture.runtime
      .getPayload()
      .appState.projects!.filter((project) => project.cwd === worktreePath);

    expect(matchingWorktrees.map((project) => project.profileId).sort()).toEqual(["default", "profile-b"]);
  });

  test("syncWorktrees does not add 'Worktree of' entry when task workspace already claims the directory", async () => {
    // When a task agent workspace exists at a .strideterm/tree/<branch> path,
    // syncWorktrees must not create a duplicate plain "Worktree of" entry for
    // the same directory.
    const parentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-syncwt-taskdedup-"));
    tempPaths.push(parentRoot);
    const treePath = path.join(parentRoot, ".strideterm", "tree", "task-branch");
    await fs.mkdir(treePath, { recursive: true });

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "parent",
        projects: [
          {
            id: "parent",
            name: "MyProject",
            kind: "terminal",
            cwd: parentRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "task-ws",
            name: "MyProject / task-branch",
            kind: "task",
            cwd: treePath,
            task: { taskId: "task-ws", parentWorkspaceId: "parent" },
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    const allProjects = fixture.runtime.getPayload().appState.projects!;
    // No plain "Worktree of" duplicate should exist at the task workspace directory
    const duplicates = allProjects.filter((p) => p.cwd === treePath && (p.notes || "").startsWith("Worktree of"));
    expect(duplicates).toHaveLength(0);
    // The task workspace itself must still be present
    expect(allProjects.find((p) => p.id === "task-ws")).toBeDefined();
  });

  test("createTaskWorkspace removes pre-existing 'Worktree of' entry at the same worktree directory", async () => {
    // When syncWorktrees creates a "Worktree of" entry before the task workspace
    // is registered (race condition), createTaskWorkspace must clean it up.
    const parentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-task-cleanup-"));
    tempPaths.push(parentRoot);
    const treePath = path.join(parentRoot, ".strideterm", "tree", "task-branch");
    await fs.mkdir(treePath, { recursive: true });

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "parent",
        projects: [
          {
            id: "parent",
            name: "MyProject",
            kind: "terminal",
            cwd: parentRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "plain-wt",
            name: "MyProject / task-branch",
            kind: "terminal",
            cwd: treePath,
            notes: "Worktree of MyProject",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    // The plain "Worktree of" entry is present before the task workspace is created
    expect(fixture.runtime.getPayload().appState.projects!.find((p) => p.id === "plain-wt")).toBeDefined();

    await fixture.runtime.createTaskWorkspace({
      cwd: parentRoot,
      useWorktree: true,
      worktreeBranch: "task-branch",
      parentWorkspaceId: "parent",
      description: "Test task",
      activate: false,
    });

    const after = fixture.runtime.getPayload().appState.projects!;
    // The plain "Worktree of" duplicate must be gone
    expect(after.find((p) => p.id === "plain-wt")).toBeUndefined();
    // A task workspace at the same cwd must exist
    expect(after.find((p) => p.kind === "task" && p.cwd === treePath)).toBeDefined();
  });

  // ── Same-cwd task-workspace duplicate-guard tests ──────────────────
  // These tests cover the three guards that close the "delete-then-create
  // race" footgun (two task agents fighting over the same directory):
  //   1. createTaskWorkspace refuses while another task is actively working.
  //   2. createTaskWorkspace refuses while a delete is mid-flight on the cwd.
  //   3. Inactive task states (paused/completed/failed/idle) are allowed to
  //      coexist — they don't write the worktree.
  // Plus a resilience test: deleteWorkspace clears state.workspaces even if
  // taskRunner cleanup throws, so the cwd never gets permanently locked.

  // Helper: minimal task-workspace shape that survives normalizeState and
  // exercises the same-cwd filter. State value drives the guard.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  function makeTaskWorkspace(id: string, name: string, cwd: string, state: string): any {
    return {
      id,
      name,
      kind: "task",
      cwd,
      activePanelId: "worker",
      panels: [
        { id: "worker", title: "Worker", command: "claude", shell: true, startup: "default" },
        { id: "judge", title: "Judge", command: "claude", shell: true, startup: "default" },
      ],
      task: {
        taskId: `${id}-tid`,
        state,
        workerPanelId: "worker",
        judgePanelId: "judge",
        currentRound: state === "running" ? 1 : 0,
        description: "preexisting",
      },
    };
  }

  // AgentTaskRunner#reconcileOnStartup flips active task states to "paused"
  // when the runtime initializes (it assumes the app just restarted so no
  // PTY can still be doing work). To exercise the live-active conflict
  // guard we have to flip the state back through the store *after* fixture
  // setup — the same dance any real running task goes through.
  async function setTaskStateAfterInit(
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    fixture: any,
    workspaceId: string,
    state: string,
  ): Promise<void> {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    await fixture.store.mutate((draft: any) => {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const ws = draft.workspaces.find((w: any) => w.id === workspaceId);
      if (ws?.task) ws.task.state = state;
    });
  }

  test("createTaskWorkspace refuses when an active running task exists at the same cwd", async () => {
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-cwd-running-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [makeTaskWorkspace("task-a", "Active task", sharedCwd, "running")],
      },
    });
    fixtures.push(fixture);
    await setTaskStateAfterInit(fixture, "task-a", "running");

    await expect(
      fixture.runtime.createTaskWorkspace({
        cwd: sharedCwd,
        description: "second task",
        activate: false,
      }),
    ).rejects.toThrow(/Another task agent .* is currently running/i);

    // State must NOT contain a second task workspace.
    const tasks = fixture.runtime.getPayload().appState.workspaces!.filter((w) => w.kind === "task");
    expect(tasks).toHaveLength(1);
    expect(tasks[0].id).toBe("task-a");
  });

  test.each(["evaluating", "judge-evaluating", "refreshing"] as const)(
    "createTaskWorkspace refuses when a task in '%s' state exists at the same cwd",
    async (taskState) => {
      const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), `strideterm-cwd-${taskState}-`));
      tempPaths.push(sharedCwd);

      const fixture = await createFixture({
        initialState: {
          workspaces: [makeTaskWorkspace("task-a", "Active task", sharedCwd, taskState)],
        },
      });
      fixtures.push(fixture);
      // Re-arm the active state — reconcileOnStartup forced it to "paused".
      await setTaskStateAfterInit(fixture, "task-a", taskState);

      await expect(
        fixture.runtime.createTaskWorkspace({
          cwd: sharedCwd,
          description: "second task",
          activate: false,
        }),
      ).rejects.toThrow(/currently running/i);
    },
  );

  test.each(["paused", "completed", "failed", "idle"] as const)(
    "createTaskWorkspace allows a second task when the existing one is '%s' (inert state)",
    async (taskState) => {
      // Inert states (paused/completed/failed/idle) don't write the worktree,
      // so a second task workspace can coexist. The user might be parking an
      // old run while starting a new one over the same dir — that's fine.
      const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), `strideterm-cwd-${taskState}-`));
      tempPaths.push(sharedCwd);

      const fixture = await createFixture({
        initialState: {
          workspaces: [makeTaskWorkspace("task-a", "Old task", sharedCwd, taskState)],
        },
      });
      fixtures.push(fixture);

      const result = await fixture.runtime.createTaskWorkspace({
        cwd: sharedCwd,
        description: "second task",
        activate: false,
      });

      expect(result?.workspaceId).toBeTruthy();
      // Both task workspaces should now exist at the same cwd.
      const tasks = fixture.runtime
        .getPayload()
        .appState.workspaces!.filter((w) => w.kind === "task" && w.cwd === sharedCwd);
      expect(tasks).toHaveLength(2);
      // cwdWarning is preserved as empty string for API stability — callers
      // should not see a stale "warning" payload now that conflicts throw.
      expect(result?.cwdWarning).toBe("");
    },
  );

  test("createTaskWorkspace refuses while a delete is still finishing cleanup at the same cwd", async () => {
    // Reproduces the user-reported race: delete a task workspace, then immediately
    // create another one over the same directory. Before the guard, the user could
    // end up with two task workspaces fighting over the worktree, or the second
    // one stuck because the first hadn't released its files yet.
    //
    // The trick: deleteWorkspace runs synchronously up to its first `await`
    // (cleanupTaskFiles). By then pendingTaskWorkspaceDeletions has the cwd, so
    // a synchronous create attempt that follows must see the pending flag and
    // bail out with a "still finishing cleanup" message.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-cwd-pending-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [makeTaskWorkspace("task-a", "Old task", sharedCwd, "paused")],
      },
    });
    fixtures.push(fixture);

    // Kick off delete without awaiting — synchronously populates the pending set.
    const deletePromise = fixture.runtime.deleteWorkspace("task-a");

    // Immediate same-cwd create must hit the pending-delete guard.
    await expect(
      fixture.runtime.createTaskWorkspace({
        cwd: sharedCwd,
        description: "second task",
        activate: false,
      }),
    ).rejects.toThrow(/still finishing cleanup/i);

    // Let the delete settle before the fixture afterEach runs.
    await deletePromise;
  });

  test("after deleteWorkspace completes, createTaskWorkspace at the same cwd succeeds", async () => {
    // The pending-delete guard must release when cleanup finishes, otherwise the
    // cwd would be locked until app restart — exactly the symptom the user
    // reported. This test pairs with the race test above: same setup, but we
    // wait for the delete to complete before creating.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-cwd-released-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [makeTaskWorkspace("task-a", "Old task", sharedCwd, "paused")],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.deleteWorkspace("task-a");

    // Old workspace must be gone from state — the resilience fix guarantees
    // state.mutate ran even if any cleanup step threw.
    const afterDelete = fixture.runtime.getPayload().appState.workspaces!;
    expect(afterDelete.find((w) => w.id === "task-a")).toBeUndefined();

    // New task at the same cwd should now succeed.
    const result = await fixture.runtime.createTaskWorkspace({
      cwd: sharedCwd,
      description: "fresh task",
      activate: false,
    });
    expect(result?.workspaceId).toBeTruthy();
    expect(result?.workspaceId).not.toBe("task-a");
  });

  test("error message names the conflicting task workspace so the user knows what to stop", async () => {
    // UX assertion — the message must include the workspace name so the user
    // can find it in the sidebar. Before the change, the warning was the only
    // signal and got dropped by the dialog; now it's a thrown error that
    // surfaces in the dialog's inline error banner.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-cwd-name-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [makeTaskWorkspace("task-a", "Refactor login flow", sharedCwd, "running")],
      },
    });
    fixtures.push(fixture);
    await setTaskStateAfterInit(fixture, "task-a", "running");

    await expect(
      fixture.runtime.createTaskWorkspace({
        cwd: sharedCwd,
        description: "second task",
        activate: false,
      }),
    ).rejects.toThrow(/Refactor login flow/);
  });

  test("deleteWorkspace clears state even when the task workspace is missing taskId", async () => {
    // Resilience: a task workspace persisted without task.taskId (corrupt state,
    // backward-compat record, or partial migration) used to be reachable by the
    // pre-cleanup code path. The hardened deleteWorkspace must still remove it
    // from state.workspaces, not get stuck because of the unusual shape.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-cwd-orphan-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [
          {
            id: "task-orphan",
            name: "Orphan task",
            kind: "task",
            cwd: sharedCwd,
            activePanelId: "worker",
            panels: [{ id: "worker", title: "Worker", command: "claude", shell: true, startup: "default" }],
            // Note: task object present but no taskId — the cleanup branch is
            // gated on `task.taskId && cwd`, so cleanup is skipped entirely.
            task: { state: "idle", workerPanelId: "worker", judgePanelId: "worker", currentRound: 0 },
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.deleteWorkspace("task-orphan");

    const after = fixture.runtime.getPayload().appState.workspaces!;
    expect(after.find((w) => w.id === "task-orphan")).toBeUndefined();

    // And the cwd must NOT be permanently locked — a new create succeeds.
    const result = await fixture.runtime.createTaskWorkspace({
      cwd: sharedCwd,
      description: "fresh task",
      activate: false,
    });
    expect(result?.workspaceId).toBeTruthy();
  });

  test("useWorktree create runs the guard BEFORE any disk side-effects (no orphan tree, no .gitignore mutation)", async () => {
    // Regression: previously the gitignore write, parent mkdir and
    // `git worktree add` all ran first, so a same-cwd conflict in useWorktree
    // mode would throw but leave orphan files. The guard must compute the
    // planned treePath synchronously and fire before any disk operations.
    const parentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-wt-preflight-"));
    tempPaths.push(parentRoot);
    const branch = "task/feature-x";
    const dirName = branch.replace(/\//g, "-");
    const plannedTreePath = path.join(parentRoot, ".strideterm", "tree", dirName);

    const execFileText = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
    const fixture = await createFixture({
      execFileTextImpl: execFileText,
      initialState: {
        workspaces: [
          {
            id: "parent",
            name: "Parent",
            kind: "terminal",
            cwd: parentRoot,
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          makeTaskWorkspace("task-a", "Live task", plannedTreePath, "running"),
        ],
      },
    });
    fixtures.push(fixture);
    await setTaskStateAfterInit(fixture, "task-a", "running");

    // Make sure no .gitignore exists yet so we can prove the guard fired
    // before the write would have happened.
    await fs.rm(path.join(parentRoot, ".gitignore"), { force: true });

    await expect(
      fixture.runtime.createTaskWorkspace({
        cwd: parentRoot,
        useWorktree: true,
        worktreeBranch: branch,
        parentWorkspaceId: "parent",
        description: "second task",
        activate: false,
      }),
    ).rejects.toThrow(/currently running/i);

    // Disk side-effects must NOT have happened:
    //   - .gitignore stays absent
    //   - The .strideterm/tree/<branch>/ dir is not created
    //   - execFileText was never called for `git worktree add`
    await expect(fs.access(path.join(parentRoot, ".gitignore"))).rejects.toThrow();
    await expect(fs.access(plannedTreePath)).rejects.toThrow();
    const gitCalls = execFileText.mock.calls.filter(
      (c) => c[0] === "git" && Array.isArray(c[1]) && c[1][0] === "worktree",
    );
    expect(gitCalls).toHaveLength(0);
  });

  test("active task in profile A does not block creation in profile B at the same cwd", async () => {
    // CLAUDE.md treats profiles as organizational, not storage isolation —
    // two profiles legitimately sharing a monorepo must be able to run
    // task agents at the same cwd in parallel. The guard scopes by profile,
    // so an "Active task" in profile A is invisible to profile B's create.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-profile-scope-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        profiles: [
          { id: "profile-a", name: "Profile A" },
          { id: "profile-b", name: "Profile B" },
        ],
        windowSlots: [
          { id: "win-a", profileId: "profile-a" },
          { id: "win-b", profileId: "profile-b" },
        ],
        workspaces: [
          {
            ...makeTaskWorkspace("task-a", "A's task", sharedCwd, "running"),
            profileId: "profile-a",
          },
        ],
      },
    });
    fixtures.push(fixture);
    await setTaskStateAfterInit(fixture, "task-a", "running");

    // Sanity: same-profile create still blocks.
    await expect(
      fixture.runtime.createTaskWorkspace({ cwd: sharedCwd, description: "from A", activate: false }, "win-a"),
    ).rejects.toThrow(/currently running/i);

    // Profile-B create must succeed despite profile-A's running task.
    const result = await fixture.runtime.createTaskWorkspace(
      { cwd: sharedCwd, description: "from B", activate: false },
      "win-b",
    );
    expect(result?.workspaceId).toBeTruthy();
    const created = fixture.runtime.getPayload().appState.workspaces!.find((w) => w.id === result.workspaceId);
    expect(created?.profileId).toBe("profile-b");
  });

  test("pending-delete flag is held until session removal resolves (not just store.mutate)", async () => {
    // Reviewer-flagged race: releasing the pending flag right after
    // store.mutate leaves a window in which the old worker/judge PTY
    // processes are still alive and holding file handles in the cwd. The
    // fix awaits sessions.removeWorkspaceSessions inside the finally so the
    // lock persists until the OS has actually torn the processes down.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-hold-until-exit-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [makeTaskWorkspace("task-a", "Old task", sharedCwd, "paused")],
      },
    });
    fixtures.push(fixture);

    // Make the session-exit promise controllable from the test. `sessionsRequested`
    // flips true the moment deleteWorkspace reaches removeWorkspaceSessions —
    // by then store.mutate has already removed task-a from state, so any
    // subsequent create observes the pending flag (not the still-present
    // workspace).
    let resolveSessions: () => void = () => {};
    let sessionsRequested = false;
    const sessionsExitPromise = new Promise<void>((resolve) => {
      resolveSessions = resolve;
    });
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    fixture.sessionManager.removeWorkspaceSessions = (workspaceId: any) => {
      sessionsRequested = true;
      // Still actually remove the in-memory sessions so the rest of the
      // delete pipeline behaves normally — just defer the returned promise.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      for (const sessionId of [...fixture.sessionManager.sessions.keys()] as any[]) {
        if (sessionId.startsWith(`${workspaceId}:`)) fixture.sessionManager.sessions.delete(sessionId);
      }
      fixture.sessionManager.removedProjects.push(workspaceId);
      return sessionsExitPromise;
    };

    const deletePromise = fixture.runtime.deleteWorkspace("task-a");
    // Drive microtasks until deleteWorkspace reaches removeWorkspaceSessions
    // — that's the point where store.mutate has finished and the pending
    // flag is in its hold-during-PTY-exit phase. Polling avoids hard-coding
    // a number of `await Promise.resolve()` calls that would be brittle
    // against future re-ordering inside deleteWorkspace.
    for (let i = 0; i < 200 && !sessionsRequested; i++) {
      await new Promise((resolve) => setImmediate(resolve));
    }
    expect(sessionsRequested).toBe(true);

    expect(fixture.runtime.getPayload().appState.workspaces!.find((w) => w.id === "task-a")).toBeUndefined();
    await expect(
      fixture.runtime.createTaskWorkspace({
        cwd: sharedCwd,
        description: "racing create",
        activate: false,
      }),
    ).rejects.toThrow(/still finishing cleanup/i);

    // Release the PTY exit promise — pending flag should now drain and the
    // delete promise should resolve.
    resolveSessions();
    await deletePromise;

    // A subsequent create at the same cwd succeeds.
    const result = await fixture.runtime.createTaskWorkspace({
      cwd: sharedCwd,
      description: "fresh task",
      activate: false,
    });
    expect(result?.workspaceId).toBeTruthy();
  });

  test("startTask refuses when another task in the same profile is already active at the same cwd", async () => {
    // createTaskWorkspace allows two paused tasks at the same cwd to coexist
    // (paused is inert, no worker process running). Starting one is fine,
    // but starting the SECOND would put two worker agents in the same dir.
    // startTask must refuse the second start with the same message.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-start-guard-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [
          makeTaskWorkspace("task-a", "Task A", sharedCwd, "paused"),
          makeTaskWorkspace("task-b", "Task B", sharedCwd, "paused"),
        ],
      },
    });
    fixtures.push(fixture);

    // Flip task-a to running (simulating a successful start). The runtime
    // exposes setTaskStateAfterInit-equivalent behavior through the store.
    await setTaskStateAfterInit(fixture, "task-a", "running");

    await expect(fixture.runtime.startTask("task-b")).rejects.toThrow(/currently running/i);

    // Sanity: starting task-a itself (already running) does NOT trip the
    // guard — selfWorkspaceId excludes the calling workspace.
    // taskRunner.startTask will no-op or return false depending on internal
    // state; the important thing is that the cwd guard doesn't false-positive
    // on the workspace's own active state.
    await expect(fixture.runtime.startTask("task-a")).resolves.toBeDefined();
  });

  test("resumeTask refuses when another task in the same profile is actively touching the cwd", async () => {
    // Same scenario as startTask, but exercising resumeTask. Resume re-spawns
    // PTYs — the second resume would put two workers in the same directory.
    const sharedCwd = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-resume-guard-"));
    tempPaths.push(sharedCwd);

    const fixture = await createFixture({
      initialState: {
        workspaces: [
          makeTaskWorkspace("task-a", "Task A", sharedCwd, "paused"),
          makeTaskWorkspace("task-b", "Task B", sharedCwd, "paused"),
        ],
      },
    });
    fixtures.push(fixture);
    await setTaskStateAfterInit(fixture, "task-a", "running");

    // resumeTask is synchronous (taskRunner.resumeTask is sync), so the
    // guard throws synchronously rather than returning a rejected promise.
    expect(() => fixture.runtime.resumeTask("task-b")).toThrow(/currently running/i);
  });

  test("createWorktree on multi-repo workspace requires rootPath and runs inside the selected repo", async () => {
    const parentRoot = await fs.mkdtemp(path.join(os.tmpdir(), "strideterm-multirepo-"));
    tempPaths.push(parentRoot);
    const repoA = path.join(parentRoot, "service-a");
    const repoB = path.join(parentRoot, "service-b");
    await fs.mkdir(repoA, { recursive: true });
    await fs.mkdir(repoB, { recursive: true });

    const fixture = await createFixture({
      initialState: {
        activeProjectId: "stack",
        projects: [
          {
            id: "stack",
            name: "Stack",
            icon: "ST",
            color: "#ffa424",
            kind: "terminal",
            cwd: parentRoot,
            gitRoots: [repoA, repoB],
            activePanelId: "dev",
            panels: [{ id: "dev", title: "Dev", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    await expect(fixture.runtime.createWorktree({ projectId: "stack", name: "feature-x" })).rejects.toThrow(
      /Multi-repo workspace requires a repository/i,
    );

    await expect(
      fixture.runtime.createWorktree({ projectId: "stack", name: "feature-x", rootPath: "/bogus/path" }),
    ).rejects.toThrow(/not part of this workspace/i);

    const payload = await fixture.runtime.createWorktree({
      projectId: "stack",
      name: "feature-x",
      rootPath: repoA,
    });

    expect(fixture.execFileText).toHaveBeenCalledWith(
      "git",
      ["worktree", "add", path.join(repoA, ".strideterm", "tree", "feature-x"), "-b", "feature-x"],
      { cwd: repoA },
    );
    const gitignore = await fs.readFile(path.join(repoA, ".gitignore"), "utf8");
    expect(gitignore).toContain(".strideterm/");
    const child = payload.appState.projects!.find((p) => p.name === "Stack / feature-x");
    expect(child?.cwd).toBe(path.join(repoA, ".strideterm", "tree", "feature-x"));
    // Child inherits nothing from multi-repo — it's a single-repo worktree
    expect(child?.gitRoots || []).toHaveLength(0);
  });

  test("returns structured payload for git fetch and diff preview actions", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProjectId: "backend",
        projects: [
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.refreshGitState("backend");
    const fetchResult = await fixture.runtime.gitFetch({ projectId: "backend" });
    const diffResult = await fixture.runtime.gitDiffPreview({
      projectId: "backend",
      path: "src/app.js",
      scope: "unstaged",
    });

    expect(fetchResult.result).toMatchObject({
      ok: true,
      summary: "Fetch completed.",
    });
    expect(fetchResult.payload.git.activeWorkspace).toBeTruthy();
    expect(fixture.git.actions[0]).toMatchObject({
      kind: "fetch",
      workspaceId: "backend",
    });
    expect(diffResult).toMatchObject({
      ok: true,
      path: "src/app.js",
      scope: "unstaged",
    });
    expect(fixture.git.actions[1]).toMatchObject({
      kind: "diff",
      workspaceId: "backend",
    });
  });

  test("saves and deletes profiles while preserving a fallback default profile", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    let payload = await fixture.runtime.saveProfile({
      id: "focus",
      name: "Focus",
      workspaceIds: ["frontend"],
    });
    expect(payload.appState.profiles.some((profile) => profile.id === "focus")).toBe(true);

    payload = await fixture.runtime.deleteProfile("focus");
    expect(payload.appState.profiles).toEqual([
      { id: "default", name: "Default", color: "#ffa424", workspaceIds: [], projectIds: [], workspaceGrid: null },
    ]);
  });

  test("does not rewrite the store during runtime stop", async () => {
    const fixture = await createFixture();
    fixtures.push(fixture);

    expect(fixture.store.saveCalls).toBe(0);
    await fixture.runtime.stop();
    expect(fixture.store.saveCalls).toBe(0);
  });

  // Regression: per-window workspace activation must keep global activeWorkspaceId
  // in sync with the activated workspace. `getPayload()` builds `payload.workspace`
  // from `state.activeWorkspaceId` (via sessions.getWorkspace), so the renderer's
  // main pane stays on the previously-active workspace unless the global is updated.
  // normalizeState derives the active profile from activeWorkspaceId, so keeping that
  // field in sync also ensures subsequent normalization passes validate correctly.
  test("activateWorkspaceInWindow mirrors slot updates into global activeWorkspaceId", async () => {
    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "frontend",
        profiles: [{ id: "default", name: "Default", color: "#fff", workspaceIds: [], workspaceGrid: null }],
        workspaces: [
          {
            id: "frontend",
            name: "Frontend",
            kind: "terminal",
            cwd: "/tmp/frontend",
            profileId: "default",
            panels: [{ id: "p1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p1",
          },
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            profileId: "default",
            panels: [{ id: "p2", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p2",
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "default",
            activeWorkspaceId: "frontend",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.activateWorkspaceInWindow("backend", "win-1");

    // Slot updated as before.
    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.activeWorkspaceId).toBe("backend");
    // Global mirrored — guards against the bug where `payload.workspace` (built
    // from sessions.getWorkspace(state.activeWorkspaceId)) stays on the previous
    // workspace and the renderer main pane never visually switches.
    expect(payload.appState.activeWorkspaceId).toBe("backend");
  });

  test("activateProfileInWindow keeps global activeWorkspaceId in sync with the new profile", async () => {
    // activateProfileInWindow writes draft.activeWorkspaceId = firstWorkspaceInNewProfile.
    // normalizeState derives the active profile from that workspace's profileId,
    // so subsequent workspace activations are validated against the correct profile
    // and do not get silently reverted by normalizeState.
    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-a",
        activeWorkspaceId: "ws-a1",
        profiles: [
          { id: "profile-a", name: "A", color: "#fff", workspaceIds: [], workspaceGrid: null },
          { id: "profile-b", name: "B", color: "#fff", workspaceIds: [], workspaceGrid: null },
        ],
        workspaces: [
          {
            id: "ws-a1",
            name: "A1",
            kind: "terminal",
            cwd: "/tmp/a1",
            profileId: "profile-a",
            panels: [{ id: "p1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p1",
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            cwd: "/tmp/b1",
            profileId: "profile-b",
            panels: [{ id: "p2", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p2",
          },
          {
            id: "ws-b2",
            name: "B2",
            kind: "terminal",
            cwd: "/tmp/b2",
            profileId: "profile-b",
            panels: [{ id: "p3", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p3",
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "profile-a",
            activeWorkspaceId: "ws-a1",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.activateProfileInWindow("profile-b", "win-1");

    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.profileId).toBe("profile-b");
    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.activeWorkspaceId).toBe("ws-b1");
    expect(payload.appState.activeWorkspaceId).toBe("ws-b1");
  });

  test("activateProfileInWindow spawns PTYs for the new active workspace's default-startup panels", async () => {
    // After profile switch the window shows the new profile's first workspace.
    // Its default-startup panels must already be running by the time the IPC
    // call returns — otherwise the renderer paints empty panes with "0 running"
    // while it waits for the user to interact with each tab.
    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-a",
        activeWorkspaceId: "ws-a1",
        profiles: [
          { id: "profile-a", name: "A", color: "#fff", workspaceIds: [], workspaceGrid: null },
          { id: "profile-b", name: "B", color: "#fff", workspaceIds: [], workspaceGrid: null },
        ],
        workspaces: [
          {
            id: "ws-a1",
            name: "A1",
            kind: "terminal",
            cwd: "/tmp/a1",
            profileId: "profile-a",
            panels: [{ id: "p-a1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p-a1",
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            cwd: "/tmp/b1",
            profileId: "profile-b",
            panels: [
              { id: "p-b1-shell", title: "Shell", command: "", shell: true, startup: "default" },
              { id: "p-b1-claude", title: "Claude", command: "claude", shell: true, startup: "default" },
            ],
            activePanelId: "p-b1-shell",
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "profile-a",
            activeWorkspaceId: "ws-a1",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    const payload = await fixture.runtime.activateProfileInWindow("profile-b", "win-1");

    expect(payload.appState.activeWorkspaceId).toBe("ws-b1");
    expect(fixture.sessionManager.sessions.get("ws-b1:p-b1-shell")?.status).toBe("running");
    expect(fixture.sessionManager.sessions.get("ws-b1:p-b1-claude")?.status).toBe("running");
    const wsSessions = payload.workspace?.sessions || [];
    const running = wsSessions.filter((s: { status: string }) => s.status === "running");
    expect(running).toHaveLength(2);
  });

  test("createWindowSlot spawns PTYs for the new window's first workspace", async () => {
    // Opening a new window for another profile from the sidebar lands the
    // user on that profile's first workspace. Without spawning here the
    // window paints empty panes with "0 running" until the user clicks away
    // and back — the per-window flow has no other trigger that calls
    // ensureSession on the freshly chosen active workspace.
    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-a",
        activeWorkspaceId: "ws-a1",
        profiles: [
          { id: "profile-a", name: "A", color: "#fff", workspaceIds: [], workspaceGrid: null },
          { id: "profile-b", name: "B", color: "#fff", workspaceIds: [], workspaceGrid: null },
        ],
        workspaces: [
          {
            id: "ws-a1",
            name: "A1",
            kind: "terminal",
            cwd: "/tmp/a1",
            profileId: "profile-a",
            panels: [{ id: "p-a1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p-a1",
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            cwd: "/tmp/b1",
            profileId: "profile-b",
            panels: [
              { id: "p-b1-shell", title: "Shell", command: "", shell: true, startup: "default" },
              { id: "p-b1-claude", title: "Claude", command: "claude", shell: true, startup: "default" },
            ],
            activePanelId: "p-b1-shell",
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "profile-a",
            activeWorkspaceId: "ws-a1",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    await fixture.runtime.createWindowSlot("profile-b");

    expect(fixture.sessionManager.sessions.get("ws-b1:p-b1-shell")?.status).toBe("running");
    expect(fixture.sessionManager.sessions.get("ws-b1:p-b1-claude")?.status).toBe("running");
  });

  test("activateWorkspaceInWindow on cross-profile target does not revert activeWorkspaceId", async () => {
    // Activating a workspace in profile A writes draft.activeWorkspaceId = ws-a2;
    // normalizeState then derives the active profile from ws-a2's profileId,
    // so subsequent normalization passes validate activeWorkspaceId against A's
    // workspaces and do not silently revert the click.
    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-b",
        activeWorkspaceId: "ws-b1",
        profiles: [
          { id: "profile-a", name: "A", color: "#fff", workspaceIds: [], workspaceGrid: null },
          { id: "profile-b", name: "B", color: "#fff", workspaceIds: [], workspaceGrid: null },
        ],
        workspaces: [
          {
            id: "ws-a1",
            name: "A1",
            kind: "terminal",
            cwd: "/tmp/a1",
            profileId: "profile-a",
            panels: [{ id: "p1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p1",
          },
          {
            id: "ws-a2",
            name: "A2",
            kind: "terminal",
            cwd: "/tmp/a2",
            profileId: "profile-a",
            panels: [{ id: "p2", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p2",
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            cwd: "/tmp/b1",
            profileId: "profile-b",
            panels: [{ id: "p3", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p3",
          },
        ],
        windowSlots: [
          {
            id: "win-1",
            profileId: "profile-a",
            activeWorkspaceId: "ws-a1",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: Date.now(),
          },
        ],
      },
    });
    fixtures.push(fixture);

    // Sanity: win-1 slot is on profile A
    const before = fixture.store.getState();
    expect(before.windowSlots?.[0].profileId).toBe("profile-a");

    const payload = await fixture.runtime.activateWorkspaceInWindow("ws-a2", "win-1");

    expect(payload.appState.windowSlots?.find((s) => s.id === "win-1")?.activeWorkspaceId).toBe("ws-a2");
    expect(payload.appState.activeWorkspaceId).toBe("ws-a2");
  });

  test("workspace grid operations without windowId target the active profile", async () => {
    const fixture = await createFixture({
      initialState: {
        activeProfileId: "profile-b",
        activeWorkspaceId: "ws-b1",
        workspaceGrid: null,
        profiles: [
          { id: "profile-a", name: "A", color: "#fff", workspaceIds: [], workspaceGrid: null },
          { id: "profile-b", name: "B", color: "#fff", workspaceIds: [], workspaceGrid: null },
        ],
        workspaces: [
          {
            id: "ws-a1",
            name: "A1",
            kind: "terminal",
            cwd: "/tmp/a1",
            profileId: "profile-a",
            panels: [{ id: "p1", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p1",
          },
          {
            id: "ws-b1",
            name: "B1",
            kind: "terminal",
            cwd: "/tmp/b1",
            profileId: "profile-b",
            panels: [{ id: "p2", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p2",
          },
          {
            id: "ws-b2",
            name: "B2",
            kind: "terminal",
            cwd: "/tmp/b2",
            profileId: "profile-b",
            panels: [{ id: "p3", title: "Shell", command: "", shell: true, startup: "default" }],
            activePanelId: "p3",
          },
        ],
      },
    });
    fixtures.push(fixture);

    let payload = await fixture.runtime.enableWorkspaceGrid("cols", ["ws-b1", "ws-b2"]);
    expect(payload.appState.profiles.find((p) => p.id === "profile-a")?.workspaceGrid).toBeNull();
    expect(payload.appState.profiles.find((p) => p.id === "profile-b")?.workspaceGrid).toEqual({
      layout: "cols",
      cellWorkspaceIds: ["ws-b1", "ws-b2"],
    });

    payload = await fixture.runtime.setGridCell(1, null);
    expect(payload.appState.profiles.find((p) => p.id === "profile-b")?.workspaceGrid).toEqual({
      layout: "cols",
      cellWorkspaceIds: ["ws-b1", null],
    });

    payload = await fixture.runtime.setGridLayout("grid");
    expect(payload.appState.profiles.find((p) => p.id === "profile-b")?.workspaceGrid).toEqual({
      layout: "grid",
      cellWorkspaceIds: ["ws-b1", null, null, null],
    });

    await fixture.runtime.setGridCell(2, "ws-b2");
    payload = await fixture.runtime.swapGridCells(0, 2);
    expect(payload.appState.profiles.find((p) => p.id === "profile-b")?.workspaceGrid).toEqual({
      layout: "grid",
      cellWorkspaceIds: ["ws-b2", null, "ws-b1", null],
    });

    payload = await fixture.runtime.disableWorkspaceGrid();
    expect(payload.appState.profiles.find((p) => p.id === "profile-b")?.workspaceGrid).toBeNull();
  });

  // ---------------------------------------------------------------------------
  // Plan § 6: Critical scenario regression tests.
  //
  // These enforce the invariants the notifications redesign is built on.
  // Each failure corresponds to a user-visible regression; don't ship red.
  // ---------------------------------------------------------------------------

  async function createTwoWorkspaceFixture() {
    return createFixture({
      initialState: {
        activeProjectId: "frontend",
        projects: [
          {
            id: "frontend",
            name: "Frontend",
            kind: "terminal",
            cwd: "/tmp/frontend",
            activePanelId: "claude",
            panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
          },
          {
            id: "backend",
            name: "Backend",
            kind: "terminal",
            cwd: "/tmp/backend",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
  }

  test("(Gap 2 regression) permission_prompt during idle cooldown fires urgent alert", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      // Put backend session off-screen so alerts land.
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // First alert: normal idle_prompt.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend.alerts[0]).toMatchObject({
        urgency: "normal",
        detail: "hook:Notification:idle_prompt",
      });

      // Inside the 15s standard cooldown window (5s elapsed) — a second
      // idle_prompt would be suppressed, but permission_prompt is urgent and
      // MUST reach the user even inside cooldown.
      await vi.advanceTimersByTimeAsync(5_000);
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");

      const alerts = fixture.runtime.getPayload().attention.byProject.backend.alerts;
      expect(alerts[0]).toMatchObject({
        urgency: "urgent",
        detail: "hook:Notification:permission_prompt",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("(Gap 2 regression) urgent alert still respects its own 3s short cooldown", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // Two permission_prompts within 3s — second is coalesced.
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");
      const firstAt = fixture.runtime.getPayload().attention.byProject.backend.alerts[0].at;

      await vi.advanceTimersByTimeAsync(1_000);
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");
      const stillFirst = fixture.runtime.getPayload().attention.byProject.backend.alerts[0].at;
      expect(stillFirst).toBe(firstAt);

      // After 3s urgent cooldown expires, next urgent alert lands.
      await vi.advanceTimersByTimeAsync(3_500);
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");
      const newer = fixture.runtime.getPayload().attention.byProject.backend.alerts[0].at;
      expect(newer).not.toBe(firstAt);
    } finally {
      vi.useRealTimers();
    }
  });

  test("completion-hook-capable agent session does NOT raise T3 silence alert", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      // Prime the session as agent-like with user input
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "do a thing\r");

      // A completion hook fires once — now signal.completionHookCapable = true. We clear the
      // alert (simulates user ack'ing) so the next detector cycle starts fresh.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });

      // Simulate a long burst of output + long silence. Without completion hook
      // gating this would trigger a T3 silence alert; with it, nothing fires.
      for (let i = 0; i < 20; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `line ${i}\n` });
      }
      // Well past agentQuietMs (45s) and agentQuietFastMs (25s).
      await vi.advanceTimersByTimeAsync(120_000);

      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("completion-hook-capable agent session ignores BEL while still running", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "do a thing\r");

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });

      // Some agent TUIs emit BEL while redrawing or thinking. With hooks
      // proven, BEL must not become a user-visible "waiting" alert.
      await vi.advanceTimersByTimeAsync(6_000);
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "still working\u0007\n" });

      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("UserPromptSubmit alone does not disable hook fallback", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "openai codex\n" });
      fixture.runtime.writeToSession("backend:shell", "do a thing\r");

      // UserPromptSubmit proves only the bookkeeping hook works. If Stop is
      // still blocked by Codex /hooks review, fallback must remain available.
      fixture.runtime.notifyAgentHook("backend:shell", "", "UserPromptSubmit");
      await vi.advanceTimersByTimeAsync(16_000);

      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "working\n" });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "> " });
      await vi.advanceTimersByTimeAsync(120_000);

      expect(fixture.runtime.getPayload().attention.byProject.backend.alerts[0]).toMatchObject({
        panelId: "shell",
        kind: "waiting",
        detail: "hook-fallback",
        tier: 3,
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("repeat idle_prompt with no new activity does NOT re-alert (real-world repeat bug)", async () => {
    vi.useFakeTimers();
    try {
      // Real-world scenario from user report: Claude fired idle_prompt once,
      // user jumped to the session, navigated away, Claude fired idle_prompt
      // AGAIN for the same waiting state → spurious second notification.
      //
      // Expected behavior: repeat hooks without new output bursts or a
      // UserPromptSubmit in between are coalesced silently.
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      // Backend tab off-screen; prime as agent-like via AGENT_OUTPUT_RE match.
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "fix bug\r");

      // Claude finishes a response — emit some output bursts so the first
      // idle_prompt looks like a real turn completion.
      for (let i = 0; i < 5; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `line ${i}\n` });
      }

      // First idle_prompt → alert fires.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      // User views the session (Jump equivalent): clearAlertForSession with
      // dismissed=false. This resets signal.waitingRaised/busy/outputBursts.
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();

      // User navigates away (backend panel no longer visible).
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      await vi.advanceTimersByTimeAsync(20_000); // past cooldown

      // Claude fires idle_prompt AGAIN for the same waiting state — no new
      // output bursts happened since reset.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");

      // No re-alert: the repeat idle_prompt is suppressed.
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("idle_prompt AFTER UserPromptSubmit DOES re-alert (legit turn)", async () => {
    vi.useFakeTimers();
    try {
      // Counter-test to the repeat-idle suppression: if the user sent a new
      // prompt (UserPromptSubmit hook) after the previous alert, the next
      // idle_prompt is a legit "Claude finished the new turn" and must fire.
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "fix bug\r");

      // Claude emits enough output to look like a real turn, then alerts.
      for (let i = 0; i < 12; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `first ${i}\n` });
      }
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      // User views and acks — signal reset zeros outputBursts.
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      await vi.advanceTimersByTimeAsync(20_000);

      // User types a new message — UserPromptSubmit hook fires. This is the
      // authoritative "user gave Claude new work" signal.
      fixture.runtime.notifyAgentHook("backend:shell", "", "UserPromptSubmit");

      // Claude works, then idle again.
      for (let i = 0; i < 12; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `second ${i}\n` });
      }
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
    } finally {
      vi.useRealTimers();
    }
  });

  test("idle_prompt AFTER substantial output (fallback without UserPromptSubmit) DOES re-alert", async () => {
    vi.useFakeTimers();
    try {
      // Defense for stale config: if UserPromptSubmit hook isn't registered
      // (old ~/.claude/settings.json without Phase 0 upgrade), we fall back
      // to output-burst threshold. 10+ bursts after reset = real turn.
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "fix bug\r");

      for (let i = 0; i < 12; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `first ${i}\n` });
      }
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      await vi.advanceTimersByTimeAsync(20_000);

      // No UserPromptSubmit (stale config), but Claude produces 12+ bursts.
      for (let i = 0; i < 12; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `second ${i}\n` });
      }

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });
    } finally {
      vi.useRealTimers();
    }
  });

  test("small heartbeat bursts (< threshold) without UserPromptSubmit DO suppress", async () => {
    vi.useFakeTimers();
    try {
      // Statusline redraws / cursor positioning might emit 1-3 output chunks
      // without representing real new work. Those must NOT defeat the repeat
      // idle_prompt suppression.
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "claude code v1.0\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "fix bug\r");

      for (let i = 0; i < 12; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `first ${i}\n` });
      }
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      await vi.advanceTimersByTimeAsync(20_000);

      // A few heartbeat bursts — well under the 10-burst threshold.
      for (let i = 0; i < 3; i += 1) {
        fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: `tick ${i}\n` });
      }

      // Repeat idle_prompt, no user prompt, only 3 bursts → suppress.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("permission_prompt is NEVER suppressed by repeat-idle guard (urgent bypass)", async () => {
    vi.useFakeTimers();
    try {
      // Regression guard: urgent permission_prompt must reach the user even
      // when the session's lastAlertAt is set and outputBursts is 0 — the
      // suppression rule is idle_prompt only.
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: false });
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      await vi.advanceTimersByTimeAsync(20_000);

      // Permission prompt with zero intervening output — urgent, must alert.
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");

      expect(fixture.runtime.getPayload().attention.byProject.backend.alerts[0]).toMatchObject({
        urgency: "urgent",
        detail: "hook:Notification:permission_prompt",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("idle task workspace does NOT fire spurious user alerts from auto-spawned Claude", async () => {
    vi.useFakeTimers();
    try {
      // Real-world bug report: user created task workspace but never pressed
      // Start. Worker and Judge panels auto-spawn Claude (default startup).
      // Both Claudes sit idle and fire Notification:idle_prompt hooks. The
      // task runner must consume them — the user shouldn't see "Worker
      // waiting for input" notifications for a task they never initiated.
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
          ],
          workspaces: [
            {
              id: "idletask",
              name: "Not started yet",
              kind: "task",
              cwd: "/tmp/idletask",
              activePanelId: "worker",
              panels: [
                { id: "worker", title: "Worker", command: "claude", shell: true, startup: "default" },
                { id: "judge", title: "Judge", command: "claude", shell: true, startup: "default" },
              ],
              task: {
                taskId: "t1",
                state: "idle",
                promptSent: false,
                currentRound: 0,
                workerPanelId: "worker",
                judgePanelId: "judge",
              },
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      // Both panels go through normal priming (input received, some output).
      fixture.sessionManager.emit("terminal:data", { sessionId: "idletask:worker", data: "$ " });
      fixture.sessionManager.emit("terminal:data", { sessionId: "idletask:judge", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("idletask:worker", "claude\r");
      fixture.runtime.writeToSession("idletask:judge", "claude\r");

      // Auto-spawned Claude on both panels hits idle → fires Notification hook.
      fixture.runtime.notifyAgentHook("idletask:worker", "idle_prompt");
      fixture.runtime.notifyAgentHook("idletask:judge", "idle_prompt");

      // NEITHER should reach the user — task runner consumes both.
      expect(fixture.runtime.getPayload().attention.byProject.idletask).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("(Gap 1 regression) Stop hook in paused task falls through to user pipeline", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "frontend",
          projects: [
            {
              id: "frontend",
              name: "Frontend",
              kind: "terminal",
              cwd: "/tmp/frontend",
              activePanelId: "claude",
              panels: [{ id: "claude", title: "Claude Code", command: "claude", shell: true, startup: "default" }],
            },
          ],
          workspaces: [
            {
              id: "mytask",
              name: "My task",
              kind: "task",
              cwd: "/tmp/mytask",
              activePanelId: "worker",
              panels: [
                { id: "worker", title: "Worker", command: "claude", shell: true, startup: "default" },
                { id: "judge", title: "Judge", command: "claude", shell: true, startup: "default" },
              ],
              task: {
                taskId: "t1",
                state: "paused",
                workerPanelId: "worker",
                judgePanelId: "judge",
                currentRound: 1,
              },
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "mytask:worker", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("mytask:worker", "go\r");

      // Task paused → task runner returns false → user pipeline sees the event.
      fixture.runtime.notifyAgentHook("mytask:worker", "idle_prompt");

      const alerts = fixture.runtime.getPayload().attention.byProject.mytask?.alerts || [];
      expect(alerts[0]).toMatchObject({
        detail: "hook:Notification:idle_prompt",
        tier: 1,
        urgency: "normal",
      });
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAlertForSession removes the attention entry and resets signal", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byProject.backend).toMatchObject({ count: 1 });

      const payload = fixture.runtime.clearAlertForSession("backend:shell");
      expect(payload.attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAlertForSession({dismissed:true}) feeds adaptive suppression", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);
      const baseline = fixture.runtime.getNotificationMetrics().dismissedWithoutInteraction;

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      // Dismiss 3 alerts in a row — adaptive multiplier should kick in (×2).
      for (let i = 0; i < 3; i += 1) {
        fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
        fixture.runtime.clearAlertForSession("backend:shell", { dismissed: true });
        await vi.advanceTimersByTimeAsync(16_000);
      }

      expect(fixture.runtime.getNotificationMetrics().dismissedWithoutInteraction).toBe(baseline + 3);

      // One more dismissal should keep metrics incrementing.
      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      fixture.runtime.clearAlertForSession("backend:shell", { dismissed: true });
      expect(fixture.runtime.getNotificationMetrics().dismissedWithoutInteraction).toBe(baseline + 4);
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAlertForSession without dismissed flag does NOT increment dismissal metric", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);
      const baseline = fixture.runtime.getNotificationMetrics().dismissedWithoutInteraction;

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      // Jump equivalent — clears without dismissed flag
      fixture.runtime.clearAlertForSession("backend:shell");

      expect(fixture.runtime.getNotificationMetrics().dismissedWithoutInteraction).toBe(baseline);
    } finally {
      vi.useRealTimers();
    }
  });

  test("notification metrics track hooks, alerts, tier and urgency", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "claude\r");

      fixture.runtime.notifyAgentHook("backend:shell", "idle_prompt");
      await vi.advanceTimersByTimeAsync(20_000);
      fixture.runtime.notifyAgentHook("backend:shell", "permission_prompt");

      const m = fixture.runtime.getNotificationMetrics();
      expect(m.hooksReceived).toBeGreaterThanOrEqual(2);
      expect(m.alertsTotal).toBeGreaterThanOrEqual(2);
      expect(m.alertsByUrgency.urgent).toBeGreaterThanOrEqual(1);
      expect(m.alertsByTier[1]).toBeGreaterThanOrEqual(2);
    } finally {
      vi.useRealTimers();
    }
  });

  test("stale busy latch resets after 5× agentQuietMs silence", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoWorkspaceFixture();
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: ["frontend:claude"] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "building...\n" });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("backend:shell", "npm run build\r");
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "compiling...\n" });

      // Wait 5× agentQuietMs (default 45s) + a buffer so the stale-busy reset
      // fires when the next chunk arrives. Using the shipped default directly
      // — if it changes, the assertion still holds because we overshoot.
      const staleMs = 45_000 * 5 + 5_000;
      await vi.advanceTimersByTimeAsync(staleMs);

      // Emit a fresh burst — the stale-busy guard should reset outputBursts
      // and busy before processing this chunk, so no alert piggy-backs on the
      // now-stale busy latch.
      fixture.sessionManager.emit("terminal:data", { sessionId: "backend:shell", data: "compiled.\n" });

      expect(fixture.runtime.getPayload().attention.byProject.backend).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("clearAllAttention — profile scoping", () => {
  test("with a windowId, only clears alerts whose workspace lives in that window's profile", async () => {
    vi.useFakeTimers();
    try {
      // Two profiles, each with a workspace owning a live attention alert.
      // A "Clear all" issued by profile-b's window must NOT wipe the
      // default-profile alert — without this scoping, the bell on the
      // default-profile window would silently fall to zero.
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "ws-default",
          profiles: [
            { id: "default", name: "Default" },
            { id: "profile-b", name: "Profile B" },
          ],
          projects: [
            {
              id: "ws-default",
              name: "Default WS",
              kind: "terminal",
              profileId: "default",
              cwd: "/tmp/wsd",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
            {
              id: "ws-b",
              name: "Profile B WS",
              kind: "terminal",
              profileId: "profile-b",
              cwd: "/tmp/wsb",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
          windowSlots: [
            {
              id: "win-default",
              profileId: "default",
              activeWorkspaceId: "ws-default",
              activeSessionId: "",
              bounds: { x: 0, y: 0, width: 1280, height: 800 },
              lastFocusedAt: 1000,
            },
            {
              id: "win-b",
              profileId: "profile-b",
              activeWorkspaceId: "ws-b",
              activeSessionId: "",
              bounds: { x: 0, y: 0, width: 1280, height: 800 },
              lastFocusedAt: 2000,
            },
          ],
        },
      });
      fixtures.push(fixture);

      // Raise an alert on each workspace.
      await fixture.runtime.syncAttentionContext({ visibleSessionIds: [] });
      for (const sessionId of ["ws-default:shell", "ws-b:shell"]) {
        fixture.sessionManager.emit("terminal:data", { sessionId, data: "$ " });
      }
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("ws-default:shell", "claude\r");
      fixture.runtime.writeToSession("ws-b:shell", "claude\r");
      fixture.runtime.notifyAgentHook("ws-default:shell", "idle_prompt");
      fixture.runtime.notifyAgentHook("ws-b:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-default"]).toBeDefined();
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-b"]).toBeDefined();

      // Profile-b's window clicks "Clear all".
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).clearAllAttention("win-b");
      const after = fixture.runtime.getPayload().attention.byWorkspace;
      // Default-profile alert survives.
      expect(after["ws-default"]).toBeDefined();
      // Profile-b alert is gone.
      expect(after["ws-b"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("without a windowId, falls back to the legacy global clear", async () => {
    vi.useFakeTimers();
    try {
      // Back-compat: callers that pre-date the windowId argument (and any
      // path the harness can't bind to a window) should still get the old
      // global wipe rather than a silent no-op.
      const fixture = await createFixture({
        initialState: {
          activeProjectId: "ws-default",
          projects: [
            {
              id: "ws-default",
              name: "Default WS",
              kind: "terminal",
              profileId: "default",
              cwd: "/tmp/wsd",
              activePanelId: "shell",
              panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
            },
          ],
        },
      });
      fixtures.push(fixture);

      await fixture.runtime.syncAttentionContext({ visibleSessionIds: [] });
      fixture.sessionManager.emit("terminal:data", { sessionId: "ws-default:shell", data: "$ " });
      await vi.advanceTimersByTimeAsync(16_000);
      fixture.runtime.writeToSession("ws-default:shell", "claude\r");
      fixture.runtime.notifyAgentHook("ws-default:shell", "idle_prompt");
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-default"]).toBeDefined();

      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).clearAllAttention();
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-default"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });
});

describe("clearAlertForSession + syncAttentionContext — profile scoping", () => {
  // Same shape as clearAllAttention's tests: two profiles, two windows, two
  // workspaces with raised alerts. The bug we're regression-testing is a
  // remote/IPC caller on profile B passing any sessionId or visibleSessionIds
  // and silencing profile A's bells.
  async function createTwoProfileFixture() {
    return createFixture({
      initialState: {
        activeProjectId: "ws-default",
        profiles: [
          { id: "default", name: "Default" },
          { id: "profile-b", name: "Profile B" },
        ],
        projects: [
          {
            id: "ws-default",
            name: "Default WS",
            kind: "terminal",
            profileId: "default",
            cwd: "/tmp/wsd",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b",
            name: "Profile B WS",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/wsb",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
        windowSlots: [
          {
            id: "win-default",
            profileId: "default",
            activeWorkspaceId: "ws-default",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 1000,
          },
          {
            id: "win-b",
            profileId: "profile-b",
            activeWorkspaceId: "ws-b",
            activeSessionId: "",
            bounds: { x: 0, y: 0, width: 1280, height: 800 },
            lastFocusedAt: 2000,
          },
        ],
      },
    });
  }

  // Raise an attention alert on each of the two workspaces.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async function raiseAlertsOnBoth(fixture: any): Promise<void> {
    await fixture.runtime.syncAttentionContext({ visibleSessionIds: [] });
    for (const sessionId of ["ws-default:shell", "ws-b:shell"]) {
      fixture.sessionManager.emit("terminal:data", { sessionId, data: "$ " });
    }
    await vi.advanceTimersByTimeAsync(16_000);
    fixture.runtime.writeToSession("ws-default:shell", "claude\r");
    fixture.runtime.writeToSession("ws-b:shell", "claude\r");
    fixture.runtime.notifyAgentHook("ws-default:shell", "idle_prompt");
    fixture.runtime.notifyAgentHook("ws-b:shell", "idle_prompt");
  }

  test("clearAlertForSession refuses a cross-profile sessionId", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoProfileFixture();
      fixtures.push(fixture);
      await raiseAlertsOnBoth(fixture);

      // Profile-b's window asks to clear profile-default's session — must
      // refuse rather than wipe the default-profile alert.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).clearAlertForSession("ws-default:shell", { windowId: "win-b" });
      const after = fixture.runtime.getPayload().attention.byWorkspace;
      expect(after["ws-default"]).toBeDefined();
      expect(after["ws-b"]).toBeDefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAlertForSession allows same-profile sessionIds", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoProfileFixture();
      fixtures.push(fixture);
      await raiseAlertsOnBoth(fixture);

      // Profile-b's window clears its own session — must work normally.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).clearAlertForSession("ws-b:shell", { windowId: "win-b" });
      const after = fixture.runtime.getPayload().attention.byWorkspace;
      expect(after["ws-default"]).toBeDefined();
      expect(after["ws-b"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("clearAlertForSession without windowId preserves legacy unscoped behavior", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoProfileFixture();
      fixtures.push(fixture);
      await raiseAlertsOnBoth(fixture);

      // No windowId → in-process / legacy caller, no scope check.
      fixture.runtime.clearAlertForSession("ws-default:shell");
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-default"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("syncAttentionContext ignores visibleSessionIds outside the caller's profile", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoProfileFixture();
      fixtures.push(fixture);
      await raiseAlertsOnBoth(fixture);

      // Profile-b's window claims profile-default's session is visible.
      // After ATTENTION_MIN_DISPLAY_MS (~6s) the unscoped code path would
      // clear the default alert — the scoped path must not.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).syncAttentionContext({
        visibleSessionIds: ["ws-default:shell"],
        windowId: "win-b",
      });
      await vi.advanceTimersByTimeAsync(10_000);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).syncAttentionContext({
        visibleSessionIds: ["ws-default:shell"],
        windowId: "win-b",
      });
      expect(fixture.runtime.getPayload().attention.byWorkspace["ws-default"]).toBeDefined();
    } finally {
      vi.useRealTimers();
    }
  });

  test("syncAttentionContext clears alerts for same-profile visible sessions", async () => {
    vi.useFakeTimers();
    try {
      const fixture = await createTwoProfileFixture();
      fixtures.push(fixture);
      await raiseAlertsOnBoth(fixture);

      // Profile-b's window correctly reports its own session visible.
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).syncAttentionContext({
        visibleSessionIds: ["ws-b:shell"],
        windowId: "win-b",
      });
      await vi.advanceTimersByTimeAsync(10_000);
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (fixture.runtime as any).syncAttentionContext({
        visibleSessionIds: ["ws-b:shell"],
        windowId: "win-b",
      });
      const after = fixture.runtime.getPayload().attention.byWorkspace;
      expect(after["ws-default"]).toBeDefined();
      expect(after["ws-b"]).toBeUndefined();
    } finally {
      vi.useRealTimers();
    }
  });
});

// ---------------------------------------------------------------------------
// Task-agent crash recovery (see docs/task-recovery.md)
// ---------------------------------------------------------------------------

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function makeTaskWorkspace(overrides: Partial<any> = {}): any {
  const id = overrides.id || `ws-${Math.random().toString(36).slice(2, 8)}`;
  const workerPanelId = "panel-worker";
  const judgePanelId = "panel-judge";
  return {
    id,
    name: overrides.name || "Recovery test task",
    kind: "task",
    cwd: overrides.cwd || "/tmp/test-task",
    profileId: overrides.profileId || "default",
    activePanelId: "panel-dashboard",
    panels: [
      { id: "panel-dashboard", title: "Dashboard", command: "__task-dashboard__", startup: "none" },
      { id: workerPanelId, title: "Worker", command: "echo worker", startup: "default" },
      { id: judgePanelId, title: "Judge", command: "echo judge", startup: "default" },
    ],
    task: {
      taskId: overrides.taskId || `task-${id}`,
      description: "test task",
      parentWorkspaceId: "",
      worktreeBase: "",
      worktreeBranch: "",
      workerPanelId,
      judgePanelId,
      maxRounds: overrides.maxRounds ?? 5,
      showerInterval: 5,
      state: overrides.state ?? "running",
      currentRound: overrides.currentRound ?? 2,
      rounds: [],
      lastShowerRound: 0,
      lastJudgeInstructions: "",
      promptSent: true,
      pausedFromState: "",
      showerResumePrompt: "",
      ...overrides.task,
    },
  };
}

describe("task recovery: resolveTaskRecovery", () => {
  test("'continue' decision sets a recovery prompt and re-spawns PTY sessions", async () => {
    const ws = makeTaskWorkspace({ id: "ws-cont", state: "running", currentRound: 3 });
    const fixture = await createFixture({
      initialState: {
        workspaces: [ws],
        activeWorkspaceId: "",
      },
    });
    fixtures.push(fixture);

    // After init, the task should already be paused with a candidate ready
    const meta = fixture.runtime.getPayload().meta;
    expect(meta.recoveryCandidates).toHaveLength(1);
    expect(meta.recoveryCandidates[0].workspaceId).toBe("ws-cont");
    expect(meta.recoveryCandidates[0].previousState).toBe("running");

    const result = await fixture.runtime.resolveTaskRecovery({ "ws-cont": "continue" });
    expect(result.ok).toBe(true);

    // showerResumePrompt now carries the orientation prompt for the worker
    const wsAfter = fixture.runtime.getPayload().appState.workspaces.find((w: { id: string }) => w.id === "ws-cont");
    expect(wsAfter?.task?.showerResumePrompt).toBeTruthy();
    expect(wsAfter?.task?.showerResumePrompt).toContain("WORKER");
    expect(wsAfter?.task?.showerResumePrompt).toContain("round 3");
    expect(wsAfter?.task?.promptSent).toBe(false);

    // Both worker and judge PTYs were spawned (we don't know which idle's
    // first, so resolveTaskRecovery prepares both)
    expect(fixture.sessionManager.sessions.has("ws-cont:panel-worker")).toBe(true);
    expect(fixture.sessionManager.sessions.has("ws-cont:panel-judge")).toBe(true);

    // Candidate list cleared — a redrive can't double-spawn
    expect(fixture.runtime.getPayload().meta.recoveryCandidates).toHaveLength(0);
  });

  test("'continue' with previousState=judge-evaluating builds a JUDGE recovery prompt", async () => {
    const ws = makeTaskWorkspace({ id: "ws-judge", state: "judge-evaluating", currentRound: 4 });
    const fixture = await createFixture({ initialState: { workspaces: [ws] } });
    fixtures.push(fixture);

    await fixture.runtime.resolveTaskRecovery({ "ws-judge": "continue" });

    const wsAfter = fixture.runtime.getPayload().appState.workspaces.find((w: { id: string }) => w.id === "ws-judge");
    expect(wsAfter?.task?.showerResumePrompt).toContain("JUDGE");
    // resumeTask should have flipped state back to judge-evaluating
    expect(wsAfter?.task?.state).toBe("judge-evaluating");
  });

  test("'skip' leaves task paused without spawning sessions", async () => {
    const ws = makeTaskWorkspace({ id: "ws-skip", state: "running" });
    const fixture = await createFixture({ initialState: { workspaces: [ws] } });
    fixtures.push(fixture);

    await fixture.runtime.resolveTaskRecovery({ "ws-skip": "skip" });

    const wsAfter = fixture.runtime.getPayload().appState.workspaces.find((w: { id: string }) => w.id === "ws-skip");
    // Task remains paused (the reconcile sweep already paused it)
    expect(wsAfter?.task?.state).toBe("paused");
    // No recovery prompt
    expect(wsAfter?.task?.showerResumePrompt).toBe("");
    // No PTY spawned
    expect(fixture.sessionManager.sessions.has("ws-skip:panel-worker")).toBe(false);
  });

  test("'fresh' decision resets the round counter", async () => {
    const ws = makeTaskWorkspace({ id: "ws-fresh", state: "running", currentRound: 7 });
    const fixture = await createFixture({ initialState: { workspaces: [ws] } });
    fixtures.push(fixture);

    await fixture.runtime.resolveTaskRecovery({ "ws-fresh": "fresh" });

    const wsAfter = fixture.runtime.getPayload().appState.workspaces.find((w: { id: string }) => w.id === "ws-fresh");
    expect(wsAfter?.task?.currentRound).toBe(0);
    expect(wsAfter?.task?.rounds).toHaveLength(0);
    expect(wsAfter?.task?.state).toBe("idle");
  });

  test("ignores decisions for workspaces that aren't recovery candidates", async () => {
    // This task is "completed" — not a recovery candidate. A stray decision
    // for it must not crash and must not flip its state.
    const completed = makeTaskWorkspace({ id: "ws-done", state: "completed", currentRound: 5 });
    const fixture = await createFixture({ initialState: { workspaces: [completed] } });
    fixtures.push(fixture);

    expect(fixture.runtime.getPayload().meta.recoveryCandidates).toHaveLength(0);

    const result = await fixture.runtime.resolveTaskRecovery({ "ws-done": "continue" });
    expect(result.ok).toBe(true);

    const wsAfter = fixture.runtime.getPayload().appState.workspaces.find((w: { id: string }) => w.id === "ws-done");
    expect(wsAfter?.task?.state).toBe("completed");
    expect(wsAfter?.task?.showerResumePrompt).toBe("");
  });
});

describe("task recovery: dialog is the only resume path", () => {
  test("startup leaves candidates pending — no silent auto-resume", async () => {
    const ws = makeTaskWorkspace({ id: "ws-default", state: "running" });
    const fixture = await createFixture({
      initialState: {
        workspaces: [ws],
      },
    });
    fixtures.push(fixture);

    await new Promise((r) => setImmediate(r));

    const meta = fixture.runtime.getPayload().meta;
    expect(meta.recoveryCandidates).toHaveLength(1);
    expect(meta.recoveryCandidates[0].workspaceId).toBe("ws-default");
    // Worker session NOT spawned — that happens when the user picks "continue"
    expect(fixture.sessionManager.sessions.has("ws-default:panel-worker")).toBe(false);
  });
});

describe("task recovery: sequential resolve", () => {
  test("processing decisions one at a time leaves remaining candidates available", async () => {
    const wsA = makeTaskWorkspace({ id: "ws-a", state: "running" });
    const wsB = makeTaskWorkspace({ id: "ws-b", state: "running" });
    const fixture = await createFixture({
      initialState: {
        workspaces: [wsA, wsB],
      },
    });
    fixtures.push(fixture);

    // Both candidates present after startup
    expect(fixture.runtime.getPayload().meta.recoveryCandidates).toHaveLength(2);

    // Resolve only ws-a — ws-b must still be a candidate so the dialog can
    // advance to it. Previously this branch wiped the whole list.
    await fixture.runtime.resolveTaskRecovery({ "ws-a": "skip" });

    const meta = fixture.runtime.getPayload().meta;
    expect(meta.recoveryCandidates).toHaveLength(1);
    expect(meta.recoveryCandidates[0].workspaceId).toBe("ws-b");

    // Then resolve the second one — list drains
    await fixture.runtime.resolveTaskRecovery({ "ws-b": "skip" });
    expect(fixture.runtime.getPayload().meta.recoveryCandidates).toHaveLength(0);
  });
});

// Drain queueMicrotask + setImmediate yields enough times to let the startup
// background refresh loop walk every queued workspace.
async function drainBackgroundLoop(iterations = 30): Promise<void> {
  for (let i = 0; i < iterations; i++) {
    await new Promise((resolve) => setImmediate(resolve));
  }
}

describe("startup git refresh — scoping + cache reuse", () => {
  test("background warmup only refreshes workspaces in the active profile", async () => {
    // Active workspace lives in profile-a. The other workspace in profile-a
    // should get a background refresh. The two workspaces under profile-b
    // belong to a profile the user can't see right now — refreshing them at
    // startup would burn ~14 git spawns per workspace for no observable
    // benefit. The scope guard in runInitialRefresh keeps this off.
    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "ws-a-1",
        profiles: [
          { id: "profile-a", name: "Profile A" },
          { id: "profile-b", name: "Profile B" },
        ],
        workspaces: [
          {
            id: "ws-a-1",
            name: "A1",
            kind: "terminal",
            profileId: "profile-a",
            cwd: "/tmp/a1",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-a-2",
            name: "A2",
            kind: "terminal",
            profileId: "profile-a",
            cwd: "/tmp/a2",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b-1",
            name: "B1",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b1",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-b-2",
            name: "B2",
            kind: "terminal",
            profileId: "profile-b",
            cwd: "/tmp/b2",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);
    await drainBackgroundLoop();

    const refreshedIds = fixture.git.refreshArgs.flat();
    expect(refreshedIds).toContain("ws-a-1"); // foreground refresh for active
    expect(refreshedIds).toContain("ws-a-2"); // background warmup, same profile
    expect(refreshedIds).not.toContain("ws-b-1"); // different profile — skipped
    expect(refreshedIds).not.toContain("ws-b-2");
  });

  test("background warmup skips invalidateSnapshotCache so the snapshot TTL still applies", async () => {
    // refreshGit invalidates the snapshot cache by default — without the
    // useCache opt-out the background loop would wipe and re-fetch every
    // workspace even when GitManager's own 8s TTL already had fresh data
    // from the foreground refresh.
    const fixture = await createFixture({
      initialState: {
        activeWorkspaceId: "ws-a-1",
        profiles: [{ id: "profile-a", name: "Profile A" }],
        workspaces: [
          {
            id: "ws-a-1",
            name: "A1",
            kind: "terminal",
            profileId: "profile-a",
            cwd: "/tmp/a1",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
          {
            id: "ws-a-2",
            name: "A2",
            kind: "terminal",
            profileId: "profile-a",
            cwd: "/tmp/a2",
            activePanelId: "shell",
            panels: [{ id: "shell", title: "Shell", command: "", shell: true, startup: "default" }],
          },
        ],
      },
    });
    fixtures.push(fixture);
    await drainBackgroundLoop();

    const invalidatedIds = fixture.git.invalidateCalls.map((call: { workspaceId: string | null }) => call.workspaceId);
    // Foreground refresh of the active workspace went through the normal
    // (invalidating) path.
    expect(invalidatedIds).toContain("ws-a-1");
    // Background warmup ran refreshGit("ws-a-2", { useCache: true }) — that
    // workspace's cache slot must never have been invalidated.
    expect(invalidatedIds).not.toContain("ws-a-2");
  });
});
