#!/usr/bin/env node

import { spawn } from "node:child_process";
import process from "node:process";
import readline from "node:readline";

const DEFAULT_URL =
  process.env.CODEXMOBILE_LIVE_APP_SERVER_URL ||
  process.env.CODEXBOT_LIVE_APP_SERVER_URL ||
  "ws://127.0.0.1:8791";
const DEFAULT_CODEX_BIN = "/Applications/Codex.app/Contents/Resources/codex";

function parseArgs(argv) {
  const options = {
    url: DEFAULT_URL,
    codexBin: DEFAULT_CODEX_BIN,
    spawnServer: false,
    json: false,
    cwd: null,
    name: null,
    threadId: null,
    includeTurns: true,
    prompt: null,
    imageDataUrls: [],
    localImagePaths: [],
    model: null,
    effort: null,
    permission: null,
    itemsJson: null,
    noWait: false,
    noStartReplacement: false,
    archived: false,
    limit: 10,
    numTurns: 1,
  };

  const positional = [];
  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === "--spawn") {
      options.spawnServer = true;
    } else if (arg === "--json") {
      options.json = true;
    } else if (arg === "--no-wait" || arg === "--detach") {
      options.noWait = true;
    } else if (arg === "--no-start-replacement") {
      options.noStartReplacement = true;
    } else if (arg === "--archived") {
      const next = argv[i + 1];
      if (next && !next.startsWith("--")) {
        options.archived = !["0", "false", "no"].includes(String(next).toLowerCase());
        i += 1;
      } else {
        options.archived = true;
      }
    } else if (arg === "--url") {
      options.url = argv[++i];
    } else if (arg === "--codex-bin") {
      options.codexBin = argv[++i];
    } else if (arg === "--cwd") {
      options.cwd = argv[++i];
    } else if (arg === "--name") {
      options.name = argv[++i];
    } else if (arg === "--thread-id") {
      options.threadId = argv[++i];
    } else if (arg === "--prompt") {
      options.prompt = argv[++i];
    } else if (arg === "--image-data-url") {
      options.imageDataUrls.push(argv[++i]);
    } else if (arg === "--local-image-path") {
      options.localImagePaths.push(argv[++i]);
    } else if (arg === "--model") {
      options.model = argv[++i];
    } else if (arg === "--effort") {
      options.effort = argv[++i];
    } else if (arg === "--permission") {
      options.permission = argv[++i];
    } else if (arg === "--items-json") {
      options.itemsJson = argv[++i];
    } else if (arg === "--limit") {
      options.limit = Number(argv[++i]);
    } else if (arg === "--num-turns") {
      options.numTurns = Number(argv[++i]);
    } else if (arg === "--no-turns") {
      options.includeTurns = false;
    } else {
      positional.push(arg);
    }
  }

  return { command: positional[0] ?? "help", options };
}

class AppServerClient {
  constructor(url, codexBin = DEFAULT_CODEX_BIN) {
    this.url = url;
    this.codexBin = codexBin;
    this.ws = null;
    this.proc = null;
    this.stdioBuffer = "";
    this.stdioDepth = 0;
    this.stdioInString = false;
    this.stdioEscape = false;
    this.nextId = 1;
    this.pending = new Map();
    this.notificationHandler = null;
  }

  async connect(initTimeoutMs = 10000) {
    if (!this.url.startsWith("ws://") && !this.url.startsWith("wss://")) {
      this.#connectStdio();
      const init = await this.request("initialize", {
        clientInfo: { name: "codexmobile-probe", title: "CodexMobile Probe", version: "0.1" },
        capabilities: { experimentalApi: true, optOutNotificationMethods: [] },
      }, initTimeoutMs);
      this.notify("initialized");
      return init;
    }

    this.ws = new WebSocket(this.url);
    this.ws.addEventListener("message", (event) => this.#onMessage(event));
    this.ws.addEventListener("error", (event) => {
      const error = event?.error ?? new Error("websocket error");
      this.#rejectPending(error);
    });
    this.ws.addEventListener("close", (event) => {
      const reason = event?.reason ? `: ${event.reason}` : "";
      this.#rejectPending(new Error(`websocket closed (${event?.code ?? "unknown"})${reason}`));
    });

    await new Promise((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error("connect timeout")), 4000);
      this.ws.addEventListener(
        "open",
        () => {
          clearTimeout(timeout);
          resolve();
        },
        { once: true },
      );
      this.ws.addEventListener(
        "error",
        (event) => {
          clearTimeout(timeout);
          reject(event?.error ?? new Error("websocket error"));
        },
        { once: true },
      );
      this.ws.addEventListener(
        "close",
        (event) => {
          clearTimeout(timeout);
          reject(new Error(`websocket closed before open (${event?.code ?? "unknown"})`));
        },
        { once: true },
      );
    });

    const init = await this.request("initialize", {
      clientInfo: { name: "codexmobile-probe", title: "CodexMobile Probe", version: "0.1" },
      capabilities: { experimentalApi: true, optOutNotificationMethods: [] },
    }, initTimeoutMs);
    this.notify("initialized");
    return init;
  }

  close() {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.close();
    }
    if (this.proc && !this.proc.killed) {
      this.proc.kill("SIGINT");
    }
  }

  onNotification(fn) {
    this.notificationHandler = fn;
  }

  request(method, params, timeoutMs = 30000) {
    const id = this.nextId++;
    const payload = { id, method };
    if (params !== undefined) {
      payload.params = params;
    }
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`${method}: request timeout`));
      }, timeoutMs);
      this.pending.set(id, {
        resolve: (value) => {
          clearTimeout(timeout);
          resolve(value);
        },
        reject: (error) => {
          clearTimeout(timeout);
          reject(error);
        },
        method,
      });
      this.#sendPayload(payload);
    });
  }

  notify(method, params = undefined) {
    const payload = { method };
    if (params !== undefined) {
      payload.params = params;
    }
    this.#sendPayload(payload);
  }

  #connectStdio() {
    const args = ["app-server", "--listen", "stdio://"];
    this.proc = spawn(this.codexBin, args, {
      stdio: ["pipe", "pipe", "pipe"],
    });
    this.proc.on("error", (error) => this.#rejectPending(error));
    this.proc.on("exit", (code, signal) => {
      if (this.pending.size) {
        this.#rejectPending(new Error(`app-server exited (${code ?? signal ?? "unknown"})`));
      }
    });

    this.proc.stdout.on("data", (chunk) => this.#onStdoutData(chunk));

    const stderr = readline.createInterface({ input: this.proc.stderr });
    stderr.on("line", (line) => {
      const raw = String(line || "").trim();
      if (raw) {
        process.stderr.write(`${raw}\n`);
      }
    });
  }

  #sendPayload(payload) {
    if (this.ws) {
      this.ws.send(JSON.stringify(payload));
      return;
    }
    if (!this.proc?.stdin?.writable) {
      throw new Error("app-server stdio is not writable");
    }
    this.proc.stdin.write(`${JSON.stringify(payload)}\n`);
  }

  #onMessage(event) {
    const raw = event.data.toString();
    this.#onRawMessage(raw);
  }

  #onStdoutData(chunk) {
    const text = chunk.toString("utf8");
    for (const char of text) {
      if (this.stdioDepth === 0) {
        if (/\s/.test(char)) {
          continue;
        }
        if (char !== "{") {
          continue;
        }
        this.stdioBuffer = "{";
        this.stdioDepth = 1;
        this.stdioInString = false;
        this.stdioEscape = false;
        continue;
      }

      this.stdioBuffer += char;
      if (this.stdioEscape) {
        this.stdioEscape = false;
        continue;
      }
      if (char === "\\") {
        this.stdioEscape = true;
        continue;
      }
      if (char === "\"") {
        this.stdioInString = !this.stdioInString;
        continue;
      }
      if (this.stdioInString) {
        continue;
      }
      if (char === "{") {
        this.stdioDepth += 1;
      } else if (char === "}") {
        this.stdioDepth -= 1;
        if (this.stdioDepth === 0) {
          const raw = this.stdioBuffer;
          this.stdioBuffer = "";
          this.#onRawMessage(raw);
        }
      }
    }
  }

  #onRawMessage(raw) {
    let msg;
    try {
      msg = JSON.parse(raw);
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      this.#rejectPending(new Error(`invalid app-server json: ${message}`));
      return;
    }
    if (typeof msg.id !== "undefined") {
      const pending = this.pending.get(msg.id);
      if (!pending) {
        return;
      }
      this.pending.delete(msg.id);
      if (msg.error) {
        const message = typeof msg.error === "string"
          ? msg.error
          : msg.error.message ?? JSON.stringify(msg.error);
        pending.reject(new Error(`${pending.method}: ${message}`));
      } else {
        pending.resolve(Object.prototype.hasOwnProperty.call(msg, "result") ? msg.result : msg);
      }
      return;
    }
    if (this.notificationHandler) {
      this.notificationHandler(msg);
    }
  }

  #rejectPending(error) {
    for (const { reject } of this.pending.values()) {
      reject(error);
    }
    this.pending.clear();
  }
}

function startServer(codexBin, url) {
  if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
    return null;
  }
  const proc = spawn(codexBin, ["app-server", "--listen", url], {
    detached: true,
    stdio: ["ignore", "ignore", "ignore"],
  });
  proc.unref();
  return proc;
}

function findThread(threads, options) {
  if (options.threadId) {
    return threads.find((thread) => thread.id === options.threadId) ?? null;
  }
  let filtered = [...threads];
  if (options.cwd) {
    filtered = filtered.filter((thread) => thread.cwd === options.cwd);
  }
  if (options.name) {
    const query = options.name.toLowerCase();
    filtered = filtered.filter((thread) =>
      (thread.name ?? "").toLowerCase().includes(query),
    );
  }
  filtered.sort((a, b) => b.updatedAt - a.updatedAt);
  return filtered[0] ?? null;
}

async function resolveThreadForCommand(client, options) {
  if (options.threadId) {
    const result = await client.request("thread/read", {
      threadId: options.threadId,
      includeTurns: false,
    });
    return result.thread;
  }

  const list = await client.request("thread/list", {
    limit: 50,
    archived: false,
  });
  return findThread(list.data, options);
}

function errorMessage(error) {
  return error instanceof Error ? error.message : String(error);
}

function isThreadNotFoundError(message) {
  return /thread not found|thread\/read:.*not found|no thread/i.test(message);
}

async function resolveReplacementThread(client, options) {
  const fallbackOptions = { ...options, threadId: null };
  const staleThreadId = options.threadId ?? null;
  try {
    const list = await client.request("thread/list", {
      limit: Math.max(Number.isFinite(options.limit) ? options.limit : 50, 100),
      archived: false,
    }, 5000);
    const candidates = Array.isArray(list?.data)
      ? list.data.filter((thread) => !staleThreadId || thread?.id !== staleThreadId)
      : [];
    const found = findThread(candidates, fallbackOptions);
    if (found) {
      return found;
    }
  } catch (error) {
    console.error(`thread/list fallback failed: ${errorMessage(error)}`);
  }

  if (!options.cwd) {
    return null;
  }
  if (options.noStartReplacement) {
    return null;
  }
  return startReplacementThread(client, options);
}

async function startReplacementThread(client, options) {
  if (!options.cwd || options.noStartReplacement) {
    return null;
  }

  const result = await client.request("thread/start", {
    cwd: options.cwd,
  }, 12000);
  return {
    id: result.thread?.id,
    name: result.thread?.name ?? options.name ?? null,
    cwd: result.cwd ?? result.thread?.cwd ?? options.cwd,
    status: result.thread?.status ?? null,
    updatedAt: result.thread?.updatedAt ?? null,
  };
}

function extractAssistantText(turn) {
  const chunks = [];
  for (const item of turn.items ?? []) {
    if (item.type !== "agent_message") {
      continue;
    }
    for (const content of item.message?.content ?? []) {
      if (content.type === "output_text" && content.text) {
        chunks.push(content.text);
      }
    }
  }
  return chunks.join("");
}

function findActiveTurn(thread) {
  const turns = Array.isArray(thread?.turns) ? [...thread.turns] : [];
  const reversed = turns.reverse();
  const terminalStatuses = new Set([
    "completed",
    "failed",
    "cancelled",
    "canceled",
    "interrupted",
  ]);
  const activeTurn = reversed.find((turn) => {
    const status = typeof turn?.status === "string" ? turn.status : turn?.status?.type;
    if (!status) {
      return false;
    }
    if (status === "inProgress" || status === "active") {
      return true;
    }
    return !terminalStatuses.has(status);
  });
  if (activeTurn) {
    return activeTurn;
  }
  const threadStatus = typeof thread?.status === "string" ? thread.status : thread?.status?.type;
  if (threadStatus === "active" && reversed.length) {
    return reversed[0];
  }
  return null;
}

function buildTurnInput(options) {
  const input = [];
  const text = (options.prompt ?? "").trim();
  if (text) {
    input.push({ type: "text", text, text_elements: [] });
  }
  for (const imageDataUrl of options.imageDataUrls ?? []) {
    if (imageDataUrl) {
      input.push({ type: "image", url: imageDataUrl });
    }
  }
  for (const localImagePath of options.localImagePaths ?? []) {
    if (localImagePath) {
      input.push({ type: "localImage", path: localImagePath });
    }
  }
  return input;
}

function normalizeModelList(payload) {
  const data = Array.isArray(payload?.data) ? payload.data : [];
  return data.map((item) => ({
    id: item.id ?? item.value ?? item.model ?? item.name ?? null,
    name: item.name ?? item.label ?? item.id ?? item.value ?? null,
    label: item.label ?? item.name ?? item.id ?? item.value ?? null,
    description: item.description ?? null,
  })).filter((item) => item.id || item.name || item.label);
}

async function readRuntimeState(client, options, init) {
  const [modelsResult, configResult, usageResult, versionResult] = await Promise.all([
    client.request("model/list", {}, 3000).catch((error) => ({
      error: error instanceof Error ? error.message : String(error),
    })),
    client.request("config/read", {}, 3000).catch((error) => ({
      error: error instanceof Error ? error.message : String(error),
    })),
    Promise.all([
      client.request("account/read", { refreshToken: true }, 3000).catch((error) => ({
        error: error instanceof Error ? error.message : String(error),
      })),
      client.request("account/rateLimits/read", {}, 3000).catch((error) => ({
        error: error instanceof Error ? error.message : String(error),
      })),
    ]).then(([account, rateLimits]) => ({ account, rateLimits })),
    new Promise((resolve) => {
      const proc = spawn(options.codexBin, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
      let out = "";
      let err = "";
      proc.stdout.on("data", (buf) => { out += buf.toString(); });
      proc.stderr.on("data", (buf) => { err += buf.toString(); });
      proc.on("close", () => resolve((out || err).trim()));
      proc.on("error", (error) => resolve(error.message));
    }),
  ]);

  let thread = null;
  if (options.threadId || options.cwd || options.name) {
    thread = await resolveThreadForCommand(client, options).catch(() => null);
  }

  return {
    protocol: "codex-app-server-v2",
    codexVersion: versionResult || null,
    initialized: init ?? null,
    capabilities: {
      threadRead: true,
      threadInjectItems: true,
      threadRealtime: true,
      threadRealtimeMethod: "thread/realtime/start",
      turnStart: true,
      turnSteer: true,
      turnInterrupt: true,
      threadRollback: true,
      modelList: !modelsResult?.error,
      configRead: !configResult?.error,
      rateLimits: !usageResult?.rateLimits?.error,
    },
    models: normalizeModelList(modelsResult),
    rawModels: modelsResult?.error ? modelsResult : undefined,
    config: configResult?.error ? null : configResult,
    configError: configResult?.error ?? null,
    account: usageResult?.account ?? null,
    rateLimits: usageResult?.rateLimits ?? null,
    thread: thread
      ? {
          id: thread.id,
          name: thread.name ?? null,
          cwd: thread.cwd ?? null,
          status: thread.status ?? null,
          updatedAt: thread.updatedAt ?? null,
        }
      : null,
  };
}

async function waitForTurn(client, threadId, turnId, silent = false) {
  return await new Promise((resolve, reject) => {
    const timeout = setTimeout(
      () => reject(new Error(`timed out waiting for turn ${turnId}`)),
      120000,
    );
    let text = "";

    client.onNotification((msg) => {
      if (msg.method === "item/agentMessage/delta" && msg.params?.threadId === threadId && msg.params?.turnId === turnId) {
        text += msg.params.delta;
        if (!silent) {
          process.stdout.write(msg.params.delta);
        }
      }
      if (msg.method === "turn/completed" && msg.params?.threadId === threadId && msg.params?.turn?.id === turnId) {
        clearTimeout(timeout);
        if (!text) {
          text = extractAssistantText(msg.params.turn);
          if (text && !silent) {
            process.stdout.write(text);
          }
        }
        if (!silent) {
          process.stdout.write("\n");
        }
        resolve({ turn: msg.params.turn, text });
      }
    });
  });
}

async function nudgeThreadRefresh(client, threadId) {
  try {
    await client.request("thread/read", {
      threadId,
      includeTurns: false,
    }, 10000);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.error(`post-submit thread/read refresh failed: ${message}`);
  }
}

async function main() {
  const { command, options } = parseArgs(process.argv.slice(2));
  let serverProc = null;
  let keepSpawnedServerAlive = false;
  if (options.spawnServer) {
    serverProc = startServer(options.codexBin, options.url);
    await new Promise((resolve) => setTimeout(resolve, 1200));
  }

  const client = new AppServerClient(options.url, options.codexBin);
  try {
    const initTimeoutMs = command === "send" ? 22000 : command === "usage" ? 18000 : 10000;
    const init = await client.connect(initTimeoutMs);
    if (command === "help") {
      console.log("Commands: list, read, materialize, settings, send, start, name, archive, unarchive, fork, interrupt, rollback, usage, runtime, models, config, inject");
      return;
    }
    if (command === "models") {
      const result = await client.request("model/list", {}, 10000);
      console.log(JSON.stringify(result, null, 2));
      return;
    }
    if (command === "config") {
      const result = await client.request("config/read", {}, 10000);
      console.log(JSON.stringify(result, null, 2));
      return;
    }
    if (command === "runtime") {
      const result = await readRuntimeState(client, options, init);
      console.log(JSON.stringify(result, null, 2));
      return;
    }
    if (command === "usage") {
      const [account, rateLimits] = await Promise.all([
        client.request("account/read", { refreshToken: true }, 8000).catch((error) => ({
          error: error instanceof Error ? error.message : String(error),
        })),
        client.request("account/rateLimits/read", {}, 8000).catch((error) => ({
          error: error instanceof Error ? error.message : String(error),
        })),
      ]);
      console.log(JSON.stringify({ account, rateLimits }, null, 2));
      return;
    }
    if (command === "list") {
      const result = await client.request("thread/list", {
        limit: options.limit,
        archived: options.archived,
      }, 5000);
      console.log(JSON.stringify(result, null, 2));
      return;
    }

    if (command === "start") {
      const startParams = {
        cwd: options.cwd,
      };
      if (options.model) {
        startParams.model = options.model;
      }
      if (options.effort) {
        startParams.effort = options.effort;
      }
      if (options.permission === "fullAccess") {
        startParams.approvalPolicy = "never";
        startParams.sandboxPolicy = { type: "dangerFullAccess" };
      }
      const result = await client.request("thread/start", startParams);
      console.log(JSON.stringify({
        threadId: result.thread.id,
        threadName: result.thread.name ?? null,
        cwd: result.cwd ?? result.thread.cwd ?? null,
      }, null, 2));
      return;
    }

    if (command === "fork") {
      const thread = await resolveThreadForCommand(client, options);
      if (!thread) {
        throw new Error("thread not found with the current filters");
      }
      const result = await client.request("thread/fork", {
        threadId: thread.id,
        cwd: options.cwd ?? thread.cwd ?? null,
        excludeTurns: true,
      }, 20000);
      console.log(JSON.stringify({
        threadId: result.thread.id,
        threadName: result.thread.name ?? null,
        cwd: result.cwd ?? result.thread.cwd ?? null,
        forkedFromId: result.thread.forkedFromId ?? thread.id,
      }, null, 2));
      return;
    }

    let thread = command === "send" && options.noWait && options.threadId
      ? { id: options.threadId, name: null, cwd: options.cwd ?? null, status: null, updatedAt: null }
      : await resolveThreadForCommand(client, options);
    if (!thread) {
      throw new Error("thread not found with the current filters");
    }

    if (command === "read") {
      const result = await client.request("thread/read", {
        threadId: thread.id,
        includeTurns: options.includeTurns,
      }, 8000);
      console.log(JSON.stringify({ init, thread: result.thread }, null, 2));
      return;
    }

    if (command === "materialize") {
      let materialized = false;
      let resumeError = null;
      try {
        await client.request("thread/resume", {
          threadId: thread.id,
          persistExtendedHistory: true,
        }, 10000);
        materialized = true;
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        if (message.includes("no rollout found")) {
          materialized = true;
        } else {
          resumeError = message;
        }
      }
      const result = await client.request("thread/read", {
        threadId: thread.id,
        includeTurns: false,
      }, 12000);
      console.log(JSON.stringify({
        init,
        thread: result.thread,
        materialized,
        resumeError,
      }, null, 2));
      return;
    }

    if (command === "name") {
      if (!options.name || !String(options.name).trim()) {
        throw new Error("--name is required for name");
      }
      await client.request("thread/name/set", {
        threadId: thread.id,
        name: String(options.name).trim(),
      }, 10000);
      const result = await client.request("thread/read", {
        threadId: thread.id,
        includeTurns: false,
      }, 8000);
      console.log(JSON.stringify({
        threadId: result.thread.id,
        threadName: result.thread.name ?? String(options.name).trim(),
        cwd: result.thread.cwd ?? thread.cwd ?? null,
      }, null, 2));
      return;
    }

    if (command === "archive") {
      await client.request("thread/archive", {
        threadId: thread.id,
      }, 10000);
      console.log(JSON.stringify({
        ok: true,
        threadId: thread.id,
        threadName: thread.name ?? null,
        cwd: thread.cwd ?? null,
        archived: true,
      }, null, 2));
      return;
    }

    if (command === "unarchive") {
      await client.request("thread/unarchive", {
        threadId: thread.id,
      }, 10000);
      const result = await client.request("thread/read", {
        threadId: thread.id,
        includeTurns: false,
      }, 8000).catch(() => ({ thread }));
      console.log(JSON.stringify({
        ok: true,
        threadId: result.thread?.id ?? thread.id,
        threadName: result.thread?.name ?? thread.name ?? null,
        cwd: result.thread?.cwd ?? thread.cwd ?? null,
        archived: false,
      }, null, 2));
      return;
    }

    if (command === "settings") {
      let resume = {};
      let resumeUnavailable = false;
      try {
        resume = await client.request("thread/resume", {
          threadId: thread.id,
          persistExtendedHistory: false,
        }, 1500);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        if (!message.includes("no rollout found")) {
          throw error;
        }
        resumeUnavailable = true;
      }
      const approvalPolicy = resume.approvalPolicy ?? null;
      const sandbox = resume.sandbox ?? null;
      const sandboxText = sandbox ? JSON.stringify(sandbox).toLowerCase() : "";
      const hasFullAccess =
        approvalPolicy === "never"
        || sandboxText.includes("danger")
        || sandboxText.includes("fullaccess")
        || sandboxText.includes("full-access")
        || sandboxText.includes("full_access");
      console.log(JSON.stringify({
        threadId: thread.id,
        threadName: thread.name ?? null,
        cwd: thread.cwd ?? null,
        model: resume.model ?? null,
        effort: resume.reasoningEffort ?? null,
        approvalPolicy,
        sandbox,
        permission: hasFullAccess ? "fullAccess" : "default",
        resumeUnavailable,
        source: resumeUnavailable ? "codex-resume-unavailable" : "codex-resume",
      }, null, 2));
      return;
    }

    if (command === "interrupt") {
      const result = await client.request("thread/read", {
        threadId: thread.id,
        includeTurns: true,
      });
      const activeTurn = findActiveTurn(result.thread);
      if (!activeTurn) {
        if (options.json) {
          console.log(JSON.stringify({
            threadId: thread.id,
            threadName: thread.name ?? null,
            interrupted: false,
            reason: "no-active-turn",
          }, null, 2));
        } else {
          console.log("No active turn to interrupt.");
        }
        return;
      }
      await client.request("turn/interrupt", {
        threadId: thread.id,
        turnId: activeTurn.id,
      });
      if (options.json) {
        console.log(JSON.stringify({
          threadId: thread.id,
          threadName: thread.name ?? null,
          interrupted: true,
          turnId: activeTurn.id,
        }, null, 2));
      } else {
        console.log(`Interrupted turn ${activeTurn.id} on thread ${thread.id}`);
      }
      return;
    }

    if (command === "rollback") {
      const numTurns = Math.max(1, Number.isFinite(options.numTurns) ? Math.floor(options.numTurns) : 1);
      const result = await client.request("thread/rollback", {
        threadId: thread.id,
        numTurns,
      });
      if (options.json) {
        console.log(JSON.stringify({
          ok: true,
          threadId: result.thread?.id ?? thread.id,
          threadName: result.thread?.name ?? thread.name ?? null,
          cwd: result.thread?.cwd ?? thread.cwd ?? null,
          numTurns,
          updatedAt: result.thread?.updatedAt ?? null,
        }, null, 2));
      } else {
        console.log(`Rolled back ${numTurns} turn(s) on thread ${thread.id}`);
      }
      return;
    }

    if (command === "inject") {
      if (!options.itemsJson) {
        throw new Error("--items-json is required for inject");
      }
      let items;
      try {
        items = JSON.parse(options.itemsJson);
      } catch (error) {
        throw new Error(`invalid --items-json: ${error instanceof Error ? error.message : String(error)}`);
      }
      if (!Array.isArray(items)) {
        throw new Error("--items-json must be a JSON array");
      }
      const result = await client.request("thread/inject_items", {
        threadId: thread.id,
        items,
      });
      console.log(JSON.stringify({ ok: true, threadId: thread.id, result }, null, 2));
      return;
    }

    if (command === "send") {
      const turnInput = buildTurnInput(options);
      if (!turnInput.length) {
        throw new Error("--prompt, --image-data-url, or --local-image-path is required for send");
      }
      if (!options.noWait) {
        try {
          await client.request("thread/resume", {
            threadId: thread.id,
            persistExtendedHistory: false,
          }, 5000);
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          if (!message.includes("no rollout found")) {
            console.error(`pre-send thread/resume skipped: ${message}`);
          }
        }
      }
      let activeTurn = null;
      if (!options.noWait) {
        try {
          const current = await client.request("thread/read", {
            threadId: thread.id,
            includeTurns: true,
          }, 8000);
          activeTurn = findActiveTurn(current.thread);
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          console.error(`pre-send thread/read skipped: ${message}`);
        }
      }
      if (activeTurn) {
        try {
          await client.request("turn/steer", {
            threadId: thread.id,
            expectedTurnId: activeTurn.id,
            input: turnInput,
          });
          await nudgeThreadRefresh(client, thread.id);
          if (options.json) {
            console.log(JSON.stringify({
              threadId: thread.id,
              threadName: thread.name ?? null,
              cwd: thread.cwd ?? null,
              turnId: activeTurn.id,
              status: "steered",
              accepted: true,
              text: "",
            }, null, 2));
          } else {
            console.log(`Steered active turn ${activeTurn.id} on thread ${thread.id}`);
          }
          return;
        } catch (error) {
          const message = error instanceof Error ? error.message : String(error);
          const foundActiveTurnId = message.match(/found `([^`]+)`/)?.[1];
          if (foundActiveTurnId && foundActiveTurnId !== activeTurn.id) {
            try {
              await client.request("turn/steer", {
                threadId: thread.id,
                expectedTurnId: foundActiveTurnId,
                input: turnInput,
              });
              await nudgeThreadRefresh(client, thread.id);
              if (options.json) {
                console.log(JSON.stringify({
                  threadId: thread.id,
                  threadName: thread.name ?? null,
                  cwd: thread.cwd ?? null,
                  turnId: foundActiveTurnId,
                  status: "steered",
                  accepted: true,
                  text: "",
                }, null, 2));
              } else {
                console.log(`Steered active turn ${foundActiveTurnId} on thread ${thread.id}`);
              }
              return;
            } catch (retryError) {
              const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
              console.error(`turn/steer retry failed, falling back to turn/start: ${retryMessage}`);
            }
          }
          console.error(`turn/steer failed, falling back to turn/start: ${message}`);
        }
      }
      const startParams = {
        threadId: thread.id,
        input: turnInput,
      };
      if (options.model) {
        startParams.model = options.model;
      }
      if (options.effort) {
        startParams.effort = options.effort;
      }
      if (options.permission === "fullAccess") {
        startParams.approvalPolicy = "never";
        startParams.sandboxPolicy = { type: "dangerFullAccess" };
      }
      let started;
      try {
        started = await client.request("turn/start", startParams);
      } catch (error) {
        let startFailure = error;
        let message = errorMessage(startFailure);
        if (options.noWait && isThreadNotFoundError(message)) {
          console.error(`turn/start target thread is stale, resolving replacement: ${message}`);
          const replacement = await resolveReplacementThread(client, options);
          if (replacement?.id) {
            thread = replacement;
            startParams.threadId = thread.id;
            try {
              started = await client.request("turn/start", startParams);
              startFailure = null;
            } catch (retryError) {
              const retryMessage = errorMessage(retryError);
              if (isThreadNotFoundError(retryMessage)) {
                const freshThread = await startReplacementThread(client, options);
                if (freshThread?.id) {
                  thread = freshThread;
                  startParams.threadId = thread.id;
                  try {
                    started = await client.request("turn/start", startParams);
                    startFailure = null;
                  } catch (freshStartError) {
                    startFailure = freshStartError;
                    message = errorMessage(freshStartError);
                  }
                } else {
                  startFailure = retryError;
                  message = retryMessage;
                }
              } else {
                startFailure = retryError;
                message = retryMessage;
              }
            }
          }
        }
        if (!startFailure) {
          // The replacement thread accepted the turn.
        } else {
        const foundActiveTurnId = message.match(/found `([^`]+)`/)?.[1];
        if (options.noWait && foundActiveTurnId) {
          await client.request("turn/steer", {
            threadId: thread.id,
            expectedTurnId: foundActiveTurnId,
            input: turnInput,
          });
          await nudgeThreadRefresh(client, thread.id);
          keepSpawnedServerAlive = Boolean(serverProc);
          if (options.json) {
            console.log(JSON.stringify({
              threadId: thread.id,
              threadName: thread.name ?? null,
              cwd: thread.cwd ?? null,
              turnId: foundActiveTurnId,
              status: "steered",
              accepted: true,
              text: "",
            }, null, 2));
          } else {
            console.log(`Steered active turn ${foundActiveTurnId} on thread ${thread.id}`);
          }
          return;
        }
        if (options.noWait && /active|turn/i.test(message)) {
          const current = await client.request("thread/read", {
            threadId: thread.id,
            includeTurns: true,
          }, 12000).catch(() => null);
          const fallbackActiveTurn = findActiveTurn(current?.thread);
          if (fallbackActiveTurn) {
            await client.request("turn/steer", {
              threadId: thread.id,
              expectedTurnId: fallbackActiveTurn.id,
              input: turnInput,
            });
            await nudgeThreadRefresh(client, thread.id);
            keepSpawnedServerAlive = Boolean(serverProc);
            if (options.json) {
              console.log(JSON.stringify({
                threadId: thread.id,
                threadName: thread.name ?? null,
                cwd: thread.cwd ?? null,
                turnId: fallbackActiveTurn.id,
                status: "steered",
                accepted: true,
                text: "",
              }, null, 2));
            } else {
              console.log(`Steered active turn ${fallbackActiveTurn.id} on thread ${thread.id}`);
            }
            return;
          }
        }
        throw startFailure;
        }
      }
      console.error(`TURN ${started.turn.id} started on thread ${thread.id}`);
      if (options.noWait) {
        await nudgeThreadRefresh(client, thread.id);
        keepSpawnedServerAlive = Boolean(serverProc);
        if (options.json) {
          console.log(JSON.stringify({
            threadId: thread.id,
            threadName: thread.name ?? null,
            cwd: thread.cwd ?? null,
            turnId: started.turn.id,
            status: "started",
            accepted: true,
            text: "",
          }, null, 2));
        } else {
          console.log(`Started turn ${started.turn.id} on thread ${thread.id}`);
        }
        return;
      }
      const completed = await waitForTurn(client, thread.id, started.turn.id, options.json);
      console.error(`TURN ${completed.turn.id} completed with status=${completed.turn.status}`);
      try {
        await client.request("thread/resume", {
          threadId: thread.id,
          persistExtendedHistory: true,
        });
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        if (!message.includes("no rollout found")) {
          console.error(`post-send thread/resume failed: ${message}`);
        }
      }
      try {
        await client.request("thread/read", {
          threadId: thread.id,
          includeTurns: false,
        });
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.error(`post-send thread/read failed: ${message}`);
      }
      if (options.json) {
        console.log(JSON.stringify({
          threadId: thread.id,
          threadName: thread.name ?? null,
          cwd: thread.cwd ?? null,
          turnId: completed.turn.id,
          status: completed.turn.status,
          text: completed.text,
        }, null, 2));
      }
      return;
    }

    throw new Error(`unknown command: ${command}`);
  } finally {
    client.close();
    if (serverProc && !keepSpawnedServerAlive) {
      serverProc.kill("SIGINT");
    }
  }
}

main().catch((error) => {
  console.error(error.stack || error.message);
  process.exit(1);
});
