import { fork } from 'child_process';
import { randomBytes } from 'crypto';
import * as fs from 'fs';
import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
import * as path from 'path';
import path__default from 'path';
import { fileURLToPath } from 'url';
import * as net from 'net';
import Database from 'better-sqlite3';

class HolderClient {
  connected = false;
  pid = 0;
  activityCb = null;
  cwdCb = null;
  disconnectCb = null;
  exitCb = null;
  lineBuf = "";
  outputCb = null;
  socket = null;
  /**
   * Connect to a holder process via its Unix domain socket.
   * Resolves once the initial `info` and `scrollback` handshake messages
   * have been received.
   */
  connect(socketPath) {
    return new Promise((resolve, reject) => {
      let settled = false;
      let info = null;
      let scrollback = "";
      const pendingMessages = [];
      const HANDSHAKE_TIMEOUT_MS = 1e4;
      const handshakeTimer = setTimeout(() => {
        if (!settled) {
          settled = true;
          this.connected = false;
          if (this.socket) {
            this.socket.destroy();
            this.socket = null;
          }
          reject(
            new Error(`Handshake timeout: holder did not respond within ${HANDSHAKE_TIMEOUT_MS}ms`)
          );
        }
      }, HANDSHAKE_TIMEOUT_MS);
      const socket = net.createConnection(socketPath);
      this.socket = socket;
      socket.setEncoding("utf8");
      socket.on("connect", () => {
        this.connected = true;
      });
      socket.on("data", (chunk) => {
        this.lineBuf += chunk;
        const lines = this.lineBuf.split("\n");
        this.lineBuf = lines.pop();
        for (const line of lines) {
          if (line.length === 0) {
            continue;
          }
          let msg;
          try {
            msg = JSON.parse(line);
          } catch {
            continue;
          }
          if (!settled) {
            if (msg.type === "info") {
              info = { exitCode: msg.exitCode, exited: msg.exited, pid: msg.pid };
              this.pid = msg.pid;
            } else if (msg.type === "scrollback") {
              scrollback = msg.data;
            } else {
              pendingMessages.push(msg);
            }
            if (info !== null) {
              const settle = () => {
                if (settled) {
                  return;
                }
                settled = true;
                clearTimeout(handshakeTimer);
                resolve({
                  exitCode: info.exitCode,
                  exited: info.exited,
                  pid: info.pid,
                  scrollback
                });
                const queued = [...pendingMessages];
                pendingMessages.length = 0;
                setTimeout(() => {
                  for (const pending of queued) {
                    this.handleMessage(pending);
                  }
                }, 0);
              };
              if (msg.type === "scrollback") {
                settle();
              } else if (msg.type === "info") {
                setTimeout(settle, 100);
              }
            }
          } else {
            this.handleMessage(msg);
          }
        }
      });
      socket.on("error", (err) => {
        if (!settled) {
          settled = true;
          clearTimeout(handshakeTimer);
          this.connected = false;
          this.socket = null;
          reject(err);
        }
      });
      socket.on("close", () => {
        const wasConnected = this.connected;
        this.connected = false;
        this.socket = null;
        this.lineBuf = "";
        if (!settled) {
          settled = true;
          clearTimeout(handshakeTimer);
          reject(new Error("Socket closed before handshake completed"));
          return;
        }
        if (wasConnected && this.disconnectCb) {
          this.disconnectCb();
        }
      });
    });
  }
  /** Gracefully disconnect from the holder (does NOT kill the holder). */
  disconnect() {
    this.connected = false;
    if (this.socket) {
      this.socket.destroy();
      this.socket = null;
    }
    this.lineBuf = "";
  }
  /** Send a signal to the PTY process (default SIGTERM). */
  kill(signal) {
    const msg = { type: "kill" };
    if (signal) {
      msg.signal = signal;
    }
    this.send(msg);
  }
  /** Register callback for activity state changes. */
  onActivity(cb) {
    this.activityCb = cb;
  }
  /** Register callback for CWD changes. */
  onCwd(cb) {
    this.cwdCb = cb;
  }
  /** Register callback for unexpected disconnect from holder. */
  onDisconnect(cb) {
    this.disconnectCb = cb;
  }
  /** Register callback for PTY exit. */
  onExit(cb) {
    this.exitCb = cb;
  }
  /** Register callback for PTY output data. */
  onOutput(cb) {
    this.outputCb = cb;
  }
  /** Resize the PTY. */
  resize(cols, rows) {
    this.send({ cols, rows, type: "resize" });
  }
  /** Write data to the PTY stdin. */
  write(data) {
    this.send({ data, type: "input" });
  }
  // ── Private Helpers ────────────────────────────────────────────────
  /** Dispatch a post-handshake message from the holder. */
  handleMessage(msg) {
    switch (msg.type) {
      case "activity":
        if (this.activityCb) {
          this.activityCb(msg.active);
        }
        break;
      case "cwd":
        if (this.cwdCb) {
          this.cwdCb(msg.path);
        }
        break;
      case "exit":
        if (this.exitCb) {
          this.exitCb(msg.code);
        }
        break;
      case "output":
        if (this.outputCb) {
          this.outputCb(msg.data);
        }
        break;
    }
  }
  /** Send an ndjson message to the holder. */
  send(msg) {
    if (!this.socket || !this.connected) {
      return;
    }
    this.socket.write(`${JSON.stringify(msg)}
`);
  }
}
const OPENCODE_DB_PATH = (() => {
  if (process.platform === "darwin") {
    return path.join(
      process.env.HOME || "",
      "Library",
      "Application Support",
      "opencode",
      "opencode.db"
    );
  }
  const xdgData = process.env.XDG_DATA_HOME || path.join(process.env.HOME || "", ".local", "share");
  return path.join(xdgData, "opencode", "opencode.db");
})();
const POLL_INTERVAL_MS = 2e3;
const SQLITE_MAX_PARAMS = 500;
class OpenCodeWatcher {
  watchers = /* @__PURE__ */ new Map();
  /**
   * Find the most recent non-archived OpenCode session that matches
   * the given working directory. Checks session.directory equals or
   * starts with `cwd`.
   *
   * Returns the session ID or null if none found.
   */
  findSessionId(cwd, createdAfter) {
    const db = openDb();
    if (!db) {
      return null;
    }
    try {
      const millisFilter = createdAfter ?? 0;
      const secondsFilter = createdAfter ? Math.floor(createdAfter / 1e3) : 0;
      const cwdLikePattern = `${cwd.replace(/[%_\\]/g, "\\$&")}/%`;
      const row = db.prepare(
        `
				SELECT id
				FROM session
				WHERE (time_archived IS NULL OR time_archived = 0)
				  AND (directory = ? OR directory LIKE ? ESCAPE '\\')
				  AND (
				    (time_updated >= 1e12 AND time_updated > ?)
				    OR (time_updated < 1e12 AND time_updated > ?)
				  )
				ORDER BY time_updated DESC
				LIMIT 1
			`
      ).get(cwd, cwdLikePattern, millisFilter, secondsFilter);
      return row?.id ?? null;
    } catch (error) {
      console.error("[opencode-watcher] Failed to find session:", error);
      return null;
    } finally {
      db.close();
    }
  }
  /**
   * Read all messages and parts for a session from SQLite, converting
   * them to ConversationMessage format.
   */
  getHistory(sessionId) {
    const db = openDb();
    if (!db) {
      return [];
    }
    try {
      const messages = db.prepare(
        `
				SELECT id, session_id, time_created, time_updated, data
				FROM message
				WHERE session_id = ?
				ORDER BY time_created ASC
			`
      ).all(sessionId);
      if (messages.length === 0) {
        return [];
      }
      const messageIds = messages.map((m) => m.id);
      const parts = batchInQuery(
        db,
        `SELECT id, message_id, session_id, time_created, time_updated, data
				FROM part
				WHERE message_id IN (__PLACEHOLDERS__)
				ORDER BY time_created ASC`,
        messageIds
      );
      const partsByMessage = /* @__PURE__ */ new Map();
      for (const part of parts) {
        if (!partsByMessage.has(part.message_id)) {
          partsByMessage.set(part.message_id, []);
        }
        partsByMessage.get(part.message_id).push(part);
      }
      return this.buildMessages(messages, partsByMessage);
    } catch (error) {
      console.error("[opencode-watcher] Failed to read history:", error);
      return [];
    } finally {
      db.close();
    }
  }
  /**
   * Stop watching a specific session. If a callback is provided, only that
   * subscriber is removed — the interval keeps running while other subscribers
   * remain. If no callback is provided, all subscribers and the interval are
   * cleared (backward compat).
   */
  stop(sessionId, callback) {
    const state = this.watchers.get(sessionId);
    if (!state) {
      return;
    }
    if (callback) {
      state.callbacks.delete(callback);
      console.log(
        `[opencode-watcher] Removed subscriber from session: ${sessionId} (remaining=${state.callbacks.size})`
      );
      if (state.callbacks.size > 0) {
        return;
      }
    }
    clearInterval(state.intervalHandle);
    this.watchers.delete(sessionId);
    console.log(`[opencode-watcher] Stopped watching session: ${sessionId}`);
  }
  /**
   * Stop all active watchers.
   */
  stopAll() {
    for (const [sessionId] of this.watchers) {
      this.stop(sessionId);
    }
  }
  /**
   * Start polling the SQLite DB every 2 seconds for new messages/parts
   * in the given session. Converts new data to ConversationMessage
   * format and invokes the callback.
   */
  watch(sessionId, callback) {
    const existing = this.watchers.get(sessionId);
    if (existing) {
      existing.callbacks.add(callback);
      console.log(
        `[opencode-watcher] Added subscriber to session: ${sessionId} (total=${existing.callbacks.size})`
      );
      return;
    }
    const { emittedMessageIds, emittedPartUpdatedAt, lastMessageTime, lastPartTime } = this.getHighWaterMarks(sessionId);
    const intervalHandle = setInterval(() => {
      this.poll(sessionId);
    }, POLL_INTERVAL_MS);
    const state = {
      callbacks: /* @__PURE__ */ new Set([callback]),
      emittedMessageIds,
      emittedPartUpdatedAt,
      intervalHandle,
      lastMessageTime,
      lastPartTime,
      sessionId
    };
    this.watchers.set(sessionId, state);
    console.log(
      `[opencode-watcher] Watching session: ${sessionId} (lastMsg=${lastMessageTime}, lastPart=${lastPartTime})`
    );
  }
  // ── Private Helpers ────────────────────────────────────────────────
  /**
   * Convert OpenCode messages + their parts into ConversationMessage
   * objects for consumption by the session handler.
   */
  buildMessages(messages, partsByMessage) {
    const result = [];
    for (const msg of messages) {
      let msgData = {};
      try {
        msgData = JSON.parse(msg.data);
      } catch {
        continue;
      }
      const role = msgData.role === "user" ? "user" : "assistant";
      const msgParts = partsByMessage.get(msg.id) || [];
      const parts = [];
      for (const part of msgParts) {
        let partData;
        try {
          partData = JSON.parse(part.data);
        } catch {
          continue;
        }
        const converted = convertPartToMessagePart(partData);
        if (converted) {
          parts.push(converted);
        }
      }
      if (parts.length === 0) {
        console.debug(`[opencode-watcher] Skipping message ${msg.id} (no usable parts)`);
        continue;
      }
      result.push({
        id: msg.id,
        parts,
        role,
        timestamp: new Date(toMillis(msg.time_created)).toISOString()
      });
    }
    return result;
  }
  /**
   * Scan existing messages/parts to determine the starting high-water
   * marks for time-based polling. Also collects the set of already-seen
   * message IDs so we do not re-emit them.
   */
  getHighWaterMarks(sessionId) {
    const db = openDb();
    if (!db) {
      return {
        emittedMessageIds: /* @__PURE__ */ new Set(),
        emittedPartUpdatedAt: /* @__PURE__ */ new Map(),
        lastMessageTime: 0,
        lastPartTime: 0
      };
    }
    try {
      const msgRow = db.prepare(
        `
				SELECT MAX(time_created) as maxTime
				FROM message
				WHERE session_id = ?
			`
      ).get(sessionId);
      const partRow = db.prepare(
        `
				SELECT MAX(time_updated) as maxTime
				FROM part
				WHERE session_id = ?
			`
      ).get(sessionId);
      const existingIds = db.prepare(
        `
				SELECT id FROM message WHERE session_id = ?
			`
      ).all(sessionId);
      const emittedMessageIds = new Set(existingIds.map((r) => r.id));
      const existingParts = db.prepare(
        `
				SELECT id, time_updated FROM part WHERE session_id = ?
			`
      ).all(sessionId);
      const emittedPartUpdatedAt = /* @__PURE__ */ new Map();
      for (const p of existingParts) {
        emittedPartUpdatedAt.set(p.id, p.time_updated);
      }
      return {
        emittedMessageIds,
        emittedPartUpdatedAt,
        lastMessageTime: msgRow?.maxTime ?? 0,
        lastPartTime: partRow?.maxTime ?? 0
      };
    } catch (error) {
      console.error("[opencode-watcher] Failed to get high-water marks:", error);
      return {
        emittedMessageIds: /* @__PURE__ */ new Set(),
        emittedPartUpdatedAt: /* @__PURE__ */ new Map(),
        lastMessageTime: 0,
        lastPartTime: 0
      };
    } finally {
      db.close();
    }
  }
  /**
   * Single poll iteration. Opens the DB, queries for new messages and
   * updated parts, converts them to ConversationMessage[], and invokes
   * the watcher callbacks.
   */
  poll(sessionId) {
    const state = this.watchers.get(sessionId);
    if (!state) {
      return;
    }
    const db = openDb();
    if (!db) {
      return;
    }
    try {
      const results = [];
      const newMessages = db.prepare(
        `
				SELECT id, session_id, time_created, time_updated, data
				FROM message
				WHERE session_id = ? AND time_created > ?
				ORDER BY time_created ASC
			`
      ).all(sessionId, state.lastMessageTime);
      if (newMessages.length > 0) {
        const dedupedMessages = newMessages.filter((m) => !state.emittedMessageIds.has(m.id));
        if (dedupedMessages.length > 0) {
          const newMsgIds = dedupedMessages.map((m) => m.id);
          const newParts = batchInQuery(
            db,
            `SELECT id, message_id, session_id, time_created, time_updated, data
						FROM part
						WHERE message_id IN (__PLACEHOLDERS__)
						ORDER BY time_created ASC`,
            newMsgIds
          );
          const partsByMessage = /* @__PURE__ */ new Map();
          for (const part of newParts) {
            if (!partsByMessage.has(part.message_id)) {
              partsByMessage.set(part.message_id, []);
            }
            partsByMessage.get(part.message_id).push(part);
          }
          const newEntries = this.buildMessages(dedupedMessages, partsByMessage);
          results.push(...newEntries);
          for (const part of newParts) {
            if (part.time_updated > state.lastPartTime) {
              state.lastPartTime = part.time_updated;
            }
            state.emittedPartUpdatedAt.set(part.id, part.time_updated);
          }
        }
        for (const msg of newMessages) {
          if (msg.time_created > state.lastMessageTime) {
            state.lastMessageTime = msg.time_created;
          }
          state.emittedMessageIds.add(msg.id);
        }
      }
      const updatedParts = db.prepare(
        `
				SELECT id, message_id, session_id, time_created, time_updated, data
				FROM part
				WHERE session_id = ? AND time_updated > ?
				ORDER BY time_created ASC
			`
      ).all(sessionId, state.lastPartTime);
      if (updatedParts.length > 0) {
        const newMsgIdSet = new Set(newMessages.map((m) => m.id));
        const newParts = updatedParts.filter((p) => {
          if (newMsgIdSet.has(p.message_id)) {
            return false;
          }
          const lastSeen = state.emittedPartUpdatedAt.get(p.id);
          return lastSeen === void 0 || p.time_updated > lastSeen;
        });
        if (newParts.length > 0) {
          const partsByMessage = /* @__PURE__ */ new Map();
          for (const part of newParts) {
            if (!partsByMessage.has(part.message_id)) {
              partsByMessage.set(part.message_id, []);
            }
            partsByMessage.get(part.message_id).push(part);
          }
          const affectedMsgIds = [...partsByMessage.keys()];
          const affectedMessages = batchInQuery(
            db,
            `SELECT id, session_id, time_created, time_updated, data
						FROM message
						WHERE id IN (__PLACEHOLDERS__)
						ORDER BY time_created ASC`,
            affectedMsgIds
          );
          const updatedEntries = this.buildMessages(affectedMessages, partsByMessage);
          results.push(...updatedEntries);
          for (const part of newParts) {
            state.emittedPartUpdatedAt.set(part.id, part.time_updated);
          }
        }
        for (const part of updatedParts) {
          if (part.time_updated > state.lastPartTime) {
            state.lastPartTime = part.time_updated;
          }
        }
      }
      if (results.length > 0) {
        for (const cb of state.callbacks) {
          try {
            cb(results);
          } catch (cbError) {
            console.error("[opencode-watcher] Callback error:", cbError);
          }
        }
      }
    } catch (error) {
      console.error("[opencode-watcher] Poll error:", error);
    } finally {
      db.close();
    }
  }
}
function batchInQuery(db, sql, ids) {
  if (ids.length === 0) {
    return [];
  }
  const results = [];
  for (let i = 0; i < ids.length; i += SQLITE_MAX_PARAMS) {
    const chunk = ids.slice(i, i + SQLITE_MAX_PARAMS);
    const placeholders = chunk.map(() => "?").join(",");
    const query = sql.replace("__PLACEHOLDERS__", placeholders);
    const rows = db.prepare(query).all(...chunk);
    results.push(...rows);
  }
  return results;
}
function convertPartToMessagePart(data) {
  switch (data.type) {
    case "reasoning":
      return { content: data.text || "", type: "thinking" };
    case "text":
      return { content: data.text || "", type: "text" };
    case "tool":
      return {
        id: data.callID || data.id || "",
        input: data.state?.input || {},
        toolName: data.tool || "Unknown",
        type: "tool_use"
      };
    default:
      return null;
  }
}
function openDb() {
  if (!fs.existsSync(OPENCODE_DB_PATH)) {
    return null;
  }
  try {
    return new Database(OPENCODE_DB_PATH, { readonly: true });
  } catch {
    return null;
  }
}
function toMillis(timestamp) {
  return timestamp < 1e12 ? timestamp * 1e3 : timestamp;
}
const OW_GLOBAL_KEY = "__shooter_opencode_watcher";
const openCodeWatcher = globalThis[OW_GLOBAL_KEY] || new OpenCodeWatcher();
globalThis[OW_GLOBAL_KEY] = openCodeWatcher;
const DB_DIR = path.join(process.env.HOME || "", ".shooter");
const DB_PATH = path.join(DB_DIR, "shooter.db");
const COLUMNS = [
  "id",
  "command",
  "args",
  "cwd",
  "cols",
  "rows",
  "pid",
  "holder_pid",
  "socket_path",
  "session_file",
  "opencode_session_id",
  "status",
  "exit_code",
  "created_at",
  "exited_at"
];
function rowToRecord(row) {
  return {
    args: row.args,
    cols: row.cols,
    command: row.command,
    createdAt: row.created_at,
    cwd: row.cwd,
    exitCode: row.exit_code ?? null,
    exitedAt: row.exited_at ?? null,
    holderPid: row.holder_pid ?? null,
    id: row.id,
    opencodeSessionId: row.opencode_session_id ?? null,
    pid: row.pid ?? null,
    rows: row.rows,
    sessionFile: row.session_file ?? null,
    socketPath: row.socket_path ?? null,
    status: row.status
  };
}
const CAMEL_TO_SNAKE = {
  createdAt: "created_at",
  exitCode: "exit_code",
  exitedAt: "exited_at",
  holderPid: "holder_pid",
  opencodeSessionId: "opencode_session_id",
  sessionFile: "session_file",
  socketPath: "socket_path"
};
class TerminalStore {
  db;
  constructor() {
    fs.mkdirSync(DB_DIR, { recursive: true });
    this.db = new Database(DB_PATH);
    this.db.pragma("journal_mode = WAL");
    this.db.exec(`
			CREATE TABLE IF NOT EXISTS terminals (
				id                    TEXT PRIMARY KEY,
				command               TEXT NOT NULL,
				args                  TEXT NOT NULL DEFAULT '[]',
				cwd                   TEXT NOT NULL,
				cols                  INTEGER NOT NULL DEFAULT 80,
				rows                  INTEGER NOT NULL DEFAULT 24,
				pid                   INTEGER,
				holder_pid            INTEGER,
				socket_path           TEXT,
				session_file          TEXT,
				opencode_session_id   TEXT,
				status                TEXT NOT NULL DEFAULT 'running',
				exit_code             INTEGER,
				created_at            TEXT NOT NULL,
				exited_at             TEXT
			)
		`);
  }
  deleteOlderThan(ms) {
    const cutoff = new Date(Date.now() - ms).toISOString();
    const result = this.db.prepare(
      "DELETE FROM terminals WHERE status IN ('exited', 'orphaned') AND COALESCE(exited_at, created_at) < ?"
    ).run(cutoff);
    return result.changes;
  }
  get(id) {
    const row = this.db.prepare("SELECT * FROM terminals WHERE id = ?").get(id);
    return row ? rowToRecord(row) : null;
  }
  insert(terminal) {
    const placeholders = COLUMNS.map(() => "?").join(", ");
    const stmt = this.db.prepare(
      `INSERT INTO terminals (${COLUMNS.join(", ")}) VALUES (${placeholders})`
    );
    stmt.run(
      terminal.id,
      terminal.command,
      terminal.args,
      terminal.cwd,
      terminal.cols,
      terminal.rows,
      terminal.pid,
      terminal.holderPid,
      terminal.socketPath,
      terminal.sessionFile,
      terminal.opencodeSessionId,
      terminal.status,
      terminal.exitCode,
      terminal.createdAt,
      terminal.exitedAt
    );
  }
  listAll() {
    const rows = this.db.prepare("SELECT * FROM terminals ORDER BY created_at DESC").all();
    return rows.map(rowToRecord);
  }
  listRunning() {
    const rows = this.db.prepare("SELECT * FROM terminals WHERE status = 'running' ORDER BY created_at DESC").all();
    return rows.map(rowToRecord);
  }
  markExited(id, exitCode) {
    this.db.prepare("UPDATE terminals SET status = 'exited', exit_code = ?, exited_at = ? WHERE id = ?").run(exitCode, (/* @__PURE__ */ new Date()).toISOString(), id);
  }
  markOrphaned(id) {
    this.db.prepare(
      "UPDATE terminals SET status = 'orphaned', exited_at = COALESCE(exited_at, ?) WHERE id = ?"
    ).run((/* @__PURE__ */ new Date()).toISOString(), id);
  }
  update(id, fields) {
    const entries = Object.entries(fields).filter(
      ([key, val]) => key !== "id" && val !== void 0
    );
    if (entries.length === 0) {
      return;
    }
    const sets = entries.map(([key]) => `${toSnake(key)} = ?`).join(", ");
    const values = entries.map(([, val]) => val ?? null);
    this.db.prepare(`UPDATE terminals SET ${sets} WHERE id = ?`).run(...values, id);
  }
}
function toSnake(key) {
  return CAMEL_TO_SNAKE[key] || key;
}
const TS_GLOBAL_KEY = "__shooter_terminal_store";
const terminalStore = globalThis[TS_GLOBAL_KEY] || new TerminalStore();
globalThis[TS_GLOBAL_KEY] = terminalStore;
const MAX_SCROLLBACK_BYTES = 512 * 1024;
const MAX_OUTPUT_BUFFER_BYTES = 1024 * 1024;
const SCROLLBACK_CHUNK_SIZE = 50 * 1024;
const CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
const EXITED_TTL_MS = 60 * 60 * 1e3;
const MAX_EXITED_TERMINALS = 10;
const SIGKILL_DELAY_MS = 5e3;
const HOLDER_READY_TIMEOUT_MS = 5e3;
const DB_CLEANUP_TTL_MS = 24 * 60 * 60 * 1e3;
const __filename$1 = fileURLToPath(import.meta.url);
const __dirname$1 = path__default.dirname(__filename$1);
class PtyManager {
  cleanupTimer = null;
  terminals = /* @__PURE__ */ new Map();
  constructor() {
    this.cleanupTimer = setInterval(() => {
      this.cleanup();
    }, CLEANUP_INTERVAL_MS);
  }
  // -----------------------------------------------------------------------
  // create — now async: forks a holder process, connects via HolderClient,
  //          persists to SQLite
  // -----------------------------------------------------------------------
  attach(id, ws) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return false;
    }
    terminal.clients.add(ws);
    terminal.outputBuffers.set(ws, { data: [], size: 0 });
    void this.sendScrollback(terminal, ws);
    return true;
  }
  // -----------------------------------------------------------------------
  // reconnectAll — recover persisted terminals on server startup
  // -----------------------------------------------------------------------
  cleanup() {
    const now = Date.now();
    const exited = [];
    for (const [id, terminal] of this.terminals) {
      if (terminal.status !== "exited") {
        continue;
      }
      const exitTime = terminal.exitedAt?.getTime() ?? terminal.createdAt.getTime();
      if (now - exitTime > EXITED_TTL_MS) {
        this.evict(id);
        continue;
      }
      exited.push({ exitedAt: exitTime, id });
    }
    if (exited.length > MAX_EXITED_TERMINALS) {
      exited.sort((a, b) => a.exitedAt - b.exitedAt);
      const toEvict = exited.slice(0, exited.length - MAX_EXITED_TERMINALS);
      for (const { id } of toEvict) {
        this.evict(id);
      }
    }
    try {
      const deleted = terminalStore.deleteOlderThan(DB_CLEANUP_TTL_MS);
      if (deleted > 0) {
        console.log(`[pty-manager] Cleaned up ${deleted} old terminal record(s) from SQLite`);
      }
    } catch {
    }
  }
  // -----------------------------------------------------------------------
  // disconnectAll — graceful shutdown: disconnect clients, keep holders alive
  // -----------------------------------------------------------------------
  async create(command, args, cwd, cols, rows) {
    const id = randomBytes(4).toString("hex");
    const socketPath = `/tmp/shooter-term-${id}.sock`;
    const holderScript = resolveHolderPath();
    const holderArgs = [id, socketPath, cwd, String(cols), String(rows), command, ...args];
    const holder = fork(holderScript, holderArgs, {
      detached: true,
      stdio: ["ignore", "ignore", "ignore", "ipc"]
    });
    holder.unref();
    await new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        holder.kill();
        reject(new Error("Holder ready timeout"));
      }, HOLDER_READY_TIMEOUT_MS);
      holder.on("message", (msg) => {
        if (msg.type === "ready") {
          clearTimeout(timeout);
          holder.disconnect();
          resolve();
        }
      });
      holder.on("error", (err) => {
        clearTimeout(timeout);
        reject(new Error(`Holder process error: ${err.message}`));
      });
      holder.on("exit", (code) => {
        clearTimeout(timeout);
        reject(new Error(`Holder process exited with code ${code} before ready`));
      });
    });
    const holderPid = holder.pid;
    const client = new HolderClient();
    const connectResult = await client.connect(socketPath);
    const now = /* @__PURE__ */ new Date();
    const terminal = {
      args,
      clients: /* @__PURE__ */ new Set(),
      cols,
      command,
      createdAt: now,
      currentCwd: null,
      cwd,
      exitCode: connectResult.exitCode,
      exitedAt: null,
      holderPid,
      id,
      isActive: false,
      openCodeNoopCb: null,
      openCodeSessionId: null,
      outputBuffers: /* @__PURE__ */ new Map(),
      pid: connectResult.pid,
      pollTimer: null,
      pty: client,
      rows,
      scrollback: connectResult.scrollback,
      sessionFile: null,
      socketPath,
      status: connectResult.exited ? "exited" : "running",
      watcherOffset: 0
    };
    this.wireHolderCallbacks(client, terminal);
    terminalStore.insert({
      args: JSON.stringify(args),
      cols,
      command,
      createdAt: now.toISOString(),
      cwd,
      exitCode: null,
      exitedAt: null,
      holderPid,
      id,
      opencodeSessionId: null,
      pid: connectResult.pid,
      rows,
      sessionFile: null,
      socketPath,
      status: "running"
    });
    this.startSessionDiscovery(terminal);
    this.terminals.set(id, terminal);
    return terminal;
  }
  // -----------------------------------------------------------------------
  // get
  // -----------------------------------------------------------------------
  destroy() {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }
    for (const [id, terminal] of this.terminals) {
      if (terminal.pollTimer) {
        clearInterval(terminal.pollTimer);
        terminal.pollTimer = null;
      }
      if (terminal.status === "running") {
        try {
          terminal.pty.kill("SIGTERM");
        } catch {
        }
        try {
          process.kill(terminal.holderPid, "SIGKILL");
        } catch {
        }
      }
      terminal.pty.disconnect();
      for (const ws of terminal.clients) {
        try {
          ws.close();
        } catch {
        }
      }
      this.terminals.delete(id);
    }
  }
  // -----------------------------------------------------------------------
  // list — running first, then recently exited, each group sorted by
  //        createdAt descending
  // -----------------------------------------------------------------------
  detach(id, ws) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return false;
    }
    terminal.clients.delete(ws);
    terminal.outputBuffers.delete(ws);
    return true;
  }
  // -----------------------------------------------------------------------
  // kill — route through holder: SIGTERM, then SIGKILL after 5 s
  // -----------------------------------------------------------------------
  disconnectAll() {
    if (this.cleanupTimer) {
      clearInterval(this.cleanupTimer);
      this.cleanupTimer = null;
    }
    for (const [, terminal] of this.terminals) {
      if (terminal.pollTimer) {
        clearInterval(terminal.pollTimer);
        terminal.pollTimer = null;
      }
      terminal.pty.disconnect();
      for (const ws of terminal.clients) {
        try {
          ws.close();
        } catch {
        }
      }
      terminal.clients.clear();
      terminal.outputBuffers.clear();
    }
    this.terminals.clear();
  }
  // -----------------------------------------------------------------------
  // remove — remove an exited terminal from the map
  // -----------------------------------------------------------------------
  get(id) {
    return this.terminals.get(id) ?? null;
  }
  // -----------------------------------------------------------------------
  // resize
  // -----------------------------------------------------------------------
  getScrollback(id) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return null;
    }
    return terminal.scrollback;
  }
  // -----------------------------------------------------------------------
  // attach — register a WebSocket client and replay scrollback
  // -----------------------------------------------------------------------
  kill(id) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return false;
    }
    if (terminal.status === "exited") {
      return true;
    }
    try {
      terminal.pty.kill("SIGTERM");
    } catch {
      terminal.status = "exited";
      terminal.exitedAt = /* @__PURE__ */ new Date();
      terminalStore.markExited(id, null);
      return true;
    }
    setTimeout(() => {
      if (terminal.status === "running") {
        try {
          terminal.pty.kill("SIGKILL");
        } catch {
        }
        terminal.status = "exited";
        terminal.exitedAt = terminal.exitedAt ?? /* @__PURE__ */ new Date();
        terminalStore.markExited(id, null);
      }
    }, SIGKILL_DELAY_MS);
    return true;
  }
  // -----------------------------------------------------------------------
  // detach — remove a WebSocket client
  // -----------------------------------------------------------------------
  list() {
    const all = Array.from(this.terminals.values());
    const running = all.filter((t) => t.status === "running").sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
    const exited = all.filter((t) => t.status === "exited").sort((a, b) => {
      const aTime = a.exitedAt?.getTime() ?? a.createdAt.getTime();
      const bTime = b.exitedAt?.getTime() ?? b.createdAt.getTime();
      return bTime - aTime;
    });
    return [...running, ...exited];
  }
  // -----------------------------------------------------------------------
  // getScrollback — return raw scrollback data for replay
  // -----------------------------------------------------------------------
  async reconnectAll() {
    const running = terminalStore.listRunning();
    if (running.length === 0) {
      console.log("[pty-manager] No persisted terminals to reconnect");
      return;
    }
    console.log(`[pty-manager] Reconnecting to ${running.length} persisted terminal(s)...`);
    for (const record of running) {
      try {
        await this.reconnectOne(record);
      } catch (err) {
        const errMsg = err instanceof Error ? err.message : String(err);
        console.warn(`[pty-manager] Failed to reconnect terminal ${record.id}: ${errMsg}`);
        this.handleReconnectFailure(record);
      }
    }
  }
  // -----------------------------------------------------------------------
  // cleanup — evict exited terminals older than 1 hour, cap at 10 exited;
  //           also clean up old SQLite records
  // -----------------------------------------------------------------------
  remove(id) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return false;
    }
    if (terminal.status === "running") {
      return false;
    }
    this.evict(id);
    return true;
  }
  // -----------------------------------------------------------------------
  // destroy — emergency forced kill (kills holder processes too)
  // -----------------------------------------------------------------------
  resize(id, cols, rows) {
    const terminal = this.terminals.get(id);
    if (!terminal || terminal.status === "exited") {
      return false;
    }
    try {
      terminal.pty.resize(cols, rows);
      terminal.cols = cols;
      terminal.rows = rows;
      return true;
    } catch {
      return false;
    }
  }
  // -----------------------------------------------------------------------
  // Private: reconnectOne — reconnect to a single persisted terminal
  // -----------------------------------------------------------------------
  appendScrollback(terminal, data) {
    terminal.scrollback += data;
    if (Buffer.byteLength(terminal.scrollback, "utf8") > MAX_SCROLLBACK_BYTES) {
      const mid = Math.floor(terminal.scrollback.length / 2);
      const newlineIdx = terminal.scrollback.indexOf("\n", mid);
      if (newlineIdx !== -1) {
        terminal.scrollback = terminal.scrollback.slice(newlineIdx + 1);
      } else {
        terminal.scrollback = terminal.scrollback.slice(mid);
      }
    }
  }
  // -----------------------------------------------------------------------
  // Private: handleReconnectFailure — handle failed reconnection
  // -----------------------------------------------------------------------
  broadcastOutput(terminal, data) {
    const msg = JSON.stringify({ data, type: "output" });
    for (const ws of terminal.clients) {
      if (ws.bufferedAmount > MAX_OUTPUT_BUFFER_BYTES) {
        this.safeSend(ws, JSON.stringify({ bytes: data.length, type: "output-dropped" }));
        continue;
      }
      const buffer = terminal.outputBuffers.get(ws);
      if (!buffer) {
        continue;
      }
      const msgSize = Buffer.byteLength(msg, "utf8");
      if (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES) {
        let droppedBytes = 0;
        while (buffer.size + msgSize > MAX_OUTPUT_BUFFER_BYTES && buffer.data.length > 0) {
          const dropped = buffer.data.shift();
          if (dropped) {
            const droppedSize = Buffer.byteLength(dropped, "utf8");
            buffer.size -= droppedSize;
            droppedBytes += droppedSize;
          }
        }
        if (droppedBytes > 0) {
          const dropMsg = JSON.stringify({
            bytes: droppedBytes,
            type: "output-dropped"
          });
          this.safeSend(ws, dropMsg);
        }
      }
      buffer.data.push(msg);
      buffer.size += msgSize;
      this.flushOutputBuffer(ws, buffer);
    }
  }
  // -----------------------------------------------------------------------
  // Private: startSessionDiscovery — polling for session files
  // -----------------------------------------------------------------------
  /** Evict a terminal, freeing all resources. */
  evict(id) {
    const terminal = this.terminals.get(id);
    if (!terminal) {
      return;
    }
    if (terminal.pollTimer) {
      clearInterval(terminal.pollTimer);
      terminal.pollTimer = null;
    }
    if (terminal.openCodeSessionId && terminal.openCodeNoopCb) {
      openCodeWatcher.stop(terminal.openCodeSessionId, terminal.openCodeNoopCb);
      terminal.openCodeNoopCb = null;
    }
    terminal.pty.disconnect();
    for (const ws of terminal.clients) {
      try {
        ws.close();
      } catch {
      }
    }
    terminal.clients.clear();
    terminal.outputBuffers.clear();
    terminal.scrollback = "";
    this.terminals.delete(id);
  }
  // -----------------------------------------------------------------------
  // Private: appendScrollback — append to cached scrollback string,
  //          trim from midpoint when cap exceeded
  // -----------------------------------------------------------------------
  /** Attempt to flush buffered messages to a WebSocket client. */
  flushOutputBuffer(ws, buffer) {
    while (buffer.data.length > 0) {
      const msg = buffer.data[0];
      if (!this.safeSend(ws, msg)) {
        break;
      }
      buffer.data.shift();
      buffer.size -= Buffer.byteLength(msg, "utf8");
    }
  }
  // -----------------------------------------------------------------------
  // Private: broadcastOutput — send output to all connected WS clients
  //          with backpressure management
  // -----------------------------------------------------------------------
  handleReconnectFailure(record) {
    if (record.holderPid) {
      try {
        process.kill(record.holderPid, 0);
        console.warn(
          `[pty-manager] Holder PID ${record.holderPid} alive but socket dead for ${record.id}`
        );
      } catch {
      }
    }
    if (record.socketPath) {
      const exitFilePath = `${record.socketPath}.exit`;
      if (existsSync(exitFilePath)) {
        try {
          const exitData = JSON.parse(readFileSync(exitFilePath, "utf8"));
          unlinkSync(exitFilePath);
          terminalStore.markExited(record.id, exitData.code);
          console.log(
            `[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
          );
          return;
        } catch {
        }
      }
    }
    terminalStore.markOrphaned(record.id);
    console.log(`[pty-manager] Marked terminal ${record.id} as orphaned`);
  }
  async reconnectOne(record) {
    if (!record.socketPath) {
      throw new Error("No socket path stored");
    }
    const exitFilePath = `${record.socketPath}.exit`;
    if (existsSync(exitFilePath)) {
      try {
        const exitData = JSON.parse(readFileSync(exitFilePath, "utf8"));
        unlinkSync(exitFilePath);
        terminalStore.markExited(record.id, exitData.code);
        console.log(
          `[pty-manager] Terminal ${record.id} exited while disconnected (code=${exitData.code})`
        );
        return;
      } catch {
      }
    }
    const client = new HolderClient();
    const connectResult = await client.connect(record.socketPath);
    let parsedArgs = [];
    try {
      parsedArgs = JSON.parse(record.args);
    } catch {
    }
    const terminal = {
      args: parsedArgs,
      clients: /* @__PURE__ */ new Set(),
      cols: record.cols,
      command: record.command,
      createdAt: new Date(record.createdAt),
      currentCwd: null,
      cwd: record.cwd,
      exitCode: connectResult.exitCode,
      exitedAt: record.exitedAt ? new Date(record.exitedAt) : null,
      holderPid: record.holderPid ?? 0,
      id: record.id,
      isActive: false,
      openCodeNoopCb: null,
      openCodeSessionId: record.opencodeSessionId ?? null,
      outputBuffers: /* @__PURE__ */ new Map(),
      pid: connectResult.pid,
      pollTimer: null,
      pty: client,
      rows: record.rows,
      scrollback: connectResult.scrollback,
      sessionFile: record.sessionFile ?? null,
      socketPath: record.socketPath,
      status: connectResult.exited ? "exited" : "running",
      watcherOffset: 0
    };
    if (connectResult.exited) {
      terminal.exitedAt = terminal.exitedAt ?? /* @__PURE__ */ new Date();
      terminalStore.markExited(record.id, connectResult.exitCode);
    }
    this.wireHolderCallbacks(client, terminal);
    if (terminal.sessionFile) ;
    if (terminal.openCodeSessionId) {
      const noopCb = (_messages) => {
      };
      terminal.openCodeNoopCb = noopCb;
      openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
    }
    if (!terminal.sessionFile && !terminal.openCodeSessionId && terminal.status === "running") {
      this.startSessionDiscovery(terminal);
    }
    if (record.pid !== connectResult.pid) {
      terminalStore.update(record.id, { pid: connectResult.pid });
    }
    this.terminals.set(record.id, terminal);
    console.log(
      `[pty-manager] Reconnected terminal ${record.id} (pid=${connectResult.pid}, holder=${record.holderPid}, status=${terminal.status})`
    );
  }
  /** Safely send a message to a WebSocket, returning false on failure. */
  safeSend(ws, msg) {
    try {
      if (ws.readyState !== 1) {
        return false;
      }
      ws.send(msg);
      return true;
    } catch {
      return false;
    }
  }
  /** Send cached scrollback data to a newly connected client in 50 KB chunks. */
  async sendScrollback(terminal, ws) {
    const fullData = terminal.scrollback;
    if (fullData.length === 0) {
      return;
    }
    const totalBytes = Buffer.byteLength(fullData, "utf8");
    const totalChunks = Math.ceil(totalBytes / SCROLLBACK_CHUNK_SIZE);
    if (totalChunks <= 1) {
      const msg = JSON.stringify({
        chunk: 1,
        data: fullData,
        total: 1,
        type: "scrollback"
      });
      this.safeSend(ws, msg);
      return;
    }
    const buf = Buffer.from(fullData, "utf8");
    let offset = 0;
    let chunkIndex = 1;
    while (offset < buf.length) {
      if (ws.bufferedAmount > SCROLLBACK_CHUNK_SIZE * 2) {
        await new Promise((resolve) => setTimeout(resolve, 50));
      }
      const end = Math.min(offset + SCROLLBACK_CHUNK_SIZE, buf.length);
      const chunkData = buf.subarray(offset, end).toString("utf8");
      const msg = JSON.stringify({
        chunk: chunkIndex,
        data: chunkData,
        total: totalChunks,
        type: "scrollback"
      });
      this.safeSend(ws, msg);
      offset = end;
      chunkIndex++;
    }
  }
  startSessionDiscovery(terminal) {
    const { command, cwd, id } = terminal;
    if (command === "claude") {
      const projectDir = path__default.join(
        process.env.HOME || "",
        ".claude",
        "projects",
        cwd.replace(/\//g, "-")
      );
      const launchTime = terminal.createdAt.getTime();
      terminal.pollTimer = setInterval(() => {
        if (terminal.status === "exited" || terminal.sessionFile) {
          if (terminal.pollTimer) {
            clearInterval(terminal.pollTimer);
            terminal.pollTimer = null;
          }
          if (terminal.sessionFile) ;
          return;
        }
        try {
          if (!existsSync(projectDir)) {
            return;
          }
          const files = readdirSync(projectDir).filter((f) => f.endsWith(".jsonl")).map((f) => {
            const stat = statSync(path__default.join(projectDir, f));
            return {
              birthtime: stat.birthtimeMs,
              fullPath: path__default.join(projectDir, f),
              mtime: stat.mtimeMs,
              name: f
            };
          }).filter((f) => f.birthtime > launchTime).sort((a, b) => b.birthtime - a.birthtime);
          if (files.length > 0) {
            terminal.sessionFile = files[0].fullPath;
            if (terminal.pollTimer) {
              clearInterval(terminal.pollTimer);
              terminal.pollTimer = null;
            }
            terminalStore.update(id, { sessionFile: terminal.sessionFile });
          }
        } catch {
        }
      }, 1500);
      setTimeout(
        () => {
          if (terminal.pollTimer) {
            clearInterval(terminal.pollTimer);
            terminal.pollTimer = null;
          }
        },
        5 * 60 * 1e3
      );
    }
    if (command === "opencode") {
      const launchTime = terminal.createdAt.getTime();
      const pollInterval = setInterval(() => {
        if (terminal.status === "exited" || terminal.openCodeSessionId) {
          clearInterval(pollInterval);
          if (terminal.openCodeSessionId) {
            const noopCb = (_messages) => {
            };
            terminal.openCodeNoopCb = noopCb;
            openCodeWatcher.watch(terminal.openCodeSessionId, noopCb);
          }
          return;
        }
        const sessionId = openCodeWatcher.findSessionId(cwd, launchTime);
        if (sessionId) {
          terminal.openCodeSessionId = sessionId;
          clearInterval(pollInterval);
          const noopCb = (_messages) => {
          };
          terminal.openCodeNoopCb = noopCb;
          openCodeWatcher.watch(sessionId, noopCb);
          terminalStore.update(id, { opencodeSessionId: sessionId });
        }
      }, 2e3);
      terminal.pollTimer = pollInterval;
      setTimeout(
        () => {
          clearInterval(pollInterval);
          terminal.pollTimer = null;
        },
        5 * 60 * 1e3
      );
    }
  }
  /** Wire up all HolderClient callbacks (activity, CWD, output, exit, disconnect). */
  wireHolderCallbacks(client, terminal) {
    client.onActivity((active) => {
      terminal.isActive = active;
      const msg = JSON.stringify({ active, type: "activity" });
      for (const ws of terminal.clients) {
        this.safeSend(ws, msg);
      }
    });
    client.onCwd((path2) => {
      terminal.currentCwd = path2;
      const msg = JSON.stringify({ path: path2, type: "cwd" });
      for (const ws of terminal.clients) {
        this.safeSend(ws, msg);
      }
    });
    client.onOutput((data) => {
      this.appendScrollback(terminal, data);
      this.broadcastOutput(terminal, data);
    });
    client.onExit((exitCode) => {
      terminal.status = "exited";
      terminal.exitCode = exitCode;
      terminal.exitedAt = /* @__PURE__ */ new Date();
      terminalStore.markExited(terminal.id, exitCode);
      const exitMsg = JSON.stringify({
        code: exitCode,
        signal: null,
        type: "exit"
      });
      for (const ws of terminal.clients) {
        this.safeSend(ws, exitMsg);
      }
    });
    client.onDisconnect(() => {
      if (terminal.status === "running") {
        console.warn(`[pty-manager] Holder disconnected unexpectedly for terminal ${terminal.id}`);
        terminal.status = "exited";
        terminal.exitedAt = /* @__PURE__ */ new Date();
        terminalStore.markOrphaned(terminal.id);
      }
    });
  }
}
function resolveHolderPath() {
  if (process.env.SHOOTER_HOLDER_PATH) {
    return process.env.SHOOTER_HOLDER_PATH;
  }
  const colocated = path__default.join(__dirname$1, "pty-holder.cjs");
  if (existsSync(colocated)) {
    return colocated;
  }
  return path__default.resolve(__dirname$1, "..", "..", "pty-holder.cjs");
}
const PTY_GLOBAL_KEY = "__shooter_pty_manager";
const ptyManager = globalThis[PTY_GLOBAL_KEY] || new PtyManager();
globalThis[PTY_GLOBAL_KEY] = ptyManager;

export { ptyManager as p };
//# sourceMappingURL=pty-manager-DR0Wt2Ac.js.map
