/**
 * Orchestrator — the chat channel runtime.
 *
 * One **channel** per task. A channel holds an `AgentSession` per roster
 * agent and emits protocol events for the cloud side to persist, stream,
 * and route. It is the in-process stand-in for what becomes the host
 * agent process in the hosted product (docs/hosted-architecture.md).
 *
 * Responsibilities:
 *   - Lazily build a channel: load the project roster, spawn sessions.
 *   - Deliver cloud-routed messages into live agent sessions.
 *   - Emit typed HostEvents for agent output, tools, permissions, exits.
 *
 * Module singleton, stashed on globalThis so Next.js HMR reuses it
 * instead of leaking a second orchestrator.
 */

import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";

import { inArray } from "drizzle-orm";

import { getDb, schema } from "./db";
import { mockAgentFactory } from "./agents/mock";
import { realAgentFactory } from "./agents/factory";
import {
  type AgentEvent,
  type AgentSession,
  type AgentSessionFactory,
  type Roster,
  type RosterAgent,
} from "./agents/types";
import { ACTIVE_STATUSES } from "./task-status";
import { getHostTask, upsertHostTask } from "./runtime-state";
import { clearRefresh, setupTaskGithub } from "./github-tokens";
import { setupTaskGitIdentity } from "./git-identity";
import { dockerCli } from "./docker-exec";
import {
  containerSkillFile,
  installedSkillPath,
  installPackageSkills,
  writeAgentSkills,
} from "./skills";
import {
  CONTAINER_CLI_PATH,
  agentCliEnv,
  apiUrlFromCloudUrl,
  loadTaskCliSecret,
  writeAgentCli,
} from "./agent-cli";
import { setupBrowserTesting } from "./browser-testing";
import { setupMcpTaskConfig } from "./mcp-gateway";
import { env } from "./env";
import type {
  ChannelEnsureInput,
  ChannelHuman,
  HostEvent,
} from "../src/protocol";

export type HostEventSubscriber = (event: HostEvent) => void;

// ---------------------------------------------------------------------------
// Channel — one task's live conversation.
// ---------------------------------------------------------------------------

interface Channel {
  taskId: string;
  roster: Roster;
  sessions: Map<string, AgentSession>;
  /** Session-spawn inputs, kept so sessions can be started lazily. */
  containerName: string;
  /** Per-agent system preamble (channel briefing + assembled persona). */
  preambles: Map<string, string>;
  /** Per-agent first-turn message — only agents whose `initialPrompt` is
   *  non-empty have an entry. Delivered once at container-ready. */
  firstTurns: Map<string, string>;
  /** In-flight (or completed) session start. A memoized promise — not a
   *  boolean flag — so a concurrent deliver() awaits actual readiness instead
   *  of observing "started" while the sessions map is still empty (the start
   *  awaits docker work before populating it). Reset to null on failure so the
   *  next ensure retries. */
  sessionsReady: Promise<boolean> | null;
  /** Agents with turn output already streamed for the current turn — i.e. the
   *  cloud has buffered content for them (see ChannelRouter.turnBuffer). */
  openTurns: Set<string>;
  /** Agents whose current turn was interrupted (ESC) — their next
   *  turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
   *  half-turn instead of delivering it to @-mentioned peers. */
  interrupted: Set<string>;
  /** Per-agent respawn counter — bounded so a broken agent can't
   *  loop forever rewriting its config. */
  respawns: Map<string, number>;
  /** When each agent last burned a respawn — a budget older than the
   *  cooldown resets, so a task self-heals once the underlying failure
   *  (missing CLI, broken config) clears instead of needing an operator. */
  respawnLastAt: Map<string, number>;
  /** ADR-049: humans in the chat (from the latest channel spec). */
  humans: ChannelHuman[];
  /** ADR-053: wire the Playwright MCP browser at session start. */
  browserTesting: boolean;
  /** ADR-062: shared-files mount mode ("off" | "ro" | "rw") for the preamble. */
  sharedFiles: string;
  /** ADR-057: the owner's MCP connections exposed through the host gateway. */
  mcpConnections: Array<{ id: string; slug: string }>;
  /** Last connection set written into the container (skip repeat execs —
   *  ensure runs on every message). Live sessions read MCP config at spawn,
   *  so a mid-task write takes effect on the next (re)spawn. */
  mcpConfigFingerprint?: string;
  /** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
   *  against a concurrent ensure double-spawning the same new agent. */
  spawning: Set<string>;
}

/** Hard cap on automatic respawns per agent per channel lifetime. */
const MAX_RESPAWNS_PER_AGENT = 5;
/** A burned respawn budget resets after this quiet period (see reconcile). */
const RESPAWN_COOLDOWN_MS = 10 * 60_000;

/** Substrings in an agent's error output that mean "config was
 *  unlinked between runs" — repair-and-respawn covers the common
 *  Docker-Desktop-macOS race where Claude's atomic writes briefly
 *  leave .claude.json missing. */
const CLAUDE_CONFIG_MISSING_PATTERNS = [
  /Claude configuration file not found/i,
  /\/\.claude\.json/i,
];

class Orchestrator {
  private readonly channels = new Map<string, Channel>();
  private readonly channelSpecs = new Map<string, ChannelEnsureInput>();
  private readonly hostSubscribers = new Set<HostEventSubscriber>();

  constructor(private readonly factory: AgentSessionFactory) {}

  // -- subscriptions --------------------------------------------------------

  subscribeHostEvents(fn: HostEventSubscriber): () => void {
    this.hostSubscribers.add(fn);
    return () => this.hostSubscribers.delete(fn);
  }

  /** Emit a host-originated system note into a task's channel (ADR-027). */
  emitSystemNote(taskId: string, text: string): void {
    this.emitHost({ kind: "system.note", taskId, text });
  }

  private emitHost(event: HostEvent): void {
    for (const fn of this.hostSubscribers) {
      try {
        fn(event);
      } catch {
        // A broken cloud subscriber must not take the channel down.
      }
    }
  }

  // -- channel lifecycle ----------------------------------------------------

  registerChannelSpec(spec: ChannelEnsureInput): void {
    this.channelSpecs.set(spec.taskId, spec);
    // ADR-049: the cloud re-sends the spec on every message AND right after a
    // mid-task roster/participant change. If the channel is already live,
    // fold the fresh spec in: append new roster agents (their sessions spawn
    // in the reconcile pass of ensureSessions) and rebuild every preamble so
    // a later respawn briefs agents with the CURRENT roster + humans.
    const channel = this.channels.get(spec.taskId);
    if (channel) this.refreshChannel(channel, spec);
  }

  /** Fold a fresh channel spec into a live channel (ADR-049). */
  private refreshChannel(channel: Channel, spec: ChannelEnsureInput): void {
    const known = new Set(channel.roster.map((a) => a.id));
    for (const agent of spec.agents) {
      if (!known.has(agent.id)) {
        channel.roster.push(agent);
        // Per-agent link/document skills, like getOrCreateChannel does at
        // channel birth. Best-effort.
        writeAgentSkills(channel.taskId, agent);
      }
    }
    channel.humans = spec.humans ?? [];
    channel.browserTesting = spec.browserTesting === true;
    channel.sharedFiles = spec.sharedFiles ?? "ro";
    channel.mcpConnections = spec.mcpConnections ?? [];
    for (const agent of channel.roster) {
      channel.preambles.set(
        agent.id,
        buildSystemPreamble(
          channel.roster,
          agent,
          spec.projects,
          spec.globalContext,
          spec.workspacePath,
          spec.branch,
          channel.humans,
          channel.browserTesting,
          channel.sharedFiles,
        ),
      );
    }
  }

  private async getOrCreateChannel(taskId: string): Promise<Channel | null> {
    const existing = this.channels.get(taskId);
    if (existing) return existing;

    const spec = this.channelSpecs.get(taskId);
    if (!spec) return null;

    const roster = spec.agents;
    const preambles = new Map<string, string>();
    const firstTurns = new Map<string, string>();
    for (const agent of roster) {
      preambles.set(
        agent.id,
        buildSystemPreamble(
          roster,
          agent,
          spec.projects,
          spec.globalContext,
          spec.workspacePath,
          spec.branch,
          spec.humans,
          spec.browserTesting,
          spec.sharedFiles ?? "ro",
        ),
      );
      // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
      // the workspace (bind-mounted into the container). Best-effort.
      writeAgentSkills(taskId, agent);
      // ADR-022: any agent with a non-empty initialPrompt opens a first
      // turn at container-ready. The rest stay silent until addressed.
      const firstTurn = assembleFirstTurnPrompt(
        spec.projects,
        spec.globalContext,
        agent,
      );
      if (firstTurn !== null) firstTurns.set(agent.id, firstTurn);
    }

    const channel: Channel = {
      taskId,
      roster,
      sessions: new Map(),
      containerName: `task-${taskId.toLowerCase()}-app-1`,
      preambles,
      firstTurns,
      sessionsReady: null,
      openTurns: new Set(),
      interrupted: new Set(),
      respawns: new Map(),
      respawnLastAt: new Map(),
      humans: spec.humans ?? [],
      browserTesting: spec.browserTesting === true,
      sharedFiles: spec.sharedFiles ?? "ro",
      mcpConnections: spec.mcpConnections ?? [],
      spawning: new Set(),
    };
    this.channels.set(taskId, channel);
    return channel;
  }

  /**
   * Spawn the channel's agent sessions, lazily and exactly once.
   *
   * The real adapters `docker exec` into the task container, which only
   * exists after `task-up` has run `docker compose up`. So sessions are
   * gated on the task being `running` — calling this earlier is a no-op
   * that returns `false`. Once the task is running, the first call
   * spawns every roster agent's session.
   *
   * Returns whether sessions are ready.
   */
  private async ensureSessions(channel: Channel): Promise<boolean> {
    // Memoized: every caller awaits the SAME in-flight start, so a concurrent
    // deliver() can't observe "ready" while the sessions map is still empty
    // (startSessions awaits docker work before populating it — the old boolean
    // flag flipped before that await and opened exactly that window).
    if (!channel.sessionsReady) {
      channel.sessionsReady = this.startSessions(channel);
      // A failed/aborted start must not poison the channel — reset so the
      // next ensure retries (e.g. the task wasn't `running` yet).
      channel.sessionsReady.then(
        (ok) => {
          if (!ok) channel.sessionsReady = null;
        },
        (err: unknown) => {
          // A start failure retries on the next ensure — but it must be
          // VISIBLE: an unlogged throw here once looped silently every ~2s
          // while a task sat dead with no sessions and no trace.
          console.warn(
            `[orchestrator] ${channel.taskId}: session start failed: ` +
              `${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
          );
          channel.sessionsReady = null;
        },
      );
    }
    const ready = await channel.sessionsReady;
    // ADR-049 reconcile pass: spawn any roster agent that has no live session
    // yet — the initial batch, host-restart recovery, and mid-task adds all
    // converge here. No-op when every roster agent has a session.
    if (ready) {
      await this.reconcileSessions(channel);
      // ADR-057: keep the container's MCP configs current so a connection
      // added mid-task lands (effective at each session's next spawn).
      await this.ensureMcpConfig(channel);
    }
    return ready;
  }

  /** Write the task's MCP configs when the connection set changed. */
  private async ensureMcpConfig(channel: Channel): Promise<void> {
    const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
    if (channel.mcpConfigFingerprint === fingerprint) return;
    const hadPrevious = channel.mcpConfigFingerprint !== undefined;
    channel.mcpConfigFingerprint = fingerprint;
    await setupMcpTaskConfig(
      channel.taskId,
      channel.containerName,
      channel.mcpConnections,
      channel.roster.some((a) => a.kind === "codex"),
    );
    // Agent CLIs read MCP servers once, at process start — and durable
    // sessions (ADR-061) make processes long-lived, so without this a
    // connection added mid-task stays invisible indefinitely. Recycle IDLE
    // sessions so the change actually reaches the agents; busy agents keep
    // their turn (and in-memory context) and pick it up on a later change or
    // natural respawn. Trade-off: a recycled agent loses its in-memory
    // context and is re-briefed from chat — same as any respawn, and better
    // than never seeing the connection the user just added. Skipped on the
    // channel's first fingerprint (sessions just spawned with this config).
    if (hadPrevious) await this.recycleIdleSessions(channel);
  }

  /** Close + respawn every session with no open turn (MCP set changed). */
  private async recycleIdleSessions(channel: Channel): Promise<void> {
    let recycled = 0;
    for (const [agentId, session] of [...channel.sessions]) {
      if (channel.openTurns.has(agentId)) continue; // never mid-turn
      if (channel.spawning.has(agentId)) continue;
      channel.sessions.delete(agentId);
      try {
        // close() flips the adapter's `closed` flag first, so the session's
        // exit is suppressed — no budget increment, no agent.exit noise, no
        // race with the respawn below (recoverClaudeAgent's pattern).
        await session.close();
      } catch {
        // Already dead — close is idempotent for our adapters.
      }
      recycled += 1;
    }
    if (recycled > 0) {
      console.log(
        `[orchestrator] ${channel.taskId}: recycled ${recycled} idle session(s) for MCP config change`,
      );
      await this.reconcileSessions(channel);
    }
  }

  /** Spawn sessions for roster agents added after the initial start. */
  private async reconcileSessions(channel: Channel): Promise<void> {
    const missing = channel.roster.filter((agent) => {
      if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
        return false;
      }
      // Crash-loop budget: an agent whose session keeps dying stops being
      // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment) —
      // but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
      // the task heals itself once the cause (a missing CLI on the shared
      // volume, a broken config) is fixed, instead of staying dead until a
      // host restart.
      if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
        const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
        if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
        channel.respawns.set(agent.id, 0);
      }
      return true;
    });
    if (missing.length === 0) return;

    const task = getHostTask(channel.taskId);
    if (!task || task.statusMirror !== "running") return;

    for (const agent of missing) channel.spawning.add(agent.id);
    try {
      // Same per-agent materialisation the initial start does: package
      // skills (idempotent — only the new agents' installs run), and the
      // shared `uai` CLI rewritten for the full roster.
      await installPackageSkills(channel.taskId, missing);
      if (channel.browserTesting) {
        await setupBrowserTesting(
          channel.taskId,
          channel.containerName,
          channel.roster.some((a) => a.kind === "codex"),
        );
      }
      const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
      const cliSecret = loadTaskCliSecret(channel.taskId);
      writeAgentCli(channel.taskId, channel.roster, apiUrl);

      for (const agent of missing) {
        const session = await this.factory.create({
          taskId: channel.taskId,
          agent,
          containerName: channel.containerName,
          systemPreamble: channel.preambles.get(agent.id) ?? "",
          agentEnv: agentCliEnv(
            channel.taskId,
            agent,
            task.ownerUserId,
            apiUrl,
            cliSecret,
          ),
        });
        channel.sessions.set(agent.id, session);
        session.onEvent((event) => {
          void this.handleAgentEvent(channel, agent.id, event);
        });
      }
    } finally {
      for (const agent of missing) channel.spawning.delete(agent.id);
    }
  }

  private async startSessions(channel: Channel): Promise<boolean> {
    const task = getHostTask(channel.taskId);
    if (!task || task.statusMirror !== "running") return false;

    // Set the task creator's git author identity in the container (ADR-029).
    // The SSH key itself is installed earlier by task-up.sh (host clone +
    // container), using the creator's per-user key. Best-effort. Awaited but
    // async (docker exec via dockerCli) so it doesn't block the event loop —
    // this runs on every channel ensure, including each post-restart reconnect.
    await setupTaskGitIdentity(channel.taskId, task.ownerName, task.ownerEmail);

    // GitHub auth for `gh` (ADR-027): mint + inject the access token and
    // (re)start its refresh schedule. Done here — not only at task-up — so it
    // survives a host restart: recovery recreates the channel and re-runs
    // ensureSessions, whereas the in-memory refresh timer from the original
    // task-up is gone. Guarded + best-effort + non-blocking; the token lands
    // before the agents (spawned just below) make their first `gh` call.
    if (task.ownerUserId) {
      void setupTaskGithub(channel.taskId, task.ownerUserId);
    }

    // ADR-047: install any package skills (native Claude Agent Skills) into the
    // container BEFORE spawning agents, so they're discoverable on the first
    // turn. Idempotent + best-effort (returns fast when there are none); a slow
    // clone/install briefly delays start, which is acceptable for skill-bearing
    // tasks. Never throws.
    await installPackageSkills(channel.taskId, channel.roster);

    // ADR-053: wire the Playwright MCP browser (configs + backgrounded
    // Chromium install) before agents spawn. Idempotent + best-effort.
    if (channel.browserTesting) {
      await setupBrowserTesting(
        channel.taskId,
        channel.containerName,
        channel.roster.some((a) => a.kind === "codex"),
      );
    }

    // ADR-057: the owner's MCP connections, reached through the host gateway
    // (tokens never enter the container) — written BEFORE agents spawn so
    // first sessions load them.
    await this.ensureMcpConfig(channel);

    // ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
    // workspace. Each agent's OWN task token — carrying only ITS permissions — is
    // injected per-agent via its docker exec env below, so per-persona permissions
    // are actually enforced. Best-effort, host-side.
    const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
    const cliSecret = loadTaskCliSecret(channel.taskId);
    writeAgentCli(channel.taskId, channel.roster, apiUrl);

    for (const agent of channel.roster) {
      const session = await this.factory.create({
        taskId: channel.taskId,
        agent,
        containerName: channel.containerName,
        systemPreamble: channel.preambles.get(agent.id) ?? "",
        agentEnv: agentCliEnv(channel.taskId, agent, task.ownerUserId, apiUrl, cliSecret),
      });
      channel.sessions.set(agent.id, session);
      session.onEvent((event) => {
        void this.handleAgentEvent(channel, agent.id, event);
      });
    }

    // First-turn delivery is owned by the CLOUD (channel-router.ensureStarted),
    // which fires each agent's initialPrompt exactly once per task — guarded by
    // a non-empty message history (its durable "already started" check). The
    // host must NOT deliver it here: this path re-runs on every session spawn,
    // so it both duplicated the opening turn AND re-injected it on a host
    // restart, making agents redo the first turn mid-task. The persona/mission
    // an agent needs live in the always-on system preamble, so nothing is lost.
    return true;
  }

  /**
   * Ensure the channel exists and, if the task is `running`, its agent
   * sessions are spawned. Idempotent — the task detail page's poll
   * calls this so agents auto-start the moment the task is running,
   * without the human having to send a message first.
   */
  async ensureStarted(taskId: string): Promise<void> {
    const channel = await this.getOrCreateChannel(taskId);
    if (channel) await this.ensureSessions(channel);
  }

  // -- inbound: a human (or routed peer) message ----------------------------

  /**
   * Deliver a cloud-routed message into one live agent session.
   */
  async deliver(
    taskId: string,
    agentId: string,
    text: string,
  ): Promise<{ ok: true } | { ok: false; error: string }> {
    // Slash command: re-run GitHub auth setup for this task (ADR-027). Handled
    // before sessions so it works even when an agent isn't ready.
    if (text.trim() === "/retry-gh") {
      void this.handleRetryGh(taskId);
      return { ok: true };
    }

    const channel = await this.getOrCreateChannel(taskId);
    if (!channel) return { ok: false, error: "task or project not found" };

    const ready = await this.ensureSessions(channel);
    if (!ready) {
      return { ok: false, error: "task is not running" };
    }

    const session = channel.sessions.get(agentId);
    if (!session) return { ok: false, error: `no such agent: ${agentId}` };

    void session.send(rewriteAttachmentRefs(text));
    return { ok: true };
  }

  /** Interrupt an agent's current turn (ESC). No-op when the task/agent has no
   *  live session (nothing to stop). */
  async interrupt(
    taskId: string,
    agentId: string,
  ): Promise<{ ok: true } | { ok: false; error: string }> {
    const channel = this.channels.get(taskId);
    const session = channel?.sessions.get(agentId);
    if (!channel || !session) return { ok: false, error: "no active session" };
    // An interrupted turn is a half-turn — its eventual turn_complete must not
    // hand the buffered fragment to @-mentioned peers. Flag it so the boundary
    // goes out `aborted` and the cloud discards the buffer. Only when the turn
    // actually streamed output (openTurns): an idle-agent ESC must not mark
    // the NEXT legitimate turn as aborted.
    if (channel.openTurns.has(agentId)) channel.interrupted.add(agentId);
    void session.interrupt();
    this.emitSystemNote(taskId, `Stopped @${agentId}.`);
    return { ok: true };
  }

  /** `/retry-gh` handler: re-mint + inject the task owner's GitHub token. */
  private async handleRetryGh(taskId: string): Promise<void> {
    const owner = getHostTask(taskId)?.ownerUserId;
    if (!owner) {
      this.emitSystemNote(taskId, "gh: no owner recorded for this task.");
      return;
    }
    this.emitSystemNote(taskId, "gh: retrying authentication…");
    // Cancel any armed auto-retry chain so this manual attempt runs fresh
    // (setup's duplicate-chain guard would otherwise no-op it).
    clearRefresh(taskId);
    const ok = await setupTaskGithub(taskId, owner);
    this.emitSystemNote(
      taskId,
      ok
        ? "gh: authentication restored."
        : "gh: still not connected — reconnect GitHub on Account, then try again.",
    );
  }

  // -- outbound: events from an agent session -------------------------------

  private async handleAgentEvent(
    channel: Channel,
    agentId: string,
    event: AgentEvent,
  ): Promise<void> {
    switch (event.type) {
      case "message_delta": {
        channel.openTurns.add(agentId);
        this.emitHost({
          kind: "agent.message_delta",
          taskId: channel.taskId,
          agentId,
          chunk: event.text,
        });
        break;
      }
      case "message_complete": {
        channel.openTurns.add(agentId);
        this.emitHost({
          kind: "agent.message_complete",
          taskId: channel.taskId,
          agentId,
          fullText: event.text,
          mentions: parseMentions(event.text, channel.roster),
        });
        break;
      }
      case "tool_call": {
        this.emitHost({
          kind: "agent.tool_call",
          taskId: channel.taskId,
          agentId,
          tool: event.title,
          meta: { detail: event.detail, toolId: event.id },
        });
        break;
      }
      case "permission_request": {
        this.emitHost({
          kind: "agent.permission_request",
          taskId: channel.taskId,
          agentId,
          requestId: event.id,
          meta: {
            title: event.title,
            detail: event.detail,
            requestId: event.id,
          },
        });
        break;
      }
      case "peer_message": {
        const text = `@${event.toAgentId} ${event.text}`;
        this.emitHost({
          kind: "agent.message_complete",
          taskId: channel.taskId,
          agentId,
          fullText: text,
          mentions: [event.toAgentId],
        });
        break;
      }
      case "error": {
        // The turn died with the session — drop its turn-state flags so a
        // respawned session starts clean.
        channel.openTurns.delete(agentId);
        channel.interrupted.delete(agentId);
        // Claude under load (especially Docker Desktop macOS) occasionally
        // unlinks ~/.claude.json mid-write during atomic config rewrites,
        // and a concurrent claude spawn lands during the gap and exits 0
        // with "Claude configuration file not found". When we recognise
        // that pattern, repair the in-container config + respawn the
        // session so the user keeps working instead of staring at a dead
        // chat.
        const agent = channel.roster.find((a) => a.id === agentId);
        const isClaude = agent?.kind === "claude";
        const configMissing = CLAUDE_CONFIG_MISSING_PATTERNS.some((re) =>
          re.test(event.message),
        );
        if (isClaude && configMissing) {
          await this.recoverClaudeAgent(channel, agentId);
          break;
        }
        this.emitHost({
          kind: "agent.exit",
          taskId: channel.taskId,
          agentId,
          reason: event.message,
        });
        // The session is DEAD — drop it so the reconciling ensureSessions
        // respawns it on the next delivery/ensure. Without this the agent
        // becomes a zombie: the map still holds the dead session and every
        // later deliver "succeeds" into a closed pipe (found live 2026-07-08
        // after a double SIGKILL). Bounded by the respawn budget, checked in
        // reconcileSessions.
        channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
        channel.respawnLastAt.set(agentId, Date.now());
        channel.sessions.delete(agentId);
        break;
      }
      case "turn_complete": {
        // Turn boundary — the cloud flushes any @-mentions buffered across this
        // turn's messages and wakes the mentioned peers with the full turn.
        // An interrupted (ESC'd) turn goes out `aborted`: it's a half-turn, so
        // the cloud discards the buffer instead of handing it to peers.
        const aborted = channel.interrupted.delete(agentId);
        channel.openTurns.delete(agentId);
        this.emitHost({
          kind: "agent.turn_complete",
          taskId: channel.taskId,
          agentId,
          aborted,
        });
        break;
      }
      case "exit":
        // Same zombie hazard as the error path — a session whose process
        // ended (even cleanly) can never carry another turn.
        channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
        channel.respawnLastAt.set(agentId, Date.now());
        channel.sessions.delete(agentId);
        break;
    }
  }

  // -- session recovery -----------------------------------------------------

  /**
   * Re-copy the host's `~/.claude.json` into the task container and
   * respawn the agent's session. Bounded by MAX_RESPAWNS_PER_AGENT so
   * a permanently-broken setup doesn't loop forever.
   */
  private async recoverClaudeAgent(
    channel: Channel,
    agentId: string,
  ): Promise<void> {
    const tries = (channel.respawns.get(agentId) ?? 0) + 1;
    channel.respawns.set(agentId, tries);
    channel.respawnLastAt.set(agentId, Date.now());

    if (tries > MAX_RESPAWNS_PER_AGENT) {
      this.emitHost({
        kind: "agent.exit",
        taskId: channel.taskId,
        agentId,
        reason:
          `Claude exited with "configuration file not found" ${tries - 1} ` +
          "times in a row. Giving up automatic recovery — recreate the " +
          "task to start fresh.",
      });
      return;
    }

    const restored = repairClaudeConfigInContainer(channel.containerName);

    // Tear the dead session down and build a fresh one in its place.
    const old = channel.sessions.get(agentId);
    if (old) {
      try {
        await old.close();
      } catch {
        // Already exited — close is idempotent for our adapters.
      }
    }

    const agent = channel.roster.find((a) => a.id === agentId);
    if (!agent) return;
    const session = await this.factory.create({
      taskId: channel.taskId,
      agent,
      containerName: channel.containerName,
      systemPreamble: channel.preambles.get(agentId) ?? "",
    });
    channel.sessions.set(agentId, session);
    session.onEvent((event) => {
      void this.handleAgentEvent(channel, agentId, event);
    });

    const message = restored
      ? `${agentId} restarted — config file was missing in the container, restored from the host.`
      : `${agentId} restarted — config file was missing in the container (host copy not found; spawned anyway).`;
    this.emitHost({
      kind: "agent.exit",
      taskId: channel.taskId,
      agentId,
      reason: message,
    });
  }

  // -- permission resolution ------------------------------------------------

  async resolvePermission(
    taskId: string,
    agentId: string,
    requestId: string,
    decision: "accept" | "decline",
  ): Promise<boolean> {
    const channel = this.channels.get(taskId);
    const session = channel?.sessions.get(agentId);
    if (!channel || !session) return false;
    await session.resolvePermission(requestId, decision);
    return true;
  }

  /** Tear a channel down (task killed). */
  async closeChannel(taskId: string): Promise<void> {
    const ch = this.channels.get(taskId);
    if (!ch) return;
    for (const session of ch.sessions.values()) await session.close();
    this.channels.delete(taskId);
  }
}

// ---------------------------------------------------------------------------
// `@mention` addressing.
// ---------------------------------------------------------------------------

/**
 * Extract the roster agents explicitly `@mentioned` in `text`, in order
 * of first appearance, de-duplicated. Returns [] when none are
 * mentioned. A single space after `@` is tolerated (`@ codex`) since
 * agents sometimes emit that.
 */
export function parseMentions(text: string, roster: Roster): string[] {
  const ids = new Set(roster.map((a) => a.id));
  const mentioned: string[] = [];
  const re = /@ ?([A-Za-z0-9_-]+)/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(text)) !== null) {
    const id = m[1];
    if (id && ids.has(id) && !mentioned.includes(id)) mentioned.push(id);
  }
  return mentioned;
}

/**
 * Resolve who an inbound human message is addressed to: the explicit
 * `@mentions`, else `fallbackAgentId`, else the first roster agent.
 */
/**
 * Rewrite cloud attachment URLs (`…/api/tasks/<id>/uploads/<file>`) to the
 * in-container workspace path so the agent reads the file directly with its own
 * tools (ADR-015 attachments live in the workspace, mounted at /workspace). The
 * cloud URL would be Clerk-gated and unreachable from inside the container.
 */
export function rewriteAttachmentRefs(text: string): string {
  return text.replace(
    /(?:https?:\/\/[^/\s)\]]+)?\/api\/tasks\/[^/\s)\]]+\/uploads\/([A-Za-z0-9._-]+)/g,
    (_m, filename: string) => `/workspace/.uai/attachments/${filename}`,
  );
}

export function resolveAddressing(
  text: string,
  roster: Roster,
  fallbackAgentId?: string,
): string[] {
  const mentioned = parseMentions(text, roster);
  if (mentioned.length > 0) return mentioned;
  if (fallbackAgentId && roster.some((a) => a.id === fallbackAgentId)) {
    return [fallbackAgentId];
  }
  const first = roster[0];
  return first ? [first.id] : [];
}

/**
 * Re-copy the host's `~/.claude.json` into the task container at
 * `/home/node/.claude.json` and chown it back to `node`. Used when an
 * agent exited with "configuration file not found" — Claude under
 * Docker Desktop macOS sometimes unlinks its own config mid-write,
 * and a concurrent spawn lands in the gap. Restoring from the host is
 * the same recovery `task-up.sh` does at first launch.
 *
 * Returns true when the file actually made it into the container.
 */
function repairClaudeConfigInContainer(containerName: string): boolean {
  // Operator can override the source path via UAI_OWNER_HOME so the
  // server doesn't have to guess. Useful when Next.js's dev process
  // env diverges from `os.homedir()` for any reason.
  const home = process.env.UAI_OWNER_HOME || homedir();
  const hostConfig = join(home, ".claude.json");
  if (!existsSync(hostConfig)) {
    console.error(
      `[orchestrator] repair: host .claude.json not found at ${hostConfig} ` +
        `(homedir=${homedir()}, UAI_OWNER_HOME=${
          process.env.UAI_OWNER_HOME ?? "<unset>"
        })`,
    );
    return false;
  }

  // Why not `docker cp`: it does unlink + create on the destination,
  // and when a stuck claude process still has the in-container file
  // open we get `Error response from daemon: unlinkat …: device or
  // resource busy` and the new bytes never land.
  //
  // Instead, pipe the host file's bytes into a `cat >` inside the
  // container. That truncates the existing inode in place — no
  // unlink, no rename — so the file's bytes are replaced and any
  // open fd just sees the new content on its next read. Permissions
  // and ownership of the inode are preserved (it was already owned
  // by `node`, which is what claude needs).
  let content: Buffer;
  try {
    content = readFileSync(hostConfig);
  } catch (err) {
    console.error(
      `[orchestrator] repair: read host .claude.json failed: ${
        err instanceof Error ? err.message : String(err)
      }`,
    );
    return false;
  }
  // Clear the existing dentry before writing. Docker Desktop macOS
  // occasionally ends up with a stale dentry where `ls -la` shows
  // `-????????? ?` and `open(O_CREAT)` fails with "Directory
  // nonexistent" — the file appears to be there but every syscall
  // bounces. `rm -f` punches through it (or no-ops on a genuine
  // missing file). Then `cat >` creates the fresh inode. Both steps
  // run in one `sh -c` so a partial failure leaves a clean state.
  const write = spawnSync(
    "docker",
    [
      "exec",
      "-i",
      containerName,
      "sh",
      "-c",
      "rm -f /home/node/.claude.json && cat > /home/node/.claude.json",
    ],
    { input: content, encoding: "buffer" },
  );
  if (write.status !== 0) {
    const stderr =
      write.stderr instanceof Buffer
        ? write.stderr.toString("utf8")
        : String(write.stderr ?? "");
    console.error(
      `[orchestrator] repair: in-place write to .claude.json failed: ${stderr.trim()}`,
    );
    return false;
  }
  return true;
}

/** A project as carried in a channel spec — one repo + its prompt. */
type ChannelProject = { slug: string; defaultPrompt: string };

/**
 * Concatenate each selected project's `defaultPrompt` in position order
 * (the spec already orders them), dropping empties, joined by a blank
 * line. The leading layer of the ADR-022 prompt-assembly chain.
 */
function concatProjectPrompts(projects: ChannelProject[]): string {
  return projects
    .map((p) => p.defaultPrompt.trim())
    .filter((p) => p.length > 0)
    .join("\n\n");
}

/**
 * Assemble one agent's first-turn message (ADR-022 prompt-assembly
 * chain):
 *
 *   concat(project.defaultPrompt in position order)
 *     + "\n\n" + globalContext         (task-level mission, if present)
 *     + "\n\n" + agent.defaultPrompt   (the agent's persona, if present)
 *     + "\n\n" + agent.initialPrompt   (this-turn instructions)
 *
 * Returns `null` when the agent has no non-empty `initialPrompt` — those
 * agents stay silent until addressed. There is no "first speaker" /
 * coder rule; any agent with an `initialPrompt` opens a first turn.
 */
export function assembleFirstTurnPrompt(
  projects: ChannelProject[],
  globalContext: string | undefined,
  agent: RosterAgent,
): string | null {
  const initial = (agent.initialPrompt ?? "").trim();
  if (initial.length === 0) return null;

  const parts = [
    concatProjectPrompts(projects),
    (globalContext ?? "").trim(),
    (agent.defaultPrompt ?? "").trim(),
    initial,
  ].filter((p) => p.length > 0);
  return parts.join("\n\n");
}

/**
 * Build the system preamble an agent gets on session start. It opens
 * with how uai's channel works — agents must know to hand off via
 * `@mention`, since there is no `peer` command / shared tmux any more
 * (ADR-008) — then appends the project context and the agent's persona.
 *
 * The persona / mission layers (project defaultPrompts, globalContext,
 * agent.defaultPrompt) live in the always-on system prompt so they apply
 * to every turn; only `initialPrompt` is delivered as the first user
 * turn (see `assembleFirstTurnPrompt`).
 */
export function buildSystemPreamble(
  roster: Roster,
  agent: RosterAgent,
  projects: ChannelProject[],
  globalContext: string | undefined,
  workspacePath: string,
  taskBranch: string,
  humans?: ChannelHuman[],
  browserTesting?: boolean,
  sharedFiles?: string,
): string {
  const channelList = roster
    .map((a) =>
      a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
    )
    .join(", ");
  // ADR-049: with several humans in the chat, brief the agent on who they are
  // and how to address one specifically. Single-human tasks keep the original
  // wording byte-identical.
  const multiHuman = (humans?.length ?? 0) > 1;
  const humanList = (humans ?? [])
    .map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
    .join(", ");
  const humanIntro = multiHuman
    ? [
        `SEVERAL humans share this channel: ${humanList}. Their messages`,
        "arrive prefixed with the sender's name so you can tell them apart.",
        "`@you` still works and reaches the human you're currently talking",
        "to (whoever last addressed you); to reach a SPECIFIC human, mention",
        "their handle instead (e.g. `@" +
          (humans?.[0]?.handle ?? "name") +
          "`). Mentioning a human sends a",
        "NOTIFICATION, so use it sparingly — only when you actually need",
        "them: a decision you can't make, a blocker, an approval, or you've",
        "finished your work and are handing it back. For everything else —",
        "status updates, thinking out loud, a direct reply to something they",
        "just asked, acknowledgments — post in the channel WITHOUT",
        "@-mentioning; they can read the channel and don't need a ping for",
        "every message.",
      ]
    : [
        "The human you're working with is **@you**. @-mentioning them sends a",
        "NOTIFICATION, so use it sparingly — only when you actually need them: a",
        "decision you can't make, a blocker, an approval, or you've finished your",
        "work and are handing it back for them to act on (e.g. `@you which`",
        "`approach do you prefer?` or `@you done — PR is up for review`). For",
        "everything else — status updates, thinking out loud, a direct reply to",
        "something they just asked, acknowledgments — post in the channel WITHOUT",
        "@-mentioning @you; they can read the channel and don't need a ping for",
        "every message. Do NOT reflexively end messages with @you.",
      ];
  const projectLines =
    projects.length === 0
      ? ["(none mounted)"]
      : projects.map(
          (p) =>
            `- \`${workspacePath}/${p.slug}\` — git worktree on \`${taskBranch}\``,
        );
  const comms = [
    "## uai task channel",
    "",
    "You are one agent in a uai task chat channel, shared with the human",
    "and the other agents. To hand work to or ask another agent, mention",
    "it by id at the start of a line — e.g. `@codex please review the",
    "diff`. uai routes that message into that agent's input.",
    "",
    `**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
    `Introduce and refer to yourself as ${agent.label}, not as a generic`,
    "assistant or model name.",
    "",
    `Agents in this channel: ${channelList}.`,
    "",
    ...humanIntro,
    "",
    "An agent only receives a message when it is explicitly @-mentioned",
    "(or addressed by the human) — so always @-mention the agent (or @you)",
    "you mean. There is NO `peer` command and no shared tmux session;",
    "hand-offs are just @-mentions in your replies.",
    "",
    "Collaborate with your peers — divide up the work, review each other's",
    "changes, share concrete ideas, and debate approach decisions by",
    "@-mentioning them. That is how the team gets things done, and you",
    "should do it freely whenever it moves the work forward. But mentioning",
    "a peer WAKES it and costs a turn, so make each one count: a message to",
    "a peer should ADVANCE the work — a real proposal, a question you need",
    "answered, a hand-off, or a review with specific findings. Do NOT",
    "@-mention a peer just to greet, thank, agree, acknowledge, or say",
    "you're ready — content-free replies wake them for nothing and spiral",
    "into endless back-and-forth. If you have nothing substantive to add,",
    "don't @-mention back. And when there's no active task yet (intros, or",
    "you're waiting on the human), answer briefly and then wait — you don't",
    "need to @-mention anyone (including @you); they can see the channel.",
    "",
    "When a message already @-mentions several participants at once (the",
    "human asking the whole group, or a peer addressing multiple agents),",
    "it's a group broadcast — this is a GROUP CHAT and everyone named has",
    "ALREADY been notified and will answer for themselves. Just answer for",
    "YOUR part. Do NOT re-@-mention the others to prompt them, hand the",
    "question to them, or wait on them — no `I'll let @x speak`, `@x your",
    "turn`, or `still waiting on @x`. Re-mentioning someone who already got",
    "the message only wakes them again and spirals into duplicate replies.",
    "Say your piece and stop.",
    "",
    "Because your input is only what you're addressed, you may be missing",
    "context from messages between the human and the other agents. The full",
    "channel transcript — every message + who wrote it (no tool calls) — is",
    "logged at `/workspace/.uai/chat.md`. Read it whenever you need that",
    "context (e.g. the human shared a file or instruction with another",
    "agent); it's appended live, so re-read it for the latest.",
    "",
    "Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
    "uai hands it back to whoever prompted you — so when you're ANSWERING,",
    "just answer plainly; you don't need to re-mention the asker. Mention",
    "someone only to bring them in or hand work off. (2) You may occasionally",
    "receive a `[channel check-in]` asking you to catch up on the channel.",
    "Read the transcript, and speak ONLY if you have something substantive to",
    "add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
    "never shown to anyone, so it is always a safe way to decline a turn.",
    "",
    "Hand off when you finish your part of the work. When you've made",
    "and committed your changes, or completed a review, end your reply by",
    "@-mentioning the agent who should act next and telling them what you",
    "did and what you need (e.g. `@codex changes committed on <branch> —",
    "please review`, or `@claude review done, N issues to fix`). Don't",
    "abandon unfinished work silently — but once your part is done and no",
    "peer needs to act, it's fine to stop; only @-mention @you if you need",
    "their input or are handing back finished work for them to act on. Don't",
    "prolong an agent-to-agent exchange just to fill silence.",
    "",
    "## Workspace layout",
    "",
    `Your shell starts in \`${workspacePath}\` (the task workspace).`,
    "That directory is **not** itself a git repo — it holds one git",
    "worktree per project this task spans. Every project below is on the",
    "same task branch. To run git commands, **`cd` into one of the",
    "project directories first**:",
    "",
    ...projectLines,
    "",
    `The task branch is \`${taskBranch}\`. Push with \`git push -u origin`,
    `${taskBranch}\` from inside the project, then open a PR with \`gh pr`,
    "create` (the container has gh authenticated). For multi-project",
    "tasks, each project's PR is independent — open one per project whose",
    "worktree you actually changed.",
    "",
    "The `.uai/` directory under each task is uai's own scaffolding",
    "(rendered Dockerfile, compose file, container scripts) — it is NOT",
    "part of the project. Never review, edit, stage, commit, or flag it;",
    "treat it as ignored, even though git may show it as untracked.",
    "",
    // ADR-062: shared files — only when this container actually carries the
    // mounts (task-up drops a marker; pre-feature containers have none).
    ...(sharedFiles &&
    sharedFiles !== "off" &&
    existsSync(join(workspacePath, ".uai", "files-mounted"))
      ? [
          "## Shared files",
          "",
          `Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
          "- `/workspace/files/org` — the org's shared files (logos, specs,",
          "  datasets), visible to every task in the org on this host.",
          "- `/workspace/files/me` — the task owner's personal files, shared",
          "  across their tasks on this host.",
          ...(sharedFiles === "rw"
            ? [
                "When producing artifacts for humans, write them here (use a",
                "subdirectory named after the task to avoid collisions).",
              ]
            : [
                "Read-only here: to hand a file back, use the chat attachment",
                "flow instead.",
              ]),
          "",
        ]
      : []),
    // ADR-046: link/document skills are materialised to a per-agent SKILL.md.
    // Point the agent at its own file (package skills are handled below).
    ...((agent.skills ?? []).some((s) => s.type !== "package")
      ? [
          "## Your skills",
          "",
          "Reference material (links + documents) has been prepared for you at",
          `\`${containerSkillFile(agent.id)}\`. Read it when a task touches what`,
          "it covers. It's yours — other agents have their own.",
          "",
        ]
      : []),
    // ADR-047: package skills are native Claude Agent Skills installed into the
    // container's skills dir (Claude-only). List them so the agent knows they're
    // available even if headless auto-discovery is unreliable.
    ...(agent.kind === "claude" &&
    (agent.skills ?? []).some((s) => s.type === "package")
      ? [
          "## Installed skills",
          "",
          "These Claude Agent Skills are installed for this task — invoke them",
          "when a task touches what they cover:",
          ...(agent.skills ?? [])
            .filter((s) => s.type === "package")
            .map((s) => `- **${s.name}** (\`${installedSkillPath(s)}\`)`),
          "",
        ]
      : []),
    // ADR-053: the in-container browser, when the project opted in.
    ...(browserTesting
      ? [
          "## Browser",
          "",
          "This container has a headless Chromium available through the",
          "`browser` MCP server (Playwright). Use it to VERIFY UI work",
          "end-to-end — the dev server you're building runs in this same",
          "container, so navigate to `http://localhost:<port>` directly.",
          "Prefer accessibility snapshots for navigation and assertions;",
          "take an actual screenshot only when rendering matters (vision",
          "input is expensive). To show a screenshot in the chat, save it",
          "under `/workspace/.uai/attachments/<name>.png` and reference it",
          "in your reply as `[image: /workspace/.uai/attachments/<name>.png]`.",
          "Your browser runs on a virtual display the humans can WATCH live",
          "(the \"browser\" preview) — nothing for you to do about that.",
          "The first browser launch in a fresh container may take a minute",
          "while Chromium's system deps finish installing — retry once if",
          "it fails immediately after task start.",
          "",
        ]
      : []),
    // ADR-048: tell agents with permissions about their `uai` CLI.
    ...((agent.permissions?.length ?? 0) > 0
      ? [
          "## The uai CLI",
          "",
          `You can query and plan through Uai by running \`node ${CONTAINER_CLI_PATH}`,
          "<command>` in the shell. Your permissions:",
          `\`${(agent.permissions ?? []).join(", ")}\`. Commands: \`projects list\`,`,
          "`people list`, `task list`" +
            (agent.permissions?.includes("tasks.create")
              ? ", `task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]`"
              : "") +
            (agent.permissions?.includes("memory.write")
              ? ", `memory save <text>`"
              : "") +
            (agent.permissions?.includes("memory.read")
              ? ", `memory search <query>`"
              : "") +
            (agent.permissions?.includes("todo.write")
              ? ", `todo list|add|claim|done`"
              : "") +
            (agent.permissions?.includes("previews.write")
              ? ", `preview add <name> <port>`"
              : "") +
            (agent.permissions?.includes("projects.write")
              ? ", `project create --name --repo`"
              : "") +
            (agent.permissions?.includes("tasks.history")
              ? ", `task search <query>` / `task read <id>`"
              : "") +
            ", `react <heart|check|x> [--msg #id]`.",
          "**Reacting:** `uai react check` is a lightweight ack of the message",
          "you were last handed — use it to acknowledge an instruction or",
          "approve a proposal without spending a whole reply (❌ = disagree,",
          "❤️ = appreciation). Messages in chat.md carry their `#id` if you",
          "want to react to an older one. Reactions wake nobody.",
          ...(agent.permissions?.includes("tasks.create")
            ? [
                "**Creating a task in Uai:** when a human asks you to create/file/",
                "queue a Uai task (or plan a backlog of them), you MUST run the",
                `shell command \`node ${CONTAINER_CLI_PATH} task create …\`. Do NOT`,
                "use your built-in TaskCreate / task tools for this — those are just",
                "your own private to-do notes and are invisible to the human in Uai.",
                "Uai tasks you create are DRAFTS — a human reviews and starts them;",
                "nothing runs (or spends) until then. Discover options first with",
                "`projects list` / `people list`.",
              ]
            : []),
          ...(agent.permissions?.includes("memory.read") ||
          agent.permissions?.includes("memory.write")
            ? [
                `Your memory handle is \`${agent.id}\` — pass \`--as ${agent.id}\` so`,
                "memories are attributed to you. Recall relevant past work with",
                "`memory search` before you start; save durable learnings with",
                "`memory save` when you finish something worth remembering. To fix",
                "or remove one of your own, `memory delete <id>` (there's no update",
                "— delete + save a corrected one).",
              ]
            : []),
          ...(agent.permissions?.includes("todo.write")
            ? [
                "**Punchlist (ADR-063):** this task has a shared checklist —",
                `\`node ${CONTAINER_CLI_PATH} todo list\` shows every item, its`,
                "status, and who's on it. When you and your peers split a task",
                "into pieces, put them on the punchlist (`todo add <text>`), then",
                "**`todo claim <#id>` BEFORE you start** an item so two agents",
                "don't do the same work — and `todo done <#id>` when it's",
                "complete. Check `todo list` before picking up work. The human",
                "watches the same list in the task UI.",
              ]
            : []),
          ...(agent.permissions?.includes("tasks.history")
            ? [
                "**Your task history:** you can recall your OWN past work —",
                `\`node ${CONTAINER_CLI_PATH} task search <query>\` searches the`,
                "chat of every past task you were on (not the human's tasks —",
                "yours), and `task read <taskId>` pulls a full transcript. Use",
                "it like a broader memory: before reinventing something, check",
                "whether you've solved it before.",
              ]
            : []),
          ...(agent.permissions?.includes("previews.write")
            ? [
                "**Previews:** started a dev server? `preview add <name>",
                "<containerPort>` exposes it so the human can open it (they",
                "enable it from the task's preview menu).",
              ]
            : []),
          "",
        ]
      : []),
    "## Commit policy",
    "",
    "Commits are SSH-signed automatically (git is configured for it) — do",
    "not disable or override signing. Do NOT add any `Co-Authored-By:`",
    "trailers to commit messages, and do NOT add 'Generated with …' or any",
    "tool/agent attribution footer to commit messages or PR/issue bodies.",
    "Write commit messages and PR descriptions plainly, as the author, with",
    "no agent attribution.",
  ].join("\n");

  // Persona / mission layers, always-on so they apply to every turn:
  // project context + task globalContext + this agent's persona. The
  // per-turn instructions (initialPrompt) are delivered separately.
  const context = [
    concatProjectPrompts(projects),
    (globalContext ?? "").trim(),
    (agent.defaultPrompt ?? "").trim(),
  ]
    .filter((p) => p.length > 0)
    .join("\n\n");

  return context.length > 0 ? `${comms}\n\n---\n\n${context}` : comms;
}

// ---------------------------------------------------------------------------
// Singleton — survives Next.js HMR by living on globalThis.
// ---------------------------------------------------------------------------

const globalForOrchestrator = globalThis as unknown as {
  __uaiOrchestrator?: Orchestrator;
  __uaiRecoverRan?: boolean;
};

export function getOrchestrator(): Orchestrator {
  if (!globalForOrchestrator.__uaiOrchestrator) {
    // `UAI_AGENTS=real` drives the real Claude/Codex CLIs inside the
    // task containers; anything else uses the mock (no Docker / no CLI
    // needed — the default until the adapters are verified on a host).
    const factory =
      process.env.UAI_AGENTS === "real" ? realAgentFactory : mockAgentFactory;
    globalForOrchestrator.__uaiOrchestrator = new Orchestrator(factory);
  }
  if (!globalForOrchestrator.__uaiRecoverRan) {
    globalForOrchestrator.__uaiRecoverRan = true;
    // Fire-and-forget — the orchestrator is usable while recovery runs.
    void recoverRunningTasks();
  }
  return globalForOrchestrator.__uaiOrchestrator;
}

// ---------------------------------------------------------------------------
// Boot-time recovery
//
// When uai starts after a machine reboot (or a hard `pnpm dev` restart)
// task containers are gone but the DB still claims status='running'.
// Walk the active rows and reconcile each against actual Docker state:
//
//   container up                  → re-discover the host port, keep
//                                   the row at `running`.
//   container exited (preserved)  → `docker start` it, re-run uai-init,
//                                   discover the fresh port, keep
//                                   `running`. AI sessions respawn on
//                                   the next ensureSessions() call.
//   container gone, worktree on   → mark `stopped`. User hits Resume,
//   disk                            which runs task-up against the same
//                                   id (task-up is idempotent).
//   container gone, worktree gone → mark `error`. Data loss.
//
// Idempotent per process — guarded by `__uaiRecoverRan` on globalThis
// so a Next.js HMR rebuild doesn't trigger it twice.
// ---------------------------------------------------------------------------

/**
 * docker ps --all output entry — Names is unique per task because we
 * scope by compose-project label (set via `-p task-<id>` at task-up).
 */
interface DockerPs {
  Names: string;
  State: string; // "running" | "exited" | "created" | "paused"
}

/**
 * Returns the task's containers, [] when docker reports none, or NULL when
 * docker itself was unreachable/timed out (status null = spawn failure or our
 * SIGKILL). The distinction matters: recovery treats [] as "container gone"
 * and DOWNGRADES the task to stopped/error — doing that because a `docker ps`
 * timed out under load would destroy a perfectly recoverable task.
 */
async function dockerListContainersByLabel(
  label: string,
): Promise<DockerPs[] | null> {
  const res = await dockerCli(
    ["ps", "--all", "--filter", `label=${label}`, "--format", "{{json .}}"],
    { timeoutMs: 60_000 },
  );
  if (res.status === null) return null; // docker state UNKNOWN — don't act on it
  if (res.status !== 0) return [];
  return res.stdout
    .split("\n")
    .map((l) => l.trim())
    .filter(Boolean)
    .map((l) => {
      try {
        return JSON.parse(l) as DockerPs;
      } catch {
        return null;
      }
    })
    .filter((x): x is DockerPs => x !== null);
}

async function dockerStart(containerName: string): Promise<boolean> {
  // Starting a big dev container can legitimately take a while — give it far
  // more than dockerCli's 30s default before declaring the task stopped.
  const res = await dockerCli(["start", containerName], { timeoutMs: 120_000 });
  if (res.status !== 0) {
    console.error(
      `[orchestrator] docker start ${containerName} failed: ${res.stderr.trim()}`,
    );
    return false;
  }
  return true;
}

async function dockerPort(
  containerName: string,
  containerPort: number,
): Promise<number | null> {
  const res = await dockerCli(["port", containerName, String(containerPort)]);
  if (res.status !== 0) return null;
  // Output: "127.0.0.1:32785\n"
  const firstLine = res.stdout.split("\n")[0]?.trim() ?? "";
  const colonIdx = firstLine.lastIndexOf(":");
  if (colonIdx === -1) return null;
  const port = Number(firstLine.slice(colonIdx + 1));
  return Number.isFinite(port) ? port : null;
}

async function dockerExec(
  containerName: string,
  cmd: string[],
  timeoutMs?: number,
): Promise<boolean> {
  const res = await dockerCli(
    ["exec", containerName, ...cmd],
    timeoutMs === undefined ? {} : { timeoutMs },
  );
  return res.status === 0;
}

async function recoverRunningTasks(): Promise<void> {
  try {
    const db = getDb();
    const rows = db
      .select()
      .from(schema.hostTasks)
      .where(inArray(schema.hostTasks.statusMirror, [...ACTIVE_STATUSES]))
      .all();
    if (rows.length === 0) return;
    console.log(
      `[orchestrator] recovery: scanning ${rows.length} active task row(s)`,
    );
    for (const task of rows) {
      try {
        await recoverOneTask(task);
      } catch (err) {
        console.error(
          `[orchestrator] recovery: ${task.taskId} failed:`,
          err instanceof Error ? err.message : err,
        );
      }
    }
  } catch (err) {
    console.error(
      "[orchestrator] recovery: top-level failure",
      err instanceof Error ? err.message : err,
    );
  }
}

async function recoverOneTask(
  task: typeof schema.hostTasks.$inferSelect,
): Promise<void> {
  const composeProject = task.composeProject;
  if (!composeProject) {
    // We never wrote a compose project name for this row — must be
    // a row stuck in `queued`/`starting` from before task-up got past
    // step 2. Leave it; the user can recreate.
    return;
  }
  const containerName = `${composeProject}-app-1`;
  const containers = await dockerListContainersByLabel(
    `com.docker.compose.project=${composeProject}`,
  );
  if (containers === null) {
    // Docker unreachable / timed out — actual state unknown. Leave the row
    // alone; the next recovery pass (or the next ensure) will see the truth.
    console.warn(
      `[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
    );
    return;
  }

  if (containers.length === 0) {
    // Container gone. Worktree state determines whether resume is
    // viable.
    const workspaceOnDisk = task.worktreePath && existsSync(task.worktreePath);
    const next = workspaceOnDisk ? "stopped" : "error";
    db_setStatus(task.taskId, next, {
      codeServerPort: null,
      previewPorts: "[]",
      composeProject: next === "error" ? null : composeProject,
    });
    console.log(
      `[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
        `worktree ${workspaceOnDisk ? "present" : "gone"})`,
    );
    return;
  }

  const running = containers.some((c) => c.State === "running");
  if (running) {
    // Container is up. Re-discover the host port in case Docker
    // remapped it across restarts (it usually does for ephemeral
    // bindings).
    const port = await dockerPort(containerName, 8080);
    if (port && port !== task.codeServerPort) {
      db_setRuntime(task.taskId, {
        codeServerPort: port,
      });
      console.log(`[orchestrator] recovery: ${task.taskId} ports refreshed`);
    }
    // Re-establish gh proactively (ADR-027): the restart killed the in-memory
    // refresh timers, so an idle task's access token would silently expire
    // ~8h later. Cheap with the per-user access-token cache — one exchange
    // per user, every task re-injects from it. Best-effort.
    if (task.ownerUserId) {
      void setupTaskGithub(task.taskId, task.ownerUserId);
    }
    return;
  }

  // Container exists but exited. Bring it back up + re-launch the
  // in-container init (deps + code-server).
  console.log(
    `[orchestrator] recovery: ${task.taskId} starting exited container ${containerName}`,
  );
  if (!(await dockerStart(containerName))) {
    db_setStatus(task.taskId, "stopped", {
      codeServerPort: null,
      previewPorts: "[]",
    });
    return;
  }
  // uai-init reinstalls workspace deps (pnpm/npm install) — minutes on a big
  // repo. dockerCli's 30s default would SIGKILL it mid-install.
  if (
    !(await dockerExec(containerName, ["/usr/local/bin/uai-init"], 10 * 60_000))
  ) {
    console.warn(
      `[orchestrator] recovery: ${task.taskId} uai-init failed; container is up but Editor may be down`,
    );
  }
  const port = (await dockerPort(containerName, 8080)) ?? null;
  db_setRuntime(task.taskId, {
    codeServerPort: port,
  });
  // Same gh re-establish as the running branch — the restarted container's gh
  // config is whatever it held when it exited.
  if (task.ownerUserId) {
    void setupTaskGithub(task.taskId, task.ownerUserId);
  }
  console.log(
    `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
  );
}

function db_setStatus(
  taskId: string,
  status: string,
  extras: Partial<typeof schema.hostTasks.$inferInsert>,
): void {
  upsertHostTask(taskId, { ...extras, statusMirror: status });
}

function db_setRuntime(
  taskId: string,
  extras: Partial<typeof schema.hostTasks.$inferInsert>,
): void {
  upsertHostTask(taskId, extras);
}

export type { Orchestrator };
