/**
 * Subagent tool — delegates tasks to specialist subagents.
 */

import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);

import type { AgentToolResult } from "@earendil-works/pi-agent-core";
import type { Message } from "@earendil-works/pi-ai";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { createLogger } from "../shared/logger.js";
import { sendSystemNote } from "../shared/system-note.js";
import { publishedTaskStore } from "../shared/task-store-bridge.js";
import { createBridgedUi } from "../shared/ui-bridge.js";
import {
  getMarkdownTheme,
  withFileMutationQueue,
} from "@earendil-works/pi-coding-agent";
import {
  Container,
  Markdown,
  Spacer,
  Text,
} from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";

// Task store access via the pitasks module.
// `taskStore` is `export let` — a live binding the pitasks extension assigns when it
// initializes. Copying the value at import time captured `undefined` and disabled
// task-id validation for the whole session (issue #1016), so the binding is read fresh.
let _pitasksModule: any = null;
let _pitasksLoad: Promise<any> | null = null;
async function pitasksModule(): Promise<any> {
  if (_pitasksModule) return _pitasksModule;
  if (!_pitasksLoad) {
    _pitasksLoad = (async () => {
      try {
        _pitasksModule = await import("../pitasks/index.js");
        return _pitasksModule;
      } catch (err) {
        log.warn(`[subagent] pitasks module unavailable: ${String((err as Error)?.message ?? err).slice(0, 120)}`);
        _pitasksLoad = null; // retry on the next call
        return null;
      }
    })();
  }
  return _pitasksLoad;
}

/** The live task store, or null while pitasks has not initialized it yet.
 *  Read from the global symbol registry: this tool's module instance cannot see the
 *  pitasks extension's `export let taskStore` binding (it snapshots `undefined`), which
 *  is why task-id validation silently failed open (issue #1016). */
async function getTaskStore(): Promise<any> {
  const published = publishedTaskStore();
  if (published) return published;
  // Fall back to the module binding for in-process loads that share this instance.
  const mod = await pitasksModule();
  const store = mod?.taskStore ?? null;
  if (!store) {
    // Validation is about to be skipped: that is a degraded state, not routine flow.
    log.warn("task_store_unavailable", { note: "pitasks store not initialized — taskId validation skipped" });
  }
  return store;
}
import {
  decideAsyncLlmDispatch,
  supportsAsyncLlm,
} from "./async-capability.js";
import { autoMarkInProgress, autoCompleteTask, type AsyncKillOrigin } from "./async-agents.js";
import { getSetting } from "./project-settings.js";
import { checkSyncLimit } from "./sync-limit.js";
import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
import { resolveAgentModelProvider } from "./resolve-agent-model.js";
import { trailToolsArgs } from "./trail.js";
import { parseModelOverride, mergeModelOverride } from "./parse-model-override.js";
import { clockHHMM, getPiInvocation, getProjectTmpDir, djb2Hash } from "./utils.js";

const SETTINGS_KEYS_FOR_SUBAGENT = JSON.parse(
  fs.readFileSync(
    path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "settings-keys.json"),
    "utf-8",
  ),
);

export { resolveAgentModelProvider, parseModelOverride, checkSyncLimit };

// ── Constants ────────────────────────────────────────────────────────────

const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
const COLLAPSED_ITEM_COUNT = 10;
const MISSING_CWD_ERROR = "Missing required parameter: cwd. Always specify the working directory for subagent tasks.";
// Resolved per-call from settings
const log = createLogger("subagent");
const SYNC_TIME_EXCEEDED_ERROR = (seconds: number, limit: number, cwd: string) => {
  log.info("sync_time_exceeded", "seconds", seconds, "limit", limit, "cwd", cwd);
  return `Estimated time ${seconds}s meets or exceeds ${limit}s sync limit. Use async: true instead.`;
};
const MISSING_ESTIMATE_ERROR = "Missing required parameter: estimatedSeconds. Provide an estimated duration in seconds for sync agent tasks.";

let syncPenalty = 0;
let lastSyncAgent = "";
let lastSyncEstimate = 0;
let lastSyncActual = 0;

/** Agents that MUST be dispatched with async: true. Sync calls are rejected.
 *  Keep in sync with rules/20-code-review-loop.md when changing this list. */
const ASYNC_ONLY_AGENTS = new Set([
  "code-reviewer-quality",
  "code-reviewer-guidelines",
  "code-reviewer-security",
  "code-reviewer-docs",
  "code-reviewer-spec",
]);
// ── Schemas ──────────────────────────────────────────────────────────────

const TaskItem = Type.Object({
  agent: Type.String({ description: "Agent name" }),
  task: Type.String({ description: "Task to delegate" }),
  cwd: Type.String({ description: "Working directory" }),
  name: Type.Optional(Type.String({ description: "Display name for async status" })),
  estimatedSeconds: Type.Optional(Type.Number({ description: "Estimated task duration in seconds. Required for sync parallel tasks." })),
  taskId: Type.Optional(Type.String({ description: "Task ID to auto-complete when this async agent finishes" })),
  model: Type.Optional(Type.String({ description: "Override the agent's model. Format: 'provider/model-id' or 'model-id'." })),
});
const ChainItem = Type.Object({
  agent: Type.String({ description: "Agent name" }),
  task: Type.String({
    description: "Task with optional {previous} placeholder",
  }),
  cwd: Type.String({ description: "Working directory" }),
  estimatedSeconds: Type.Number({ description: "Estimated step duration in seconds" }),
});
// pi 1.5.0's StringEnum returns a pi-schema Schema, which TypeBox's Static cannot
// read — params.agentScope degraded to `unknown`. Unsafe keeps the JSON-Schema
// shape providers need while carrying the literal union in the static type.
const AgentScopeSchema = Type.Unsafe<AgentScope>({
  type: "string",
  enum: ["user", "project", "both"],
  description: 'Agent directories to use. Default: "both".',
  default: "both",
});

const SubagentParams = Type.Object({
  agent: Type.Optional(
    Type.String({ description: "Agent name (single mode)" }),
  ),
  task: Type.Optional(
    Type.String({ description: "Task to delegate (single mode)" }),
  ),
  tasks: Type.Optional(
    Type.Array(TaskItem, { description: "Parallel execution" }),
  ),
  chain: Type.Optional(
    Type.Array(ChainItem, { description: "Sequential execution" }),
  ),
  agentScope: Type.Optional(AgentScopeSchema),
  confirmProjectAgents: Type.Optional(
    Type.Boolean({
      description: "Prompt before project agents. Default: true.",
      default: true,
    }),
  ),
  cwd: Type.Optional(
    Type.String({ description: "Working directory. Required for single/async mode." }),
  ),
  // Optional at schema level because async mode doesn't need it; runtime enforces for sync
  estimatedSeconds: Type.Optional(
    Type.Number({ description: "Estimated task duration in seconds. Required for sync agents. If >= 30s, must use async: true instead." }),
  ),
  async: Type.Optional(
    Type.Boolean({ description: "Run in background (default: false). Agent runs detached, results surface when complete.", default: false }),
  ),
  fireAndForget: Type.Optional(
    Type.Boolean({ description: "When true with async, skip result delivery to conversation. Agent runs silently — only terminal notification on completion. Use for maintenance tasks like memory dreaming.", default: false }),
  ),
  name: Type.Optional(
    Type.String({ description: "Display name for async agents in status line and notifications (e.g., 'Dream', 'Code Review'). Defaults to agent name." }),
  ),
  taskId: Type.Optional(
    Type.String({ description: "Task ID to auto-complete when this async agent finishes. Required for async agents — pass \"-1\" only for a single read-only check; an agent that edits, pushes, or drives a PR needs a real task id." }),
  ),
  asyncKill: Type.Optional(
    Type.String({ description: "Kill async agent(s) by name, id prefix, or 'all'. Returns which agents were killed." }),
  ),
  persistSession: Type.Optional(
    Type.Boolean({ description: "Persist agent session to disk for reuse across calls. Default: false (ephemeral). When true, the same agent in the same project reuses its session — keeps context, saves tokens." }),
  ),
  model: Type.Optional(
    Type.String({ description: "Override the agent's model. Format: 'provider/model-id' (e.g., 'litellm/claude-opus-4-6-1m') or just 'model-id' (inherits provider). Overrides all other model resolution." }),
  ),
});

// ── Interfaces ───────────────────────────────────────────────────────────

export interface UsageStats {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  cost: number;
  contextTokens: number;
  turns: number;
}

export interface SingleResult {
  agent: string;
  agentSource: "user" | "project" | "package" | "unknown";
  task: string;
  exitCode: number;
  messages: Message[];
  stderr: string;
  usage: UsageStats;
  model?: string;
  stopReason?: string;
  durationMs?: number;
  errorMessage?: string;
  step?: number;
}

export interface SubagentDetails {
  mode: "single" | "parallel" | "chain";
  agentScope: AgentScope;
  projectAgentsDir: string | null;
  results: SingleResult[];
}

export type DisplayItem =
  | { type: "text"; text: string }
  | { type: "toolCall"; name: string; args: Record<string, any> };

// ── Helper functions ─────────────────────────────────────────────────────

function formatTokens(count: number): string {
  if (count < 1000) return count.toString();
  if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
  if (count < 1000000) return `${Math.round(count / 1000)}k`;
  return `${(count / 1000000).toFixed(1)}M`;
}

function formatUsageStats(
  usage: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    cost: number;
    contextTokens?: number;
    turns?: number;
    durationMs?: number;
  },
  model?: string,
): string {
  const parts: string[] = [];
  if (usage.durationMs != null) {
    const s = Math.floor(usage.durationMs / 1000);
    if (s < 60) parts.push(`${s}s`);
    else if (s < 3600) parts.push(`${Math.floor(s / 60)}m${s % 60}s`);
    else parts.push(`${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`);
  }
  if (usage.turns)
    parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
  if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
  if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
  if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
  if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
  if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
  if (usage.contextTokens && usage.contextTokens > 0)
    parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
  if (model) parts.push(model);
  return parts.join(" ");
}

function formatToolCall(
  toolName: string,
  args: Record<string, unknown>,
  fg: (c: any, t: string) => string,
): string {
  const shorten = (p: string) => {
    const h = os.homedir();
    return p.startsWith(h) ? `~${p.slice(h.length)}` : p;
  };
  switch (toolName) {
    case "bash": {
      const c = (args.command as string) || "...";
      const p = c.length > 60 ? `${c.slice(0, 60)}...` : c;
      return fg("muted", "$ ") + fg("toolOutput", p);
    }
    case "read": {
      const f = shorten((args.file_path || args.path || "...") as string);
      let t = fg("accent", f);
      const o = args.offset as number | undefined;
      const l = args.limit as number | undefined;
      if (o !== undefined || l !== undefined) {
        const s = o ?? 1;
        const e = l !== undefined ? s + l - 1 : "";
        t += fg("warning", `:${s}${e ? `-${e}` : ""}`);
      }
      return fg("muted", "read ") + t;
    }
    case "write": {
      const f = shorten((args.file_path || args.path || "...") as string);
      const c = (args.content || "") as string;
      let t = fg("muted", "write ") + fg("accent", f);
      if (c.split("\n").length > 1)
        t += fg("dim", ` (${c.split("\n").length} lines)`);
      return t;
    }
    case "edit":
      return (
        fg("muted", "edit ") +
        fg("accent", shorten((args.file_path || args.path || "...") as string))
      );
    case "ls":
      return (
        fg("muted", "ls ") + fg("accent", shorten((args.path || ".") as string))
      );
    case "find":
      return (
        fg("muted", "find ") +
        fg("accent", (args.pattern || "*") as string) +
        fg("dim", ` in ${shorten((args.path || ".") as string)}`)
      );
    case "grep":
      return (
        fg("muted", "grep ") +
        fg("accent", `/${(args.pattern || "") as string}/`) +
        fg("dim", ` in ${shorten((args.path || ".") as string)}`)
      );
    default: {
      const s = JSON.stringify(args);
      return (
        fg("accent", toolName) +
        fg("dim", ` ${s.length > 50 ? s.slice(0, 50) + "..." : s}`)
      );
    }
  }
}

function getFinalOutput(messages: Message[]): string {
  for (let i = messages.length - 1; i >= 0; i--) {
    const m = messages[i];
    if (m.role === "assistant")
      for (const p of m.content) if (p.type === "text") return p.text;
  }
  return "";
}

function getDisplayItems(messages: Message[]): DisplayItem[] {
  const items: DisplayItem[] = [];
  for (const m of messages)
    if (m.role === "assistant")
      for (const p of m.content) {
        if (p.type === "text") items.push({ type: "text", text: p.text });
        else if (p.type === "toolCall")
          items.push({ type: "toolCall", name: p.name, args: p.arguments });
      }
  return items;
}

async function mapWithConcurrency<I, O>(
  items: I[],
  concurrency: number,
  fn: (item: I, i: number) => Promise<O>,
): Promise<O[]> {
  if (items.length === 0) return [];
  const limit = Math.max(1, Math.min(concurrency, items.length));
  const results: O[] = new Array(items.length);
  let next = 0;
  await Promise.all(
    Array.from({ length: limit }, async () => {
      while (true) {
        const i = next++;
        if (i >= items.length) return;
        results[i] = await fn(items[i], i);
      }
    }),
  );
  return results;
}

async function writePromptFile(
  name: string,
  prompt: string,
  cwd?: string,
): Promise<{ dir: string; filePath: string }> {
  let dir: string;
  const projectDir = getProjectTmpDir(cwd || process.cwd());
  dir = await fs.promises.mkdtemp(path.join(projectDir, "subagent-"));
  const filePath = path.join(
    dir,
    `prompt-${name.replace(/[^\w.-]+/g, "_")}.md`,
  );
  await withFileMutationQueue(filePath, async () => {
    await fs.promises.writeFile(filePath, prompt, {
      encoding: "utf-8",
      mode: 0o600,
    });
  });
  return { dir, filePath };
}

// ── Types ────────────────────────────────────────────────────────────────

type OnUpdate = (partial: AgentToolResult<SubagentDetails>) => void;

// ── runSingleAgent ───────────────────────────────────────────────────────

export async function runSingleAgent(
  agents: AgentConfig[],
  agentName: string,
  task: string,
  cwd: string,
  step: number | undefined,
  signal: AbortSignal | undefined,
  onUpdate: OnUpdate | undefined,
  makeDetails: (r: SingleResult[]) => SubagentDetails,
  parentModelId?: string,
  parentProvider?: string,
  persistSession?: boolean,
  explicit?: { model?: string; provider?: string },
): Promise<SingleResult> {
  const agent = agents.find((a) => a.name === agentName);
  if (!agent) {
    const avail = agents.map((a) => `"${a.name}"`).join(", ") || "none";
    return {
      agent: agentName,
      agentSource: "unknown",
      task,
      exitCode: 1,
      messages: [],
      stderr: `Unknown agent: "${agentName}". Available: ${avail}.`,
      usage: {
        input: 0,
        output: 0,
        cacheRead: 0,
        cacheWrite: 0,
        cost: 0,
        contextTokens: 0,
        turns: 0,
      },
      step,
    };
  }

  const args: string[] = ["--mode", "json", "-p"];
  if (!persistSession) {
    args.push("--no-session");
  } else {
    // Deterministic session ID based on agent + cwd for session reuse
    args.push("--session-id", `sync-${agentName}-${djb2Hash(agentName + ':' + cwd).toString(36)}`);
  }
  const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, parentModelId, parentProvider, cwd, explicit);
  if (effectiveModel) args.push("--model", effectiveModel);
  if (effectiveProvider) args.push("--provider", effectiveProvider);
  args.push(...trailToolsArgs(agent.tools, cwd));

  let tmpDir: string | null = null;
  let tmpFile: string | null = null;

  const startTime = Date.now();
  const cur: SingleResult = {
    agent: agentName,
    agentSource: agent.source,
    task,
    exitCode: 0,
    messages: [],
    stderr: "",
    usage: {
      input: 0,
      output: 0,
      cacheRead: 0,
      cacheWrite: 0,
      cost: 0,
      contextTokens: 0,
      turns: 0,
    },
    model: effectiveModel,
    step,
  };

  const emit = () => {
    if (onUpdate)
      onUpdate({
        content: [
          {
            type: "text",
            text: getFinalOutput(cur.messages) || "(running...)",
          },
        ],
        details: makeDetails([cur]),
      });
  };

  try {
    if (agent.systemPrompt.trim()) {
      const resolvedPrompt = substituteSettingsPlaceholders(
        agent.systemPrompt,
        (key) => getSetting(cwd, key as any),
        Object.keys(SETTINGS_KEYS_FOR_SUBAGENT),
      );
      const tmp = await writePromptFile(agent.name, resolvedPrompt, cwd);
      tmpDir = tmp.dir;
      tmpFile = tmp.filePath;
      args.push("--append-system-prompt", tmpFile);
    }
    args.push(`Task: ${task}`);
    let aborted = false;

    log.info("sync_spawn_start", agentName, "cwd", cwd);
    const exitCode = await new Promise<number>((resolve) => {
      const inv = getPiInvocation(args);
      const proc = spawn(inv.command, inv.args, {
        cwd: cwd,
        shell: false,
        stdio: ["ignore", "pipe", "pipe"],
        env: { ...process.env, PI_MODEL: effectiveModel || undefined, PI_SUBAGENT_CHILD: "1", PI_AGENT_NAME: agentName, PI_PRIMARY_MODEL: process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || "", __PI_CONFIG_SESSION_ID: (globalThis as any).__piConfigSessionId || "", __PI_PARENT_SESSION_ID: (globalThis as any).__piConfigSessionId || "" },
      });
      let buf = "";

      const processLine = (line: string) => {
        if (!line.trim()) return;
        let ev: any;
        try {
          ev = JSON.parse(line);
        } catch {
          return;
        }
        if (ev.type === "message_end" && ev.message) {
          const msg = ev.message as Message;
          cur.messages.push(msg);
          if (msg.role === "assistant") {
            cur.usage.turns++;
            const u = msg.usage;
            if (u) {
              cur.usage.input += u.input || 0;
              cur.usage.output += u.output || 0;
              cur.usage.cacheRead += u.cacheRead || 0;
              cur.usage.cacheWrite += u.cacheWrite || 0;
              cur.usage.cost += u.cost?.total || 0;
              cur.usage.contextTokens = u.totalTokens || 0;
            }
            if (!cur.model && msg.model) cur.model = msg.model;
            if (msg.stopReason) cur.stopReason = msg.stopReason;
            if (msg.errorMessage) cur.errorMessage = msg.errorMessage;
          }
          emit();
        }
        if (ev.type === "tool_result_end" && ev.message) {
          cur.messages.push(ev.message as Message);
          emit();
        }
      };

      proc.stdout.on("data", (d) => {
        buf += d.toString();
        const lines = buf.split("\n");
        buf = lines.pop() || "";
        for (const l of lines) processLine(l);
      });
      proc.stderr.on("data", (d) => {
        cur.stderr += d.toString();
      });
      proc.on("close", (c) => {
        log.info("sync_proc_close", agentName, "code", c);
        if (buf.trim()) processLine(buf);
        resolve(c ?? 0);
      });
      proc.on("error", (err) => {
        log.error("sync_proc_error", agentName, err?.message);
        resolve(1);
      });

      if (signal) {
        const kill = () => {
          aborted = true;
          proc.kill("SIGTERM");
          setTimeout(() => {
            if (!proc.killed) proc.kill("SIGKILL");
          }, 5000);
        };
        if (signal.aborted) kill();
        else signal.addEventListener("abort", kill, { once: true });
      }
    });

    log.info("sync_proc_resolved", agentName, "exitCode", exitCode, "elapsed", Date.now() - startTime);
    cur.exitCode = exitCode;
    cur.durationMs = Date.now() - startTime;

    if (aborted) throw new Error("Subagent was aborted");
    return cur;
  } finally {
    if (tmpFile)
      try {
        fs.unlinkSync(tmpFile);
      } catch {}
    if (tmpDir)
      try {
        fs.rmdirSync(tmpDir);
      } catch {}
  }
}

/** The message for a task id the store does not know; null when it is valid or exempt. */
export function taskIdError(taskId: string, lookup: (id: string) => boolean): string | null {
  if (taskId === "-1") return null;
  return lookup(taskId)
    ? null
    : `Task #${taskId} not found. Verify the task ID exists (use TaskList to check), or pass taskId: "-1" for a read-only check (editing or PR work needs a real task id).`;
}

/** Validate that a taskId references an existing task. Awaits the store load, so the
 *  first async call of a session validates too instead of failing open (issue #1016). */
async function validateTaskId(taskId: string, _cwd: string, _sessionId?: string): Promise<string | null> {
  if (taskId === "-1") return null;
  const store = await getTaskStore();
  if (!store) return null; // pitasks genuinely unavailable — warned in getTaskStore
  try {
    return taskIdError(taskId, (id) => Boolean(store.get(id)));
  } catch (err) {
    // Same fail-open as before, but a broken store must not look like a valid id.
    log.warn("task_id_validation_failed", { reason: String((err as Error)?.message ?? err).slice(0, 120) });
    return null;
  }
}

// ── Registration ─────────────────────────────────────────────────────────

export function registerSubagentTool(
  pi: ExtensionAPI,
  spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string },
  killAsyncAgent: (target: string, origin?: AsyncKillOrigin) => { killed: string[]; errors: string[] },
): void {
  // Only the orchestrator (top-level pi) can spawn subagents.
  // Child processes set PI_SUBAGENT_CHILD=1 to prevent infinite recursion.
  if (process.env.PI_SUBAGENT_CHILD === "1") return;

  // #968 part B: the project-agent approval is bridged — answerable from
  // TUI or pidash browser, first answer wins.
  const bridgedUi = createBridgedUi(pi);

  // ── Extracted execute helpers (closure over pi, spawnAsyncAgent, killAsyncAgent) ──

  // asyncKill is an LLM-issued decision, not a user action.
  function executeAsyncKill(
    params: { asyncKill: string },
    mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
  ) {
    const { killed, errors } = killAsyncAgent(params.asyncKill, "orchestrator");
    log.info("subagent_async_kill", { target: params.asyncKill, origin: "orchestrator", killed: killed.length });
    const lines: string[] = [];
    if (killed.length > 0) lines.push(`Killed: ${killed.join(", ")}`);
    if (errors.length > 0) lines.push(errors.join("\n"));
    return {
      content: [{ type: "text" as const, text: lines.join("\n") || "No action taken." }],
      details: mkd("single")([]),
      isError: errors.length > 0 && killed.length === 0,
    };
  }

  async function executeAsync(
    params: any,
    agents: AgentConfig[],
    mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
    parentModelId: string | undefined,
    parentProvider: string | undefined,
    ctx: any,
    explicit?: { model?: string; provider?: string },
  ) {
    if (params.chain) {
      return {
        content: [{ type: "text" as const, text: "Async mode does not support chain. Use agent + task or tasks array." }],
        details: mkd("single")([]),
        isError: true,
      };
    }

    // Parallel async — spawn each task as a separate async agent
    if (params.tasks && params.tasks.length > 0) {
      // Validate all tasks have names
      const unnamed = params.tasks.filter((t: any) => !(t as any).name);
      if (unnamed.length > 0) {
        return {
          content: [{ type: "text" as const, text: `Async agents require a name for display in status line. Missing name for: ${unnamed.map((t: any) => t.agent).join(", ")}` }],
          details: mkd("single")([]),
          isError: true,
        };
      }
      const noTaskId = params.tasks.filter((t: any) => (t as any).taskId == null);
      if (noTaskId.length > 0) {
        const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nMissing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}\n\nIf an agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf an agent is only doing a single read-only check: pass taskId: "-1" (editing or PR work needs a real task id)`;
        // Force a follow-up turn so the AI MUST deal with this error
        await sendSystemNote(
          pi,
          `🚨 CRITICAL: Async subagent call was REJECTED — missing taskId for: ${noTaskId.map((t: any) => t.agent).join(", ")}. NO agents were spawned. You MUST retry with taskId for each agent. Use TaskList to find task IDs; "-1" is only for a single read-only check.`,
          { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
        );
        return {
          content: [{ type: "text" as const, text: errorMsg }],
          details: mkd("single")([]),
          isError: true,
        };
      }
      for (const t of params.tasks) {
        const tid = (t as any).taskId as string;
        const taskErr = await validateTaskId(tid, ctx.cwd, ctx.sessionManager?.getSessionId?.());
        if (taskErr) {
          await sendSystemNote(
            pi,
            `🚨 CRITICAL: ${taskErr} (agent: ${t.agent}). NO agents were spawned. Fix the taskId and retry.`,
            { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
          );
          return {
            content: [{ type: "text" as const, text: `${taskErr} (agent: ${t.agent})` }],
            details: mkd("single")([]),
            isError: true,
          };
        }
      }
      const results: string[] = [];
      const errors: string[] = [];
      // Group parallel async tasks so results are delivered together
      const groupId = params.tasks.length > 1
        ? `group-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
        : undefined;
      for (const t of params.tasks) {
        const taskExplicit = mergeModelOverride(t.model, explicit);
        const r = spawnAsyncAgent(t.agent, t.task, t.cwd, agents, {
          fireAndForget: params.fireAndForget,
          name: (t as any).name,
          parentModelId,
          parentProvider,
          groupId,
          taskId: (t as any).taskId,
          persistSession: params.persistSession,
          explicit: taskExplicit,
        });
        if (r.error) {
          errors.push(`${t.agent}: ${r.error}`);
        } else {
          const label = (t as any).name || t.agent;
          results.push(`${label} [${r.id}]${r.model ? ` (${r.model})` : ""}`);
        }
      }
      const lines: string[] = [];
      if (results.length > 0) lines.push(`Spawned ${results.length} async agents:\n${results.map(r => `- ${r}`).join("\n")}`);
      if (errors.length > 0) lines.push(`Failed:\n${errors.map(e => `- ${e}`).join("\n")}`);
      lines.push("\nUse /async-status to check progress.");
      return {
        content: [{ type: "text" as const, text: lines.join("\n") }],
        details: mkd("single")([]),
        isError: errors.length > 0 && results.length === 0,
      };
    }

    // Single async
    if (!params.agent || !params.task) {
      return {
        content: [{ type: "text" as const, text: "Async mode requires agent and task (or tasks array)." }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    if (!params.cwd) {
      return {
        content: [{ type: "text" as const, text: MISSING_CWD_ERROR }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    if (!params.name) {
      return {
        content: [{ type: "text" as const, text: "Async agents require a name for display in status line (e.g., 'Dream', 'Code Review', 'PR #42')." }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    if (params.taskId == null) {
      const errorMsg = `Missing required parameter: taskId. Every async agent MUST have a taskId.\n\nIf this agent is working on a task: pass the task ID (e.g., taskId: "5")\nIf this agent is only doing a single read-only check: pass taskId: "-1" (editing or PR work needs a real task id)\n\nExample: subagent(agent="${params.agent}", task="...", async=true, name="${params.name}", taskId="-1")`;
      // Force a follow-up turn so the AI MUST deal with this error
      await sendSystemNote(
        pi,
        `🚨 CRITICAL: Async subagent call for "${params.agent}" was REJECTED — missing taskId. The agent was NOT spawned. You MUST retry the subagent call with taskId. Use TaskList to find the task ID; "-1" is only for a single read-only check. Do NOT proceed without fixing this.`,
        { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
      );
      return {
        content: [{ type: "text" as const, text: errorMsg }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    {
      const taskErr = await validateTaskId(params.taskId, ctx.cwd, ctx.sessionManager?.getSessionId?.());
      if (taskErr) {
        await sendSystemNote(
          pi,
          `🚨 CRITICAL: ${taskErr} The agent was NOT spawned. Fix the taskId and retry.`,
          { customType: "subagent-taskid-error", triggerTurn: true, deliverAs: "followUp" },
        );
        return {
          content: [{ type: "text" as const, text: taskErr }],
          details: mkd("single")([]),
          isError: true,
        };
      }
    }
    if (syncPenalty > 0) {
      log.info("sync_penalty_reset", "async used");
      syncPenalty = 0;
    }
    const result = spawnAsyncAgent(params.agent, params.task, params.cwd, agents, {
      fireAndForget: params.fireAndForget,
      name: params.name,
      parentModelId,
      parentProvider,
      taskId: params.taskId,
      persistSession: params.persistSession,
      explicit: explicit,
    });
    if (result.error) {
      return {
        content: [{ type: "text" as const, text: result.error }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    const label = params.name || params.agent;
    const modelInfo = result.model ? ` (${result.model})` : "";
    return {
      content: [{ type: "text" as const, text: `Async agent spawned: ${label} [${result.id}]${modelInfo}\nUse /async-status to check progress. Results will appear when complete.` }],
      details: mkd("single")([]),
    };
  }

  async function executeChain(
    params: any,
    agents: AgentConfig[],
    mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
    signal: AbortSignal | undefined,
    onUpdate: OnUpdate | undefined,
    activeAgents: Set<string>,
    updateWorking: () => void,
    parentModelId: string | undefined,
    parentProvider: string | undefined,
    asyncOk: boolean,
    sessionId?: string,
    explicit?: { model?: string; provider?: string },
  ) {
    const results: SingleResult[] = [];
    let prev = "";

    for (let i = 0; i < params.chain.length; i++) {
      if (!params.chain[i].cwd) {
        return {
          content: [
            {
              type: "text" as const,
              text: `${MISSING_CWD_ERROR} (chain step ${i + 1}, agent: ${params.chain[i].agent})`,
            },
          ],
          details: mkd("chain")([]),
          isError: true,
        };
      }
    }
    // defensive: LLMs may omit schema-required fields
    const missingChainEstimate = params.chain.find((s: any) => s.estimatedSeconds == null);
    if (missingChainEstimate) {
      return {
        content: [{ type: "text" as const, text: `${MISSING_ESTIMATE_ERROR} (chain step, agent: ${missingChainEstimate.agent})` }],
        details: mkd("chain")([]),
        isError: true,
      };
    }
    const invalidChainEstimate = params.chain.find((s: any) => s.estimatedSeconds != null && s.estimatedSeconds <= 0);
    if (invalidChainEstimate) {
      return {
        content: [{ type: "text" as const, text: `estimatedSeconds must be a positive number. (chain step, agent: ${invalidChainEstimate.agent})` }],
        details: mkd("chain")([]),
        isError: true,
      };
    }
    // chain runs sequentially — sum all steps
    const totalChainSeconds = params.chain.reduce((sum: number, s: any) => sum + s.estimatedSeconds, 0);
    if (!params.chain || params.chain.length === 0) {
      return { content: [{ type: "text" as const, text: "Error: chain array is empty — at least one step is required." }], details: undefined };
    }
    const chainCwd = params.chain[0].cwd;
    const { limit: chainLimit } = checkSyncLimit(0, chainCwd);
    if (syncPenalty > 0) {
      const adjustedTotal = totalChainSeconds + syncPenalty;
      if (adjustedTotal >= chainLimit) {
        log.info("sync_penalty_rejected_chain", "penalty", syncPenalty, "adjusted", adjustedTotal);
        return {
          content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Chain total ${totalChainSeconds}s + penalty ${syncPenalty}s = ${adjustedTotal}s — exceeds ${chainLimit}s limit. Use async: true.` }],
          details: mkd("chain")([]),
          isError: true,
        };
      }
    }
    if (totalChainSeconds >= chainLimit && asyncOk) {
      return {
        content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(totalChainSeconds, chainLimit, chainCwd) }],
        details: mkd("chain")([]),
        isError: true,
      };
    }

    for (let i = 0; i < params.chain.length; i++) {
      const s = params.chain[i];
      const t = s.task.replace(/\{previous\}/g, prev);
      const chainUpdate: OnUpdate | undefined = onUpdate
        ? (p) => {
            const c = p.details?.results[0];
            if (c)
              onUpdate({
                content: p.content,
                details: mkd("chain")([...results, c]),
              });
          }
        : undefined;
      activeAgents.clear();
      activeAgents.add(s.agent);
      updateWorking();
      // Auto-mark linked task as in_progress (same as async path)
      const chainTaskId = s.taskId as string | undefined;
      if (chainTaskId && chainTaskId !== "-1") {
        await autoMarkInProgress(chainTaskId, s.cwd, sessionId).catch(() => {});
      }
      const r = await runSingleAgent(
        agents,
        s.agent,
        t,
        s.cwd,
        i + 1,
        signal,
        chainUpdate,
        mkd("chain"),
        parentModelId,
        parentProvider,
        params.persistSession,
        explicit,
      );
      activeAgents.delete(s.agent);
      updateWorking();
      results.push(r);
      if (
        r.exitCode !== 0 ||
        r.stopReason === "error" ||
        r.stopReason === "aborted"
      ) {
        return {
          content: [
            {
              type: "text" as const,
              text: `Chain stopped at step ${i + 1} (${s.agent}): ${r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)"}`,
            },
          ],
          details: mkd("chain")(results),
          isError: true,
        };
      }
      // Auto-complete linked task on success (same as async path)
      if (chainTaskId && chainTaskId !== "-1") {
        await autoCompleteTask(chainTaskId, s.cwd, sessionId).catch(() => {});
      }
      prev = getFinalOutput(r.messages);
    }
    return {
      content: [
        {
          type: "text" as const,
          text:
            getFinalOutput(results[results.length - 1].messages) ||
            "(no output)",
        },
      ],
      details: mkd("chain")(results),
    };
  }

  async function executeParallel(
    params: any,
    agents: AgentConfig[],
    mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
    signal: AbortSignal | undefined,
    onUpdate: OnUpdate | undefined,
    activeAgents: Set<string>,
    updateWorking: () => void,
    parentModelId: string | undefined,
    parentProvider: string | undefined,
    asyncOk: boolean,
    sessionId?: string,
    explicit?: { model?: string; provider?: string },
  ) {
    if (params.tasks.length > MAX_PARALLEL_TASKS)
      return {
        content: [
          {
            type: "text" as const,
            text: `Too many tasks (${params.tasks.length}). Max ${MAX_PARALLEL_TASKS}.`,
          },
        ],
        details: mkd("parallel")([]),
        isError: true,
      };
    // defensive: LLMs may omit required fields
    const missingEstimate = params.tasks.find((t: any) => t.estimatedSeconds == null);
    if (missingEstimate) {
      return {
        content: [{ type: "text" as const, text: `${MISSING_ESTIMATE_ERROR} (parallel task, agent: ${missingEstimate.agent})` }],
        details: mkd("parallel")([]),
        isError: true,
      };
    }
    const invalidEstimate = params.tasks.find((t: any) => t.estimatedSeconds != null && t.estimatedSeconds <= 0);
    if (invalidEstimate) {
      return {
        content: [{ type: "text" as const, text: `estimatedSeconds must be a positive number. (parallel task, agent: ${invalidEstimate.agent})` }],
        details: mkd("parallel")([]),
        isError: true,
      };
    }
    // parallel runs concurrently — use longest task
    const maxParallelSeconds = Math.max(...params.tasks.map((t: any) => t.estimatedSeconds!));
    const parallelCwd = params.tasks[0]?.cwd ?? process.cwd();
    const { limit: parallelLimit } = checkSyncLimit(0, parallelCwd);
    if (syncPenalty > 0) {
      const adjustedMax = maxParallelSeconds + syncPenalty;
      if (adjustedMax >= parallelLimit) {
        log.info("sync_penalty_rejected_parallel", "penalty", syncPenalty, "adjusted", adjustedMax);
        return {
          content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Max parallel estimate ${maxParallelSeconds}s + penalty ${syncPenalty}s = ${adjustedMax}s — exceeds ${parallelLimit}s limit. Use async: true.` }],
          details: mkd("parallel")([]),
          isError: true,
        };
      }
    }
    if (maxParallelSeconds >= parallelLimit && asyncOk) {
      return {
        content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(maxParallelSeconds, parallelLimit, parallelCwd) }],
        details: mkd("parallel")([]),
        isError: true,
      };
    }
    const all: SingleResult[] = params.tasks.map((t: any) => ({
      agent: t.agent,
      agentSource: "unknown" as const,
      task: t.task,
      exitCode: -1,
      messages: [],
      stderr: "",
      usage: {
        input: 0,
        output: 0,
        cacheRead: 0,
        cacheWrite: 0,
        cost: 0,
        contextTokens: 0,
        turns: 0,
      },
    }));
    const emitAll = () => {
      if (onUpdate) {
        const run = all.filter((r) => r.exitCode === -1).length;
        const done = all.filter((r) => r.exitCode !== -1).length;
        onUpdate({
          content: [
            {
              type: "text",
              text: `Parallel: ${done}/${all.length} done, ${run} running...`,
            },
          ],
          details: mkd("parallel")([...all]),
        });
      }
    };
    for (let i = 0; i < params.tasks.length; i++) {
      if (!params.tasks[i].cwd) {
        return {
          content: [
            {
              type: "text" as const,
              text: `${MISSING_CWD_ERROR} (parallel task ${i + 1}, agent: ${params.tasks[i].agent})`,
            },
          ],
          details: mkd("parallel")([]),
          isError: true,
        };
      }
    }
    // Auto-mark linked tasks as in_progress
    for (const t of params.tasks) {
      const tid = (t as any).taskId as string | undefined;
      if (tid && tid !== "-1") {
        await autoMarkInProgress(tid, t.cwd, sessionId).catch(() => {});
      }
    }
    const results = await mapWithConcurrency(
      params.tasks,
      MAX_CONCURRENCY,
      async (t: any, i: number) => {
        const label = t.name || t.agent;
        activeAgents.add(label);
        updateWorking();
        const taskExplicit = mergeModelOverride(t.model, explicit);
        const r = await runSingleAgent(
          agents,
          t.agent,
          t.task,
          t.cwd,
          undefined,
          signal,
          (p) => {
            if (p.details?.results[0]) {
              all[i] = p.details.results[0];
              emitAll();
            }
          },
          mkd("parallel"),
          parentModelId,
          parentProvider,
          params.persistSession,
          taskExplicit,
        );
        all[i] = r;
        activeAgents.delete(t.name || t.agent);
        updateWorking();
        emitAll();
        // Auto-complete linked task on success (same as async path)
        const tid = t.taskId as string | undefined;
        if (
          tid &&
          tid !== "-1" &&
          r.exitCode === 0 &&
          r.stopReason !== "error" &&
          r.stopReason !== "aborted"
        ) {
          await autoCompleteTask(tid, t.cwd, sessionId).catch(() => {});
        }
        return r;
      },
    );
    const ok = results.filter((r) => r.exitCode === 0).length;
    const summaries = results.map((r) => {
      const o = getFinalOutput(r.messages);
      const p = o.slice(0, 100) + (o.length > 100 ? "..." : "");
      return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${p || "(no output)"}`;
    });
    return {
      content: [
        {
          type: "text" as const,
          text: `Parallel: ${ok}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
        },
      ],
      details: mkd("parallel")(results),
    };
  }

  async function executeSingle(
    params: any,
    agents: AgentConfig[],
    mkd: (mode: "single" | "parallel" | "chain") => (results: SingleResult[]) => SubagentDetails,
    signal: AbortSignal | undefined,
    onUpdate: OnUpdate | undefined,
    activeAgents: Set<string>,
    updateWorking: () => void,
    parentModelId: string | undefined,
    parentProvider: string | undefined,
    asyncOk: boolean,
    sessionId?: string,
    explicit?: { model?: string; provider?: string },
  ) {
    if (!params.cwd) {
      return {
        content: [
          {
            type: "text" as const,
            text: MISSING_CWD_ERROR,
          },
        ],
        details: mkd("single")([]),
        isError: true,
      };
    }
    if (params.estimatedSeconds == null) {
      return {
        content: [{ type: "text" as const, text: MISSING_ESTIMATE_ERROR }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    if (params.estimatedSeconds <= 0) {
      return {
        content: [{ type: "text" as const, text: "estimatedSeconds must be a positive number." }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    const { limit: singleLimit } = checkSyncLimit(0, params.cwd);
    if (syncPenalty > 0) {
      const adjustedEstimate = (params.estimatedSeconds ?? 0) + syncPenalty;
      if (adjustedEstimate >= singleLimit) {
        log.info("sync_penalty_rejected", params.agent, "estimate", params.estimatedSeconds, "penalty", syncPenalty, "adjusted", adjustedEstimate);
        return {
          content: [{ type: "text" as const, text: `Rejected: your last sync agent '${lastSyncAgent}' estimated ${lastSyncEstimate}s but took ${lastSyncActual}s. Penalty: ${syncPenalty}s. Your estimate ${params.estimatedSeconds}s + penalty ${syncPenalty}s = ${adjustedEstimate}s — exceeds ${singleLimit}s limit. Use async: true.` }],
          details: mkd("single")([]),
          isError: true,
        };
      }
    }
    if (params.estimatedSeconds >= singleLimit && asyncOk) {
      return {
        content: [{ type: "text" as const, text: SYNC_TIME_EXCEEDED_ERROR(params.estimatedSeconds, singleLimit, params.cwd) }],
        details: mkd("single")([]),
        isError: true,
      };
    }
    const label = params.name || params.agent;
    activeAgents.add(label);
    updateWorking();
    // Auto-mark linked task as in_progress (same as async path)
    const syncTaskId = params.taskId as string | undefined;
    if (syncTaskId && syncTaskId !== "-1") {
      await autoMarkInProgress(syncTaskId, params.cwd, sessionId).catch(() => {});
    }
    log.info("sync_execute_start", params.agent, "taskId", syncTaskId ?? "none");
    const syncStartTime = Date.now();
    const r = await runSingleAgent(
      agents,
      params.agent,
      params.task,
      params.cwd,
      undefined,
      signal,
      onUpdate,
      mkd("single"),
      parentModelId,
      parentProvider,
      params.persistSession,
      explicit,
    );
    const actualSeconds = Math.round((Date.now() - syncStartTime) / 1000);
    if (actualSeconds > checkSyncLimit(0, params.cwd).limit && actualSeconds - (params.estimatedSeconds ?? 0) > 30) {
      syncPenalty = actualSeconds - (params.estimatedSeconds ?? 0);
      lastSyncAgent = params.agent;
      lastSyncEstimate = params.estimatedSeconds ?? 0;
      lastSyncActual = actualSeconds;
      log.info("sync_penalty_set", params.agent, "estimate", params.estimatedSeconds, "actual", actualSeconds, "penalty", syncPenalty);
    }
    log.info("sync_execute_done", params.agent, "exitCode", r.exitCode, "stopReason", r.stopReason, "elapsed", Date.now() - syncStartTime);
    activeAgents.delete(label);
    updateWorking();
    const err =
      r.exitCode !== 0 ||
      r.stopReason === "error" ||
      r.stopReason === "aborted";
    if (err)
      return {
        content: [
          {
            type: "text" as const,
            text: `Agent ${r.stopReason || "failed"}: ${r.errorMessage || r.stderr || getFinalOutput(r.messages) || "(no output)"}`,
          },
        ],
        details: mkd("single")([r]),
        isError: true,
      };
    // Auto-complete linked task on success (same as async path)
    if (syncTaskId && syncTaskId !== "-1") {
      await autoCompleteTask(syncTaskId, params.cwd, sessionId).catch(() => {});
    }
    log.info("sync_execute_returning", params.agent);
    return {
      content: [
        { type: "text" as const, text: getFinalOutput(r.messages) || "(no output)" },
      ],
      details: mkd("single")([r]),
    };
  }

  // ── Extracted renderResult helpers ──

  function renderSingleResult(
    r: SingleResult,
    expanded: boolean,
    theme: any,
    ts: string,
    mdTheme: any,
    renderItems: (items: DisplayItem[], limit?: number) => string,
  ) {
    const isErr =
      r.exitCode !== 0 ||
      r.stopReason === "error" ||
      r.stopReason === "aborted";
    const icon = isErr ? theme.fg("error", "✗") : theme.fg("success", "✓");
    const items = getDisplayItems(r.messages);
    const final = getFinalOutput(r.messages);
    if (expanded) {
      const c = new Container();
      let h = `${ts}${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
      if (isErr && r.stopReason)
        h += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
      c.addChild(new Text(h, 0, 0));
      if (isErr && r.errorMessage)
        c.addChild(
          new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0),
        );
      c.addChild(new Spacer(1));
      c.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
      c.addChild(new Text(theme.fg("dim", r.task), 0, 0));
      c.addChild(new Spacer(1));
      c.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
      if (items.length === 0 && !final)
        c.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
      else {
        for (const i of items)
          if (i.type === "toolCall")
            c.addChild(
              new Text(
                theme.fg("muted", "→ ") +
                  formatToolCall(i.name, i.args, theme.fg.bind(theme)),
                0,
                0,
              ),
            );
        if (final) {
          c.addChild(new Spacer(1));
          c.addChild(new Markdown(final.trim(), 0, 0, mdTheme));
        }
      }
      const u = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
      if (u) {
        c.addChild(new Spacer(1));
        c.addChild(new Text(theme.fg("dim", u), 0, 0));
      }
      return c;
    }
    let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
    if (isErr && r.stopReason)
      t += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
    if (isErr && r.errorMessage)
      t += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
    else if (items.length === 0)
      t += `\n${theme.fg("muted", "(no output)")}`;
    else {
      t += `\n${renderItems(items, COLLAPSED_ITEM_COUNT)}`;
      if (items.length > COLLAPSED_ITEM_COUNT)
        t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
    }
    const u = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
    if (u) t += `\n${theme.fg("dim", u)}`;
    return new Text(t, 0, 0);
  }

  function renderChainResults(
    details: SubagentDetails,
    expanded: boolean,
    theme: any,
    ts: string,
    mdTheme: any,
    renderItems: (items: DisplayItem[], limit?: number) => string,
    aggUsage: (rs: SingleResult[]) => { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; durationMs: number },
  ) {
    const ok = details.results.filter((r) => r.exitCode === 0).length;
    const icon =
      ok === details.results.length
        ? theme.fg("success", "✓")
        : theme.fg("error", "✗");
    if (expanded) {
      const c = new Container();
      c.addChild(
        new Text(
          `${ts}${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${ok}/${details.results.length} steps`)}`,
          0,
          0,
        ),
      );
      for (const r of details.results) {
        const ri =
          r.exitCode === 0
            ? theme.fg("success", "✓")
            : theme.fg("error", "✗");
        c.addChild(new Spacer(1));
        c.addChild(
          new Text(
            `${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${ri}`,
            0,
            0,
          ),
        );
        c.addChild(
          new Text(
            theme.fg("muted", "Task: ") + theme.fg("dim", r.task),
            0,
            0,
          ),
        );
        for (const i of getDisplayItems(r.messages))
          if (i.type === "toolCall")
            c.addChild(
              new Text(
                theme.fg("muted", "→ ") +
                  formatToolCall(i.name, i.args, theme.fg.bind(theme)),
                0,
                0,
              ),
            );
        const f = getFinalOutput(r.messages);
        if (f) {
          c.addChild(new Spacer(1));
          c.addChild(new Markdown(f.trim(), 0, 0, mdTheme));
        }
        const su = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
        if (su) c.addChild(new Text(theme.fg("dim", su), 0, 0));
      }
      const tu = formatUsageStats(aggUsage(details.results));
      if (tu) {
        c.addChild(new Spacer(1));
        c.addChild(new Text(theme.fg("dim", `Total: ${tu}`), 0, 0));
      }
      return c;
    }
    let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${ok}/${details.results.length} steps`)}`;
    for (const r of details.results) {
      const ri =
        r.exitCode === 0
          ? theme.fg("success", "✓")
          : theme.fg("error", "✗");
      const di = getDisplayItems(r.messages);
      t += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${ri}`;
      t +=
        di.length === 0
          ? `\n${theme.fg("muted", "(no output)")}`
          : `\n${renderItems(di, 5)}`;
    }
    const tu = formatUsageStats(aggUsage(details.results));
    if (tu) t += `\n\n${theme.fg("dim", `Total: ${tu}`)}`;
    t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
    return new Text(t, 0, 0);
  }

  function renderParallelResults(
    details: SubagentDetails,
    expanded: boolean,
    theme: any,
    ts: string,
    mdTheme: any,
    renderItems: (items: DisplayItem[], limit?: number) => string,
    aggUsage: (rs: SingleResult[]) => { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; durationMs: number },
  ) {
    const running = details.results.filter((r) => r.exitCode === -1).length;
    const ok = details.results.filter((r) => r.exitCode === 0).length;
    const fail = details.results.filter((r) => r.exitCode > 0).length;
    const isRunning = running > 0;
    const icon = isRunning
      ? theme.fg("warning", "⏳")
      : fail > 0
        ? theme.fg("warning", "◐")
        : theme.fg("success", "✓");
    const status = isRunning
      ? `${ok + fail}/${details.results.length} done, ${running} running`
      : `${ok}/${details.results.length} tasks`;
    if (expanded && !isRunning) {
      const c = new Container();
      c.addChild(
        new Text(
          `${ts}${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
          0,
          0,
        ),
      );
      for (const r of details.results) {
        const ri =
          r.exitCode === 0
            ? theme.fg("success", "✓")
            : theme.fg("error", "✗");
        c.addChild(new Spacer(1));
        c.addChild(
          new Text(
            `${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${ri}`,
            0,
            0,
          ),
        );
        c.addChild(
          new Text(
            theme.fg("muted", "Task: ") + theme.fg("dim", r.task),
            0,
            0,
          ),
        );
        for (const i of getDisplayItems(r.messages))
          if (i.type === "toolCall")
            c.addChild(
              new Text(
                theme.fg("muted", "→ ") +
                  formatToolCall(i.name, i.args, theme.fg.bind(theme)),
                0,
                0,
              ),
            );
        const f = getFinalOutput(r.messages);
        if (f) {
          c.addChild(new Spacer(1));
          c.addChild(new Markdown(f.trim(), 0, 0, mdTheme));
        }
        const su = formatUsageStats({ ...r.usage, durationMs: r.durationMs }, r.model);
        if (su) c.addChild(new Text(theme.fg("dim", su), 0, 0));
      }
      const tu = formatUsageStats(aggUsage(details.results));
      if (tu) {
        c.addChild(new Spacer(1));
        c.addChild(new Text(theme.fg("dim", `Total: ${tu}`), 0, 0));
      }
      return c;
    }
    let t = `${ts}${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
    for (const r of details.results) {
      const ri =
        r.exitCode === -1
          ? theme.fg("warning", "⏳")
          : r.exitCode === 0
            ? theme.fg("success", "✓")
            : theme.fg("error", "✗");
      const di = getDisplayItems(r.messages);
      t += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${ri}`;
      t +=
        di.length === 0
          ? `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`
          : `\n${renderItems(di, 5)}`;
    }
    if (!isRunning) {
      const tu = formatUsageStats(aggUsage(details.results));
      if (tu) t += `\n\n${theme.fg("dim", `Total: ${tu}`)}`;
    }
    if (!expanded) t += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
    return new Text(t, 0, 0);
  }

  pi.registerTool({
    name: "subagent",
    label: "Subagent",
    description: [
      "Delegate tasks to specialized subagents with isolated context.",
      "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
      "Agents are bundled with this package, plus user (~/.pi/agent/agents) and project (.pi/agents) agents.",
    ].join(" "),
    promptSnippet:
      "Delegate tasks to specialized subagents (single, parallel, or chain mode)",
    promptGuidelines: [
      "Use subagent to delegate code changes, git operations, debugging, tests, and reviews to specialist agents.",
      "Route by intent: python code → python-expert, git commit → git-expert, PR → github-expert, etc.",
      "Run independent tasks in parallel using the tasks array.",
      "For multi-step workflows, use chain mode with {previous} placeholder.",
      "Set async: true when you don't need the result immediately for your next step. The result will surface automatically when complete. Use sync (default) only when the next step depends on this agent's output.",
      "ALWAYS use async: true for independent tasks that can run in parallel — code reviews, opening issues, research, analysis. Only use sync when the very next step depends on this agent's output (e.g., chain where step 2 needs step 1's result).",
      "ALWAYS pass cwd — use the project directory for current repo work, or the target path for external repos (e.g., ${PROJECT_TMP_DIR}/...).",
      "ALWAYS provide estimatedSeconds for sync agents. If estimated time is 30 seconds or more, you MUST use async: true instead.",
      "After spawning async agents, END YOUR TURN. Do NOT write bash loops, sleep commands, or poll for results — the system delivers results automatically as a follow-up message. Spawn the agent and stop.",
    ],
    parameters: SubagentParams,

    async execute(_id, params, signal, onUpdate, ctx) {
      // Track active subagents for working indicator
      const activeAgents = new Set<string>();
      const updateWorking = () => {
        if (!ctx.hasUI) return;
        if (activeAgents.size === 0) {
          ctx.ui.setWorkingMessage();
          return;
        }
        const names = [...activeAgents];
        if (names.length === 1) {
          ctx.ui.setWorkingMessage(`🔧 ${names[0]} working...`);
        } else {
          ctx.ui.setWorkingMessage(`🔧 ${names.length} agents (${names.join(", ")})...`);
        }
      };

      const scope: AgentScope = params.agentScope ?? "both";
      const discovery = discoverAgents(ctx.cwd, scope);
      const agents = discovery.agents;
      const confirm = params.confirmProjectAgents ?? true;
      let parentModelId = ctx.model?.id as string | undefined;
      let parentProvider = ctx.model?.provider as string | undefined;
      // Parse explicit model override from params (applies to all execution modes)
      const explicitOverride = parseModelOverride(params.model);
      const asyncOk = supportsAsyncLlm(parentProvider, ctx.cwd);
      let capabilityNote = "";

      const hasChain = (params.chain?.length ?? 0) > 0;
      const hasTasks = (params.tasks?.length ?? 0) > 0;
      const hasSingle = Boolean(params.agent && params.task);
      const modes = Number(hasChain) + Number(hasTasks) + Number(hasSingle);

      const mkd =
        (mode: "single" | "parallel" | "chain") =>
        (results: SingleResult[]): SubagentDetails => ({
          mode,
          agentScope: scope,
          projectAgentsDir: discovery.projectAgentsDir,
          results,
        });

      // Kill async agents — early return before mode validation
      if (params.asyncKill) {
        return executeAsyncKill(params as { asyncKill: string }, mkd);
      }

      // Enforce async-only agents — force async on native; on acpx leave sync (coerce path).
      {
        const requested: string[] = [];
        if (params.agent) requested.push(params.agent);
        if (params.tasks) for (const t of params.tasks) requested.push(t.agent);
        if (params.chain) for (const s of params.chain) requested.push(s.agent);
        const violators = [...new Set(requested.filter(n => ASYNC_ONLY_AGENTS.has(n)))];
        if (violators.length > 0) {
          const inChain = params.chain?.some(s => ASYNC_ONLY_AGENTS.has(s.agent));
          if (inChain) {
            return {
              content: [{ type: "text", text: `These agents cannot be used in chain mode: ${violators.join(", ")}. Dispatch them as separate async tasks instead.` }],
              details: mkd("single")([]),
              isError: true,
            };
          }
          if (asyncOk && params.async !== true) {
            params.async = true;
            // Set default name(s) for async display — async path requires names
            if (params.agent && !params.name) {
              params.name = params.agent;
            }
            if (params.tasks) {
              for (const t of params.tasks) {
                if (!(t as any).name) (t as any).name = t.agent;
              }
            }
          }
        }
      }

      if (modes !== 1) {
        const avail =
          agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
        return {
          content: [
            {
              type: "text",
              text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${avail}`,
            },
          ],
          details: mkd("single")([]),
        };
      }

      // Confirm project agents
      if ((scope === "project" || scope === "both") && confirm && ctx.hasUI) {
        const requested = new Set<string>();
        if (params.chain) for (const s of params.chain) requested.add(s.agent);
        if (params.tasks) for (const t of params.tasks) requested.add(t.agent);
        if (params.agent) requested.add(params.agent);
        const projAgents = Array.from(requested)
          .map((n) => agents.find((a) => a.name === n))
          .filter((a): a is AgentConfig => a?.source === "project");
        if (projAgents.length > 0) {
          const ok = await bridgedUi.confirm(
            ctx,
            "Run project-local agents?",
            `Agents: ${projAgents.map((a) => a.name).join(", ")}\nSource: ${discovery.projectAgentsDir}`,
          );
          if (!ok)
            return {
              content: [
                {
                  type: "text",
                  text: "Canceled: project-local agents not approved.",
                },
              ],
              details: mkd(
                hasChain ? "chain" : hasTasks ? "parallel" : "single",
              )([]),
            };
        }
      }

      // acpx parent: coerce LLM async → sync, or use sidecar for fireAndForget must-async.
      // Capability uses parent session cwd (asyncOk); settingsCwd is only for sidecar lookup.
      if (params.async === true && !asyncOk) {
        const settingsCwd =
          params.cwd ||
          params.tasks?.[0]?.cwd ||
          ctx.cwd;
        const decision = decideAsyncLlmDispatch({
          parentProvider,
          cwd: settingsCwd,
          mustAsync: params.fireAndForget === true,
          parentSupportsAsyncLlm: asyncOk,
        });
        if (decision.action === "skip") {
          return {
            content: [{ type: "text", text: decision.note }],
            details: mkd(hasTasks ? "parallel" : "single")([]),
          };
        }
        if (decision.action === "sidecar-async") {
          parentProvider = decision.sidecar.provider;
          parentModelId = decision.sidecar.model;
          capabilityNote = decision.note;
        } else if (decision.action === "coerce-sync") {
          params.async = false;
          capabilityNote = decision.note;
          console.debug(`[subagent] ${capabilityNote}`);
        }
      }

      const explicit = explicitOverride;

      // Async mode — spawn in background and return immediately
      if (params.async === true) {
        const result = await executeAsync(params, agents, mkd, parentModelId, parentProvider, ctx, explicit);
        if (capabilityNote && result.content?.[0]?.type === "text") {
          result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
        }
        return result;
      }

      const withCapabilityNote = <T extends { content?: Array<{ type: string; text?: string }> }>(
        result: T,
      ): T => {
        if (capabilityNote && result.content?.[0]?.type === "text" && result.content[0].text) {
          result.content[0].text = `${capabilityNote}\n\n${result.content[0].text}`;
        }
        return result;
      };

      const sessionId = ctx.sessionManager?.getSessionId?.();

      // Chain mode
      if (params.chain && params.chain.length > 0) {
        return withCapabilityNote(
          await executeChain(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
        );
      }

      // Parallel mode
      if (params.tasks && params.tasks.length > 0) {
        return withCapabilityNote(
          await executeParallel(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
        );
      }

      // Single mode
      if (params.agent && params.task) {
        return withCapabilityNote(
          await executeSingle(params, agents, mkd, signal, onUpdate, activeAgents, updateWorking, parentModelId, parentProvider, asyncOk, sessionId, explicit),
        );
      }

      return {
        content: [
          {
            type: "text",
            text: `Invalid parameters. Available: ${agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none"}`,
          },
        ],
        details: mkd("single")([]),
      };
    },

    renderCall(args, theme, context) {
      if (!context.state.startedAt) context.state.startedAt = clockHHMM();
      const ts = theme.fg("dim", `[${context.state.startedAt}] `);
      const scope: AgentScope = args.agentScope ?? "both";
      if (args.chain && args.chain.length > 0) {
        const chainEst = args.chain.every((s: any) => s.estimatedSeconds != null)
          ? theme.fg("dim", ` ~${args.chain.reduce((sum: number, s: any) => sum + s.estimatedSeconds, 0)}s (sum)`)
          : "";
        let t =
          ts +
          theme.fg("toolTitle", theme.bold("subagent ")) +
          theme.fg("accent", `chain (${args.chain.length} steps)`) +
          theme.fg("muted", ` [${scope}]`) + chainEst;
        for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
          const s = args.chain[i];
          const p = s.task.replace(/\{previous\}/g, "").trim();
          t += `\n  ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", s.agent)}${theme.fg("dim", ` ${p.length > 40 ? p.slice(0, 40) + "..." : p}`)}`;
        }
        if (args.chain.length > 3)
          t += `\n  ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
        return new Text(t, 0, 0);
      }
      if (args.tasks && args.tasks.length > 0) {
        const parallelEst = args.tasks.every((tk: any) => tk.estimatedSeconds != null)
          ? theme.fg("dim", ` ~${Math.max(...args.tasks.map((tk: any) => tk.estimatedSeconds))}s (max)`)
          : "";
        let t =
          ts +
          theme.fg("toolTitle", theme.bold("subagent ")) +
          theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
          theme.fg("muted", ` [${scope}]`) + parallelEst;
        for (const tk of args.tasks.slice(0, 3))
          t += `\n  ${theme.fg("accent", tk.agent)}${theme.fg("dim", ` ${tk.task.length > 40 ? tk.task.slice(0, 40) + "..." : tk.task}`)}`;
        if (args.tasks.length > 3)
          t += `\n  ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
        return new Text(t, 0, 0);
      }
      const asyncLabel = args.async === true ? theme.fg("warning", " [async]") : "";
      const estLabel = args.estimatedSeconds != null && args.async !== true ? theme.fg("dim", ` ~${args.estimatedSeconds}s`) : "";
      let t =
        ts +
        theme.fg("toolTitle", theme.bold("subagent ")) +
        theme.fg("accent", args.agent || "...") +
        theme.fg("muted", ` [${scope}]`) + asyncLabel + estLabel;
      t += `\n  ${theme.fg("dim", args.task ? (args.task.length > 60 ? args.task.slice(0, 60) + "..." : args.task) : "...")}`;
      return new Text(t, 0, 0);
    },

    renderResult(result, { expanded }, theme, context) {
      if (!context.state.completedAt) context.state.completedAt = clockHHMM();
      const ts = theme.fg("dim", `[${context.state.completedAt}] `);
      const details = result.details as SubagentDetails | undefined;
      if (!details || details.results.length === 0) {
        const t = result.content[0];
        return new Text(ts + (t?.type === "text" ? t.text : "(no output)"), 0, 0);
      }
      const mdTheme = getMarkdownTheme();

      const renderItems = (items: DisplayItem[], limit?: number) => {
        const show = limit ? items.slice(-limit) : items;
        const skip = limit && items.length > limit ? items.length - limit : 0;
        let t = "";
        if (skip > 0) t += theme.fg("muted", `... ${skip} earlier items\n`);
        for (const i of show) {
          if (i.type === "text") {
            const p = expanded
              ? i.text
              : i.text.split("\n").slice(0, 3).join("\n");
            t += `${theme.fg("toolOutput", p)}\n`;
          } else
            t += `${theme.fg("muted", "→ ") + formatToolCall(i.name, i.args, theme.fg.bind(theme))}\n`;
        }
        return t.trimEnd();
      };

      const aggUsage = (rs: SingleResult[]) => {
        const t = {
          input: 0,
          output: 0,
          cacheRead: 0,
          cacheWrite: 0,
          cost: 0,
          turns: 0,
          durationMs: 0,
        };
        for (const r of rs) {
          t.input += r.usage.input;
          t.output += r.usage.output;
          t.cacheRead += r.usage.cacheRead;
          t.cacheWrite += r.usage.cacheWrite;
          t.cost += r.usage.cost;
          t.turns += r.usage.turns;
          t.durationMs = Math.max(t.durationMs, r.durationMs || 0);
        }
        return t;
      };

      // Single
      if (details.mode === "single" && details.results.length === 1) {
        return renderSingleResult(details.results[0], expanded, theme, ts, mdTheme, renderItems);
      }

      // Chain
      if (details.mode === "chain") {
        return renderChainResults(details, expanded, theme, ts, mdTheme, renderItems, aggUsage);
      }

      // Parallel
      if (details.mode === "parallel") {
        return renderParallelResults(details, expanded, theme, ts, mdTheme, renderItems, aggUsage);
      }

      const t = result.content[0];
      return new Text(t?.type === "text" ? t.text : "(no output)", 0, 0);
    },
  });
}
