/**
 * Terminal Server — WebSocket PTY backend for terminal canvas widgets.
 *
 * Uses tmux for session persistence across page refreshes. Each terminal
 * widget gets a tmux session with an opaque name (hash of branch + canvas +
 * widget). On disconnect the pty process is killed (detaching from tmux)
 * but the tmux session stays alive. On reconnect the existing tmux session
 * is reattached.
 *
 * Session lifecycle is managed by terminal-registry.js which persists
 * session metadata to `.storyboard/terminal-sessions.json`.
 *
 * Falls back to direct shell spawn when tmux is not available.
 *
 * Dev-only — this runs inside the Vite dev server, same trust model.
 *
 * Protocol:
 *   Client → Server:  text (stdin to PTY)
 *   Client → Server:  JSON { type: "resize", cols, rows }
 *   Server → Client:  text (stdout from PTY)
 *   Server → Client:  JSON { type: "conflict", ... }
 *   Server → Client:  JSON { type: "session-info", ... }
 */

import { execSync } from 'node:child_process'
import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs'
import { resolve, join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { tmpdir } from 'node:os'
import { devLog } from '../logger/devLogger.js'

let WebSocketServer
try {
  WebSocketServer = (await import('ws')).WebSocketServer
} catch {
  WebSocketServer = null
}
import {
  initRegistry,
  registerSession,
  disconnectSession,
  orphanSession,
  generateTmuxName,
  findTmuxNameForWidget,
  killSession,
  bulkCleanup,
  getSessionStats,
} from './terminal-registry.js'
import {
  writeTerminalConfig as writeTermConfig,
  initTerminalConfig,
  readTerminalConfigById,
  getLastAgentSession,
  recordAgentSession,
} from './terminal-config.js'
import { findByWorktree } from '../worktree/serverRegistry.js'
import { detectWorktreeName } from '../worktree/port.js'
import { bindWidget, unbindWidget } from '../messaging/delivery.js'
import { joinPresence, leavePresence } from '../messaging/presence.js'
import {
  buildResumeStartupCommand,
  watchSessionIdFile,
  captureFilePath,
  ensureCopilotCaptureHookInstalled,
  ensureClaudeCaptureHookInstalled,
  ensureCodexCaptureHookInstalled,
} from './agent-session.js'

let pty
try {
  pty = await import('node-pty')
} catch {
  pty = null
}

/** Check if tmux is available on the system */
let hasTmux = false
try {
  execSync('which tmux', { stdio: 'ignore' })
  hasTmux = true
} catch {
  hasTmux = false
}

const TERMINAL_PATH_PREFIX = '/_storyboard/terminal/'

// Resolve the absolute path to the storyboard CLI executable. Used when
// generating commands that we send into shells via tmux send-keys — we
// can't rely on `storyboard` being on the user's PATH (their .zshrc may
// reset it after the tmux env override applies).
function resolveStoryboardCli() {
  try {
    const here = dirname(fileURLToPath(import.meta.url))
    // terminal-server.js lives at packages/storyboard/src/core/canvas/
    // CLI entry is at packages/storyboard/src/core/cli/index.js
    const cliPath = resolve(here, '..', 'cli', 'index.js')
    if (existsSync(cliPath)) return cliPath
  } catch { /* fall through */ }
  return 'storyboard'
}
const STORYBOARD_CLI = resolveStoryboardCli()
const STORYBOARD_CLI_CMD = STORYBOARD_CLI === 'storyboard'
  ? 'storyboard'
  : `${process.execPath} ${JSON.stringify(STORYBOARD_CLI)}`
/**
 * Env var prefixes/names from external terminal emulators and shell configs
 * that must be stripped before spawning tmux or shell processes — they leak
 * custom theming, prompts, and shell integrations into the storyboard terminal.
 */
const SHELL_CONFIG_STRIP_RE = /^(ZDOTDIR|STARSHIP(_.*)?|GHOSTTY(_.*)?|POWERLEVEL.*|P9K_.*|P10K_.*|ZSH_THEME|BASH_ENV|ITERM(_.*)?|KITTY(_.*)?|ALACRITTY(_.*)?|WEZTERM(_.*)?|PROMPT_COMMAND|RPROMPT|RPS1)$/

function isShellConfigVar(key) {
  return SHELL_CONFIG_STRIP_RE.test(key) || key === 'ENV'
}

/**
 * Overrides injected into tmux global env to neutralize external shell themes.
 * Applied after the tmux server is guaranteed to exist.
 */
const TMUX_SHELL_OVERRIDES = {
  STARSHIP_CONFIG: '/dev/null',
  POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true',
  ZSH_THEME: '',
  TERM_PROGRAM: 'storyboard',
  COLORTERM: 'truecolor',
  FORCE_COLOR: '3',
}

/** Env vars that suppress color in supports-color based CLIs — must be unset in tmux global env */
const TMUX_COLOR_SUPPRESS_UNSET = ['CI', 'NO_COLOR', 'GITHUB_ACTIONS']

/** Apply shell-config overrides to the tmux server's global environment */
function applyTmuxShellOverrides() {
  for (const [key, val] of Object.entries(TMUX_SHELL_OVERRIDES)) {
    try { execSync(`tmux set-environment -g ${key} "${val}" 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
  }
  // Unset vars that should not exist at all inside storyboard terminals
  for (const key of Object.keys(process.env)) {
    if (isShellConfigVar(key) && !(key in TMUX_SHELL_OVERRIDES)) {
      try { execSync(`tmux set-environment -g -u ${key} 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
    }
  }
  // Unset color-suppressing vars from tmux global env
  for (const key of TMUX_COLOR_SUPPRESS_UNSET) {
    try { execSync(`tmux set-environment -g -u ${key} 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
  }
}

/** Filter process.env, removing shell-config vars that would leak into PTY */
/** Env vars that suppress color output in supports-color based CLIs */
const COLOR_SUPPRESS_VARS = new Set(['CI', 'NO_COLOR', 'GITHUB_ACTIONS'])

function cleanEnv() {
  const filtered = {}
  for (const [k, v] of Object.entries(process.env)) {
    if (!isShellConfigVar(k) && !COLOR_SUPPRESS_VARS.has(k)) filtered[k] = v
  }
  return filtered
}

import { readTerminalSettings, readAgentsConfig } from './configReader.js'

/** Read terminal-widget settings (merged from lib defaults + storyboard.config.json + terminal.config.json). */
function readTerminalConfig() {
  return readTerminalSettings()
}

/**
 * Resolve a startup command to a configured canvas agent entry.
 * Returns `{ id, cfg }` or null when no match is found.
 */
function resolveAgentConfig(startupCommand) {
  if (!startupCommand || startupCommand === 'shell') return null
  const agentsConfig = readAgentsConfig()
  if (!agentsConfig || typeof agentsConfig !== 'object') return null
  for (const [id, cfg] of Object.entries(agentsConfig)) {
    if (!cfg?.startupCommand) continue
    // Prefer exact/prefix match for deterministic routing; keep binary fallback for backwards compat.
    if (
      startupCommand === cfg.startupCommand
      || startupCommand.startsWith(`${cfg.startupCommand} `)
      || startupCommand.startsWith(cfg.startupCommand.split(' ')[0])
    ) {
      return { id, cfg }
    }
  }
  return null
}

/** Active PTY processes keyed by tmuxName (not tmux sessions — those persist independently) */
const ptyProcesses = new Map()

/** WebSocket connections keyed by tmuxName, for conflict notification */
const wsConnections = new Map()

/** Branch name for this worktree, set during setup */
let currentBranch = 'unknown'

/** Actual server port, resolved from httpServer at setup time */
let actualServerPort = null

/** Vite base path (e.g. "/branch--0.5.0/"), captured from setupTerminalServer */
let actualBase = '/'

/** httpServer reference, used to recompute port if it wasn't bound at setup time */
let actualHttpServer = null

/** Hot pool manager reference (set by setupTerminalServer) */
let hotPoolRef = null

// ── PTY exhaustion detection & recovery ──

const PTY_ERROR_PATTERNS = [
  /ENXIO/, /posix_openpt/, /Device not configured/,
  /no available pty/i, /too many pty/i, /out of pty/i,
]

function isPtyExhausted(err) {
  const msg = err?.message || ''
  return PTY_ERROR_PATTERNS.some(p => p.test(msg))
}

/**
 * Spawn a PTY process with automatic cleanup on PTY exhaustion.
 * On failure: kills archived sessions → retries → kills background → retries → throws.
 * If all cleanup attempts fail, throws an error with `err.resourceLimited = true`
 * and `err.stats` containing session counts.
 */
function spawnWithCleanup(command, args, opts) {
  try {
    return pty.spawn(command, args, opts)
  } catch (err) {
    if (!isPtyExhausted(err)) throw err

    devLog().logEvent('warn', 'PTY exhaustion detected, attempting cleanup', { error: err.message })

    // Wave 1: clean archived sessions
    const wave1 = bulkCleanup({ statuses: ['archived'] })
    if (wave1.removed > 0) {
      devLog().logEvent('info', `Cleaned ${wave1.removed} archived sessions, retrying spawn`)
      try { return pty.spawn(command, args, opts) } catch (e) {
        if (!isPtyExhausted(e)) throw e
      }
    }

    // Wave 2: clean background sessions
    const wave2 = bulkCleanup({ statuses: ['background'] })
    if (wave2.removed > 0) {
      devLog().logEvent('info', `Cleaned ${wave2.removed} background sessions, retrying spawn`)
      try { return pty.spawn(command, args, opts) } catch (e) {
        if (!isPtyExhausted(e)) throw e
      }
    }

    // All cleanup exhausted — throw with resource-limited metadata
    const resourceErr = new Error('No PTY devices available — all cleanup attempts exhausted')
    resourceErr.resourceLimited = true
    resourceErr.stats = getSessionStats()
    throw resourceErr
  }
}

/** Active snapshot intervals keyed by tmuxName */
const snapshotIntervals = new Map()

/**
 * Time-windowed rolling buffer — accumulates raw PTY output with timestamps
 * so we can trim by age (5 min for private buffer, 1 min for public snapshot).
 * Each entry is { ts: number, data: string }.
 */
const rollingBuffers = new Map()

/** Max buffer age in ms (5 minutes for private buffer) */
const BUFFER_MAX_AGE_MS = 5 * 60 * 1000

/** Max snapshot age in ms (1 minute for public snapshot) */
const SNAPSHOT_MAX_AGE_MS = 1 * 60 * 1000

/** Append PTY output to the rolling buffer for a session */
function appendToRollingBuffer(tmuxName, data) {
  let entries = rollingBuffers.get(tmuxName)
  if (!entries) {
    entries = []
    rollingBuffers.set(tmuxName, entries)
  }
  entries.push({ ts: Date.now(), data })
  // Eagerly trim entries older than the max (buffer cap = 5 min)
  const cutoff = Date.now() - BUFFER_MAX_AGE_MS
  while (entries.length > 0 && entries[0].ts < cutoff) {
    entries.shift()
  }
}

/** Get concatenated buffer content within a time window */
function getRollingBufferContent(tmuxName, maxAgeMs = BUFFER_MAX_AGE_MS) {
  const entries = rollingBuffers.get(tmuxName)
  if (!entries || entries.length === 0) return ''
  const cutoff = Date.now() - maxAgeMs
  return entries
    .filter((e) => e.ts >= cutoff)
    .map((e) => e.data)
    .join('')
}

/** Strip ANSI escape sequences from a string */
function stripAnsi(str) {
  // eslint-disable-next-line no-control-regex
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[>=<]|\x1b\[[?]?[0-9;]*[hlsur]/g, '')
}

/**
 * Send text into a tmux pane and confirm it submitted.
 *
 * The naive `send-keys -l <text>; send-keys Enter` pattern is racy against
 * TUI agents (Copilot, Claude) that are still initializing their input
 * widget when we fire — the text lands in the prompt box but the Enter
 * is eaten before submit. We work around it by capturing the pane after
 * each send and re-sending Enter (up to N times) until the unique tail
 * of our text is no longer visible (= input was cleared = submitted).
 *
 * Synchronous on purpose (matches existing send-keys helpers).
 */
function tmuxSubmit(tmuxName, text, { retries = 5, delayMs = 250 } = {}) {
  if (!hasTmux || !text) return
  // Pick a needle that's unique enough to detect in the pane but short
  // enough to survive wrapping. Last 40 chars of the message works well
  // and avoids matching the original injected echo from earlier sends.
  const needle = text.slice(-40)
  const sleep = (ms) => {
    try { execSync(`sleep ${(ms / 1000).toFixed(3)}`, { stdio: 'ignore' }) } catch { /* empty */ }
  }
  try {
    execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(text)}`, { stdio: 'ignore' })
  } catch { return }
  for (let attempt = 0; attempt <= retries; attempt++) {
    sleep(delayMs)
    try {
      execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
    } catch { return }
    sleep(delayMs)
    let pane = ''
    try {
      pane = execSync(`tmux capture-pane -t "${tmuxName}" -p -J`, { encoding: 'utf8', timeout: 1000 })
    } catch { return }
    // If the tail of our text is no longer in the visible pane, the input
    // was consumed (submitted). Done.
    if (!pane.includes(needle)) return
  }
}

/**
 * Inject a [System] identity message into a running agent's stdin via tmux send-keys.
 * Called from BOTH hot and cold paths after the tmux session is bound and config is written.
 * Uses the same pattern as messaging (📩) and skill injection (📡).
 *
 * Only injected for agent/prompt widgets — bare terminals skip this to avoid
 * cluttering the shell with system messages a human would see.
 */
function injectIdentityMessage(tmuxName, { widgetId, displayName, canvasId, branch: _branch, serverUrl }) {
  void _branch
  if (!hasTmux) return
  const configFile = `.storyboard/terminals/${widgetId}.json`
  const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
  tmuxSubmit(tmuxName, msg)
}

function injectRoleMessage(tmuxName, role) {
  if (!hasTmux || !role) return
  const msg = `Your role in the hub is ${role}, read .agents/roles/${role}.role.md to follow additional instructions`
  tmuxSubmit(tmuxName, msg)
}

function injectHubTokenMessage(tmuxName, hasToken) {
  if (!hasTmux) return
  const msg = hasToken
    ? 'You hold the hub token (speaking rights). Read .agents/roles/cluster-token.md for instructions.'
    : 'You do not hold the hub token. Wait for a message token before acting in the hub.'
  tmuxSubmit(tmuxName, msg)
}

function injectRoleMessageForWidget(tmuxName, widgetId) {
  const cfg = readTerminalConfigById(widgetId)
  if (!cfg || (cfg.kind !== 'agent' && cfg.kind !== 'prompt')) return
  injectRoleMessage(tmuxName, cfg.role)
  // Inject hub token status if this widget is in a hub
  if (cfg.hubs && cfg.hubs.length > 0) {
    const hasToken = cfg.hubs.some((c) => c.hasHubToken)
    injectHubTokenMessage(tmuxName, hasToken)
  }
}

/** Safe directory name from canvasId (replace `/` with `--`) */
function safeCanvasDir(canvasId) {
  return canvasId.replace(/\//g, '--')
}

/** Snapshot directory for a canvas (legacy — kept for fallback reads) */
function legacySnapshotDir(canvasId) {
  return join(process.cwd(), '.storyboard', 'terminal-snapshots', safeCanvasDir(canvasId))
}

/** Private buffer directory — .storyboard/ (gitignored) */
function bufferDir() {
  return join(process.cwd(), '.storyboard', 'terminal-buffers')
}

/** Public snapshot directory — assets/.storyboard-public/terminal-snapshots/ (committed) */
function publicSnapshotDir() {
  return join(process.cwd(), 'assets', '.storyboard-public', 'terminal-snapshots')
}

/** Public snapshot index filenames (single consolidated files for all agents). */
const SNAPSHOT_INDEX_JSON = 'agents.snapshot.json'
const SNAPSHOT_INDEX_TXT = 'agents-txt.snapshot.json'

/** Max entries kept in the snapshot indexes before GC drops the oldest by timestamp. */
const SNAPSHOT_INDEX_MAX_ENTRIES = 50

/** In-memory cache of the snapshot indexes to avoid re-reading on every capture. */
let snapshotIndexCache = null
let snapshotTxtIndexCache = null
let snapshotLegacyFilesCleaned = false

function emptyIndex() {
  return { version: 1, updatedAt: null, agents: {} }
}

function readIndexFile(filePath) {
  try {
    if (!existsSync(filePath)) return emptyIndex()
    const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
    if (parsed && typeof parsed === 'object' && parsed.agents && typeof parsed.agents === 'object') {
      return { version: parsed.version || 1, updatedAt: parsed.updatedAt || null, agents: parsed.agents }
    }
  } catch { /* fall through */ }
  return emptyIndex()
}

function loadSnapshotIndexes() {
  if (!snapshotIndexCache) {
    snapshotIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON))
  }
  if (!snapshotTxtIndexCache) {
    snapshotTxtIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_TXT))
  }
  return { jsonIndex: snapshotIndexCache, txtIndex: snapshotTxtIndexCache }
}

/**
 * Garbage-collect index entries to keep size manageable.
 * Drops oldest entries by timestamp until under the cap. Operates on both
 * indexes together so they stay in sync.
 */
function gcSnapshotIndexes(jsonIndex, txtIndex) {
  const keys = Object.keys(jsonIndex.agents)
  if (keys.length <= SNAPSHOT_INDEX_MAX_ENTRIES) return
  const sorted = keys
    .map((k) => ({ k, t: jsonIndex.agents[k]?.timestamp || '' }))
    .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
  const dropCount = keys.length - SNAPSHOT_INDEX_MAX_ENTRIES
  for (let i = 0; i < dropCount; i++) {
    const id = sorted[i].k
    delete jsonIndex.agents[id]
    delete txtIndex.agents[id]
  }
}

function atomicWriteJson(filePath, data) {
  const tmpPath = filePath + '.tmp'
  try {
    writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
    renameSync(tmpPath, filePath)
  } catch (err) {
    try { if (existsSync(tmpPath)) unlinkSync(tmpPath) } catch {} // eslint-disable-line no-empty
    throw err
  }
}

function writeSnapshotIndexes(jsonIndex, txtIndex) {
  const sDir = publicSnapshotDir()
  try { mkdirSync(sDir, { recursive: true }) } catch {} // eslint-disable-line no-empty
  const now = new Date().toISOString()
  jsonIndex.updatedAt = now
  txtIndex.updatedAt = now
  try {
    atomicWriteJson(join(sDir, SNAPSHOT_INDEX_JSON), jsonIndex)
  } catch (err) {
    devLog().logEvent('error', 'Failed to write agents.snapshot.json', { error: err.message })
  }
  try {
    atomicWriteJson(join(sDir, SNAPSHOT_INDEX_TXT), txtIndex)
  } catch (err) {
    devLog().logEvent('error', 'Failed to write agents-txt.snapshot.json', { error: err.message })
  }
}

/**
 * One-time cleanup: remove legacy per-widget snapshot files left in the
 * public dir from before the consolidated-index format. Runs on first call.
 */
function cleanupLegacySnapshotFiles() {
  if (snapshotLegacyFilesCleaned) return
  snapshotLegacyFilesCleaned = true
  const sDir = publicSnapshotDir()
  if (!existsSync(sDir)) return
  try {
    for (const file of readdirSync(sDir)) {
      if (file === SNAPSHOT_INDEX_JSON || file === SNAPSHOT_INDEX_TXT) continue
      if (file.endsWith('.snapshot.json') || file.endsWith('.snapshot.txt')) {
        try { unlinkSync(join(sDir, file)) } catch {} // eslint-disable-line no-empty
      }
    }
  } catch { /* empty */ }
}

/**
 * Read the `private` prop for a widget from the terminal config.
 * Returns true if the widget has props.private === true.
 */
function isWidgetPrivate(widgetId, _canvasId) {
  void _canvasId
  try {
    const config = readTerminalConfigById(widgetId)
    if (config?.widgetProps?.private) return true
  } catch { /* empty */ }
  return false
}

/**
 * Capture terminal content and write both buffer + snapshot files.
 *
 * Buffer (private):  .storyboard/terminal-buffers/<widgetId>.buffer.json  — 5-min scrollback, full metadata
 * Snapshot (public):  assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json  — 1-min scrollback, stripped ANSI
 *                     assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.txt   — human-readable text
 *
 * When widget is private, the public snapshot is skipped and any existing
 * snapshot file is renamed to ~<filename> (tilde prefix = gitignored).
 */
function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows, createdAt }) {
  let paneContent = ''
  try {
    paneContent = execSync(`tmux capture-pane -t "${tmuxName}" -p -e`, {
      encoding: 'utf8',
      timeout: 3000,
    })
  } catch {
    // tmux capture failed — rolling buffer is the only source
  }

  const now = new Date().toISOString()
  const rawTail = getRollingBufferContent(tmuxName, BUFFER_MAX_AGE_MS)

  // ── Private buffer (.storyboard/terminal-buffers/<widgetId>.buffer.json) ──
  const bDir = bufferDir()
  const bufferPath = join(bDir, `${widgetId}.buffer.json`)
  const bufferTmpPath = bufferPath + '.tmp'

  const bufferData = {
    widgetId,
    canvasId,
    tmuxName,
    prettyName: prettyName || null,
    createdAt: createdAt || now,
    timestamp: now,
    cols: cols || 80,
    rows: rows || 24,
    paneContent,
    scrollback: rawTail,
  }

  try {
    mkdirSync(bDir, { recursive: true })
    writeFileSync(bufferTmpPath, JSON.stringify(bufferData, null, 2), 'utf8')
    renameSync(bufferTmpPath, bufferPath)
  } catch (err) {
    devLog().logEvent('error', 'Failed to write private buffer', { widgetId, error: err.message, path: bufferPath })
    try { if (existsSync(bufferTmpPath)) unlinkSync(bufferTmpPath) } catch {} // eslint-disable-line no-empty
  }

  // ── Plain-text buffer (.storyboard/terminal-buffers/<widgetId>.buffer.txt) ──
  // Agent-readable raw text: screen first, then scrollback history.
  const txtPath = join(bDir, `${widgetId}.buffer.txt`)
  const txtTmpPath = txtPath + '.tmp'
  try {
    const screen = stripAnsi(paneContent).replace(/\r\n/g, '\n').replace(/\n+$/, '')
    const history = stripAnsi(rawTail).replace(/\r\n/g, '\n').replace(/\n+$/, '')

    let txt = `[${widgetId}${prettyName ? ' | ' + prettyName : ''} | ${now}]\n\n`
    txt += '--- screen ---\n'
    txt += (screen || '(empty)') + '\n'
    if (history) {
      txt += '\n--- scrollback ---\n'
      txt += history + '\n'
    }

    writeFileSync(txtTmpPath, txt, 'utf8')
    renameSync(txtTmpPath, txtPath)
  } catch (err) {
    devLog().logEvent('error', 'Failed to write private buffer txt', { widgetId, error: err.message })
    try { if (existsSync(txtTmpPath)) unlinkSync(txtTmpPath) } catch {} // eslint-disable-line no-empty
  }

  // ── Public snapshot index (assets/.storyboard-public/terminal-snapshots/) ──
  // We maintain two consolidated index files keyed by widgetId:
  //   - agents.snapshot.json     → structured data
  //   - agents-txt.snapshot.json → human-readable text payloads
  cleanupLegacySnapshotFiles()
  const isPrivate = isWidgetPrivate(widgetId, canvasId)
  const { jsonIndex, txtIndex } = loadSnapshotIndexes()

  if (isPrivate) {
    let changed = false
    if (jsonIndex.agents[widgetId]) { delete jsonIndex.agents[widgetId]; changed = true }
    if (txtIndex.agents[widgetId]) { delete txtIndex.agents[widgetId]; changed = true }
    if (changed) writeSnapshotIndexes(jsonIndex, txtIndex)
    return
  }

  const snapshotScrollback = getRollingBufferContent(tmuxName, SNAPSHOT_MAX_AGE_MS)
  const strippedPane = stripAnsi(paneContent)
  const strippedScrollback = stripAnsi(snapshotScrollback)

  // ── JSON entry ──
  jsonIndex.agents[widgetId] = {
    widgetId,
    canvasId,
    prettyName: prettyName || null,
    timestamp: now,
    cols: cols || 80,
    rows: rows || 24,
    paneContent: strippedPane,
    scrollback: strippedScrollback,
  }

  // ── Text entry ──
  try {
    const screenText = strippedPane.replace(/\r\n/g, '\n').replace(/\n+$/, '')
    const scrollText = strippedScrollback.replace(/\r\n/g, '\n').replace(/\n+$/, '')
    const sep = '='.repeat(80)

    let snpTxt = ''
    snpTxt += `SESSION: ${widgetId}${prettyName ? ' | ' + prettyName : ''}\n`
    snpTxt += `CANVAS:  ${canvasId}\n`
    snpTxt += `BRANCH:  ${currentBranch}\n`
    snpTxt += `TIME:    ${now}\n`
    snpTxt += '\n'
    snpTxt += sep + '\n'
    snpTxt += 'SCREEN\n'
    snpTxt += sep + '\n'
    snpTxt += '\n'
    snpTxt += (screenText || '(empty)') + '\n'

    if (scrollText) {
      snpTxt += '\n'
      snpTxt += sep + '\n'
      snpTxt += 'SCROLLBACK (last 60s)\n'
      snpTxt += sep + '\n'
      snpTxt += '\n'
      snpTxt += scrollText + '\n'
    }

    txtIndex.agents[widgetId] = snpTxt
  } catch (err) {
    devLog().logEvent('error', 'Failed to build snapshot text entry', { widgetId, error: err.message })
  }

  gcSnapshotIndexes(jsonIndex, txtIndex)
  writeSnapshotIndexes(jsonIndex, txtIndex)
}

/** Start periodic snapshot capture for a session */
function startSnapshotCapture(opts) {
  const { tmuxName } = opts
  if (snapshotIntervals.has(tmuxName)) return

  const termCfg = readTerminalConfig()
  const interval = termCfg.snapshotInterval ?? 5000

  const id = setInterval(() => captureSnapshot(opts), interval)
  snapshotIntervals.set(tmuxName, id)
}

/** Stop periodic snapshot capture and do a final capture */
function stopSnapshotCapture(tmuxName, finalOpts) {
  const id = snapshotIntervals.get(tmuxName)
  if (id) {
    clearInterval(id)
    snapshotIntervals.delete(tmuxName)
  }
  if (finalOpts) {
    captureSnapshot(finalOpts)
  }
  rollingBuffers.delete(tmuxName)
}

/** Check if a tmux session with the given name exists */
function tmuxSessionExists(name) {
  try {
    execSync(`tmux has-session -t "${name}" 2>/dev/null`, { stdio: 'ignore' })
    return true
  } catch {
    return false
  }
}

/**
 * Orphan a terminal session by widget ID. Called when a terminal widget is
 * deleted. The tmux session is preserved with a grace timer.
 */
export function orphanTerminalSession(widgetId) {
  const tmuxName = findTmuxNameForWidget(widgetId)
  if (!tmuxName) {
    devLog().logEvent('warn', 'orphanTerminalSession: no registry entry for widget', { widgetId })
    legacyKillSession(widgetId)
    return
  }

  console.log(`[storyboard] orphanTerminalSession: archiving ${tmuxName} (widget: ${widgetId})`)

  // Set archived status FIRST (bumps generation so WS onclose won't override)
  orphanSession(tmuxName)

  // Close the WS connection if any (notifies client)
  const ws = wsConnections.get(tmuxName)
  if (ws && ws.readyState <= 1) {
    try { ws.close() } catch { /* empty */ }
  }
  wsConnections.delete(tmuxName)

  // Kill the PTY process (detaches from tmux)
  const proc = ptyProcesses.get(tmuxName)
  if (proc) {
    try { proc.kill() } catch { /* empty */ }
    ptyProcesses.delete(tmuxName)
  }
}

/** Kill legacy sb-{widgetId} sessions for backwards compat */
function legacyKillSession(widgetId) {
  const legacyName = `sb-${widgetId}`
  try {
    execSync(`tmux kill-session -t "${legacyName}" 2>/dev/null`, { stdio: 'ignore' })
  } catch { /* empty */ }
}

/**
 * Attach the terminal WebSocket server to a Vite HTTP server.
 * @param {object} httpServer
 * @param {string} base — Vite base path
 * @param {string} branch — current git branch name
 */
export function setupTerminalServer(httpServer, base = '/', branch = 'unknown', hotPoolManager = null) {
  if (!pty || !WebSocketServer) {
    if (!pty) devLog().logEvent('warn', 'node-pty not available — terminal widgets disabled')
    if (!WebSocketServer) devLog().logEvent('warn', 'ws not available — terminal widgets disabled')
    return
  }

  currentBranch = branch
  hotPoolRef = hotPoolManager
  actualBase = base || '/'
  actualHttpServer = httpServer

  // Capture the actual port from the running HTTP server. At configureServer
  // time the httpServer may not have bound yet (address() returns null), so
  // we also listen for the 'listening' event to capture the port later.
  try {
    const addr = httpServer.address()
    if (addr && addr.port) actualServerPort = addr.port
  } catch { /* empty */ }
  if (!actualServerPort && typeof httpServer.once === 'function') {
    httpServer.once('listening', () => {
      try {
        const addr = httpServer.address()
        if (addr && addr.port) actualServerPort = addr.port
      } catch { /* empty */ }
    })
  }

  // Ensure node-pty spawn-helper has execute permission (npm install can strip it)
  try {
    const nodePtyDir = resolve(process.cwd(), 'node_modules/node-pty/prebuilds')
    execSync(`chmod +x "${nodePtyDir}"/darwin-*/spawn-helper 2>/dev/null || true`, { stdio: 'ignore' })
  } catch { /* empty */ }

  // Initialize registry and terminal config
  const root = process.cwd()
  const termCfg = readTerminalConfig()
  initRegistry(root, { gracePeriod: termCfg.orphanGracePeriod })
  initTerminalConfig(root)

  // Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json),
  // Claude Code hook (~/.claude/settings.json), and Codex CLI hook
  // (~/.codex/hooks.json) that capture sessionStart payloads into per-widget
  // files. All idempotent.
  try { ensureCopilotCaptureHookInstalled() } catch { /* best-effort */ }
  try { ensureClaudeCaptureHookInstalled() } catch { /* best-effort */ }
  try { ensureCodexCaptureHookInstalled() } catch { /* best-effort */ }

  // Best-effort: apply shell-config overrides if a tmux server already exists
  // from a previous dev server run. If no server exists, this fails silently —
  // overrides are applied again in createTerminal() after the first new-session.
  if (hasTmux) {
    applyTmuxShellOverrides()
  }

  const mode = hasTmux ? 'tmux (persistent sessions)' : 'node-pty (no persistence)'
  console.log(`[storyboard] terminal server ready (${mode}) [branch: ${branch}]`)

  const wss = new WebSocketServer({ noServer: true })
  const baseNoTrail = (base || '/').replace(/\/$/, '')

  httpServer.on('upgrade', (req, socket, head) => {
    let pathname = req.url || ''
    if (baseNoTrail && pathname.startsWith(baseNoTrail)) {
      pathname = pathname.slice(baseNoTrail.length) || '/'
    }

    if (!pathname.startsWith(TERMINAL_PATH_PREFIX)) return

    // Parse sessionId and query params
    const pathAndQuery = pathname.slice(TERMINAL_PATH_PREFIX.length)
    const [sessionId, queryStr] = pathAndQuery.split('?')
    if (!sessionId) {
      socket.destroy()
      return
    }

    const params = new URLSearchParams(queryStr || '')
    const canvasId = params.get('canvas') || 'unknown'
    const prettyName = params.get('name') || null
    const widgetStartupCommand = params.get('startupCommand') || null
    const readOnly = params.get('readOnly') === '1'

    wss.handleUpgrade(req, socket, head, (ws) => {
      if (readOnly) {
        handleReadOnlyConnection(ws, sessionId, canvasId)
      } else {
        handleConnection(ws, sessionId, canvasId, prettyName, widgetStartupCommand)
      }
    })
  })
}

/**
 * Read-only WebSocket connection — attaches to an existing tmux session
 * for output-only streaming. Does NOT close existing WS connections,
 * does NOT kill existing pty processes, does NOT register in the session registry.
 * Used by the PromptWidget's inline terminal viewer.
 */
function handleReadOnlyConnection(ws, widgetId, canvasId) {
  const branch = currentBranch
  const tmuxName = generateTmuxName(branch, canvasId, widgetId)

  if (!hasTmux || !tmuxSessionExists(tmuxName)) {
    try {
      ws.send(JSON.stringify({ type: 'error', message: 'No active session to observe' }))
      ws.close()
    } catch { /* empty */ }
    return
  }

  // Track read-only connections separately so they don't interfere with the primary
  const roKey = `${tmuxName}:ro`
  const existingRo = wsConnections.get(roKey)
  if (existingRo && existingRo !== ws && existingRo.readyState <= 1) {
    try { existingRo.close() } catch { /* empty */ }
  }
  wsConnections.set(roKey, ws)

  let ptyProcess
  try {
    ptyProcess = pty.spawn('tmux', ['-f', '/dev/null', 'attach-session', '-t', tmuxName, '-r'], {
      name: 'xterm-256color',
      cols: 80,
      rows: 24,
      cwd: process.cwd(),
      env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
    })
  } catch (err) {
    try {
      ws.send(JSON.stringify({ type: 'error', message: `Failed to attach: ${err.message}` }))
      ws.close()
    } catch { /* empty */ }
    return
  }

  // Forward pty output to WS (one-way only)
  ptyProcess.onData((data) => {
    if (ws.readyState === 1) {
      try { ws.send(data) } catch { /* empty */ }
    }
  })

  ptyProcess.onExit(() => {
    wsConnections.delete(roKey)
    if (ws.readyState <= 1) {
      try { ws.close() } catch { /* empty */ }
    }
  })

  // Handle resize from client (needed for correct rendering)
  ws.on('message', (msg) => {
    try {
      const str = typeof msg === 'string' ? msg : msg.toString()
      if (!str.startsWith('{')) return // ignore non-JSON (input data)
      const parsed = JSON.parse(str)
      if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
        ptyProcess.resize(parsed.cols, parsed.rows)
      }
    } catch { /* empty */ }
    // All other input is silently dropped (read-only)
  })

  ws.on('close', () => {
    wsConnections.delete(roKey)
    try { ptyProcess.kill() } catch { /* empty */ }
  })

  ws.on('error', () => {
    wsConnections.delete(roKey)
    try { ptyProcess.kill() } catch { /* empty */ }
  })

  // Send session info
  try {
    ws.send(JSON.stringify({ type: 'session-info', tmuxName, readOnly: true }))
  } catch { /* empty */ }
}

function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupCommand = null) {
  const branch = currentBranch
  const tmuxName = generateTmuxName(branch, canvasId, widgetId)

  // Register in registry, check for conflicts
  const { entry, conflict } = registerSession({ branch, canvasId, widgetId, prettyName })

  // Resolve server URL deterministically:
  // 1. Use the actual port from httpServer (set at setup time, or via the
  //    'listening' event). Re-poll address() if it wasn't ready earlier.
  // 2. Fall back to server registry (tracks running dev servers)
  // 3. Last resort: default port 1234 (matches Vite default)
  let serverPort = actualServerPort
  if (!serverPort && actualHttpServer) {
    try {
      const addr = actualHttpServer.address()
      if (addr && addr.port) {
        actualServerPort = addr.port
        serverPort = addr.port
      }
    } catch { /* empty */ }
  }
  if (!serverPort) {
    try {
      const name = detectWorktreeName()
      const servers = findByWorktree(name)
      if (servers.length > 0) serverPort = servers[0].port
    } catch { /* empty */ }
  }
  if (!serverPort) serverPort = 1234
  // Include the Vite base path so the URL works when the dev server is
  // mounted under a sub-path (e.g. "/branch--0.5.0/" via the Caddy proxy).
  // Without this, `${serverUrl}/_storyboard/canvas/...` would 404 because
  // canvas routes are namespaced under the base.
  const baseTrimmed = (actualBase || '/').replace(/\/$/, '')
  const serverUrl = `http://localhost:${serverPort}${baseTrimmed}`

  // Write terminal config for agent context
  writeTermConfig({ branch, canvasId, widgetId, serverUrl, tmuxName, displayName: prettyName || null, widgetProps: prettyName ? { prettyName } : null })

  // Close any existing WS for this session (one viewer at a time)
  const existingWs = wsConnections.get(tmuxName)
  if (existingWs && existingWs !== ws && existingWs.readyState <= 1) {
    try { existingWs.close() } catch { /* empty */ }
  }
  wsConnections.set(tmuxName, ws)

  // Kill any existing pty process for this session (stale connection)
  const existing = ptyProcesses.get(tmuxName)
  if (existing) {
    try { existing.kill() } catch { /* empty */ }
    ptyProcesses.delete(tmuxName)
  }

  const cwd = process.cwd()
  const shell = process.env.SHELL || '/bin/zsh'
  const termCfg = readTerminalConfig()
  const prompt = termCfg.prompt || '$ '

  // Shared identity env vars for both tmux and direct paths
  const identityEnv = {
    STORYBOARD_WIDGET_ID: widgetId,
    STORYBOARD_CANVAS_ID: canvasId,
    STORYBOARD_BRANCH: branch,
    STORYBOARD_SERVER_URL: serverUrl,
  }

  // Env for the tmux path — cleaned of external shell config + neutralizing overrides.
  // These env vars are inherited by the shell spawned inside new-session (NOT by the
  // tmux server global env). Verified: tmux new-session passes the spawning process's
  // env to the session shell. This does NOT contaminate other tmux sessions.
  const zdotdir = join(tmpdir(), 'storyboard-terminal')
  try {
    mkdirSync(zdotdir, { recursive: true })
    writeFileSync(join(zdotdir, '.zshenv'), '')
    writeFileSync(join(zdotdir, '.zshrc'), `export PS1='${prompt.replace(/'/g, "'\\''")}'\nunset RPS1\n`)
  } catch { /* best effort */ }

  const tmuxEnv = {
    ...cleanEnv(),
    TERM: 'xterm-256color',
    COLORTERM: 'truecolor',
    FORCE_COLOR: '3',
    TERM_PROGRAM: 'storyboard',
    ZDOTDIR: zdotdir,
    STARSHIP_CONFIG: '/dev/null',
    POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true',
    ZSH_THEME: '',
    BASH_ENV: '',
    ENV: '',
    ...identityEnv,
  }

  // Full env for the direct-shell fallback (no tmux).
  const directEnv = {
    ...cleanEnv(),
    TERM: 'xterm-256color',
    COLORTERM: 'truecolor',
    FORCE_COLOR: '3',
    TERM_PROGRAM: 'storyboard',
    ZDOTDIR: zdotdir,
    STARSHIP_CONFIG: '/dev/null',
    POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true',
    ZSH_THEME: '',
    BASH_ENV: '',
    ENV: '',
    PS1: prompt,
    ...identityEnv,
  }
  let ptyProcess
  let isNewSession = false
  let usedWarmAgent = false // true when session came from a pre-warmed agent pool

  try {
  if (hasTmux) {
    const reattach = tmuxSessionExists(tmuxName)

    // Also check for legacy sb-{widgetId} sessions and migrate
    const legacyName = `sb-${widgetId}`
    const hasLegacy = !reattach && tmuxSessionExists(legacyName)
    let actualName = hasLegacy ? legacyName : tmuxName

    // If no existing session, try to acquire from the hot pool
    let poolSession = null
    let poolId = null
    if (!reattach && !hasLegacy && hotPoolRef) {
      const startupCommand = widgetStartupCommand ?? readTerminalConfig().startupCommand ?? null

      // Resolve startup command to agent ID for pool lookup
      const resolvedAgent = resolveAgentConfig(startupCommand)
      if (resolvedAgent?.id) poolId = resolvedAgent.id

      // If we have a saved session id for this widget, skip the hot pool
      // entirely — we're about to launch with `--resume <id>`, which a
      // pre-warmed session can't do (it's already past startup). The cold
      // path will then assemble the resume wrapper. Pool sessions stay
      // intact for other widgets.
      const savedSession = resolvedAgent?.cfg?.sessionIdEnv
        ? getLastAgentSession({ branch, canvasId, widgetId })
        : null

      // Try agent pool first, then fall back to terminal pool for bare shells
      const targetPool = poolId || (startupCommand ? null : 'terminal')
      if (!savedSession && targetPool && hotPoolRef.has(targetPool)) {
        poolSession = hotPoolRef.acquire(targetPool)
      }

      // If we got a warm session, rename it to the canonical tmux name
      if (poolSession?.tmuxName) {
        try {
          try { execSync(`tmux kill-session -t "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
          execSync(`tmux rename-session -t "${poolSession.tmuxName}" "${tmuxName}"`, { stdio: 'ignore' })
          // Persist the agent's captured session id onto the widget's
          // terminal config. The hook subprocess writes the capture file
          // asynchronously after sessionStart fires, so it may not be on
          // disk yet at handoff. Try once now (in case it already wrote)
          // and ALSO install a watcher on the pool-keyed capture file so
          // late writes (and any subsequent sessionStart from /new) are
          // also persisted. The hook always writes to the pool-keyed
          // file because copilot's STORYBOARD_WIDGET_ID env was set at
          // pool warm-time and we can't change a running process's env.
          try {
            const persist = (sessionId) => {
              try {
                recordAgentSession({
                  branch,
                  canvasId,
                  widgetId,
                  agentId: poolId || null,
                  sessionId,
                })
              } catch { /* empty */ }
            }
            const immediate = hotPoolRef.getCapturedSessionId(targetPool, poolSession.id)
            if (immediate) persist(immediate)
            // Watcher on the pool capture file. Fires for the initial
            // (delayed) sessionStart and any subsequent ones.
            const poolCaptureFile = captureFilePath(cwd, `pool-${poolSession.id}`)
            watchSessionIdFile(poolCaptureFile, persist)
          } catch { /* best-effort */ }
          hotPoolRef.consume(targetPool, poolSession.id)
          usedWarmAgent = !!poolId // only true for agent pools, not terminal pools
        } catch {
          hotPoolRef.release(targetPool, poolSession.id)
          poolSession = null
        }
      }
    }

    // -f /dev/null skips user tmux.conf; 'set status off' hides the status bar
    const args = (reattach || hasLegacy || poolSession)
      ? ['-f', '/dev/null', 'attach-session', '-t', actualName]
      : ['-f', '/dev/null', 'new-session', '-s', tmuxName, '-c', cwd]

    // If migrating from legacy, rename the tmux session
    if (hasLegacy) {
      try {
        execSync(`tmux rename-session -t "${legacyName}" "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' })
      } catch { /* empty */ }
    }

    ptyProcess = spawnWithCleanup('tmux', args, {
      name: 'xterm-256color',
      cols: 80,
      rows: 24,
      cwd,
      env: tmuxEnv,
    })

    // Hide status bar + apply shell-config overrides
    const targetName = (reattach || hasLegacy) ? actualName : tmuxName
    isNewSession = !(reattach || hasLegacy) || !!poolSession
    const hideStatus = () => {
      try {
        execSync(`tmux set-option -t "${targetName}" status off 2>/dev/null`, { stdio: 'ignore' })
        execSync(`tmux set-option -t "${targetName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
        // Only enable mouse for reattach sessions. For new sessions, mouse on
        // is deferred — tmux mouse events crash Clack prompts in the welcome script.
        if (!isNewSession) {
          execSync(`tmux set-option -t "${targetName}" mouse on 2>/dev/null`, { stdio: 'ignore' })
        }

        // Apply shell-config overrides to the tmux server's global env.
        // This is the reliable call — the tmux server is guaranteed to exist
        // after pty.spawn('tmux', ...) above.
        applyTmuxShellOverrides()

        // Update tmux session env vars so new shells (and agents reading $STORYBOARD_WIDGET_ID)
        // always reflect the current widget identity — even after reassignment.
        const tmuxEnvVars = {
          STORYBOARD_WIDGET_ID: widgetId,
          STORYBOARD_CANVAS_ID: canvasId,
          STORYBOARD_BRANCH: branch,
          STORYBOARD_SERVER_URL: serverUrl,
        }
        for (const [key, val] of Object.entries(tmuxEnvVars)) {
          execSync(`tmux set-environment -t "${targetName}" ${key} "${val}" 2>/dev/null`, { stdio: 'ignore' })
        }
        // Write a sourceable env file keyed by tmux session name.
        // Running shells can source this to get fresh identity without restarting.
        const envDir = join(cwd, '.storyboard', 'terminals')
        try {
          const envContent = Object.entries(tmuxEnvVars)
            .map(([k, v]) => `export ${k}="${v}"`)
            .join('\n') + '\n'
          writeFileSync(join(envDir, `${targetName}.env`), envContent)
        } catch { /* best effort */ }

        // Write shell aliases for `start` and agent shorthand commands.
        // Written on every connection (not just new sessions) so the file
        // is always available and up-to-date for manual sourcing.
        const canvasArg = canvasId !== 'unknown' ? canvasId : ''
        const nameArgVal = prettyName ? ` --name "${prettyName}"` : ''
        const welcomeBase = `${STORYBOARD_CLI_CMD} terminal-welcome --branch "${branch}" --canvas "${canvasArg}"${nameArgVal}`

        // Write real executable scripts to .storyboard/terminals/bin/ and
        // prepend that dir to PATH via tmux set-environment. This makes
        // `start`, `copilot`, `claude`, `codex` available in ANY shell
        // inside the tmux session — even bare shells after a crash.
        const binDir = join(envDir, 'bin')
        try { mkdirSync(binDir, { recursive: true }) } catch { /* empty */ }

        // `start` — opens welcome screen (no args) or launches a command.
        // Uses `exec` to REPLACE the current shell, preventing nested
        // welcome→shell→welcome→shell stacking. The parent welcome (if any)
        // sees its child close and loops back to its menu.
        const startScript = [
          '#!/usr/bin/env sh',
          `if [ $# -eq 0 ]; then`,
          `  exec ${welcomeBase}`,
          `else`,
          `  exec ${welcomeBase} --startup "$*"`,
          `fi`,
        ].join('\n') + '\n'
        try {
          writeFileSync(join(binDir, 'start'), startScript, { mode: 0o755 })
        } catch { /* empty */ }

        // Agent shorthand scripts (copilot, claude, codex, etc.)
        try {
          const agentsConfig = readAgentsConfig()
          if (agentsConfig && typeof agentsConfig === 'object') {
            for (const [id, cfg] of Object.entries(agentsConfig)) {
              if (!cfg.startupCommand) continue
              const agentScript = [
                '#!/usr/bin/env sh',
                `exec start ${cfg.startupCommand} "$@"`,
              ].join('\n') + '\n'
              try {
                writeFileSync(join(binDir, id), agentScript, { mode: 0o755 })
              } catch { /* empty */ }
            }
          }
        } catch { /* empty */ }

        // Prepend bin dir to PATH in the tmux session environment.
        // Every new shell in this session will inherit the updated PATH.
        try {
          const currentPath = process.env.PATH || '/usr/bin:/bin'
          if (!currentPath.includes(binDir)) {
            execSync(`tmux set-environment -t "${targetName}" PATH "${binDir}:${currentPath}" 2>/dev/null`, { stdio: 'ignore' })
          }
        } catch { /* empty */ }

        // Also keep the sourceable aliases file for backwards compatibility
        const aliasLines = [
          '# Storyboard terminal aliases — auto-generated, do not edit',
          `start() { if [ $# -eq 0 ]; then ${welcomeBase}; else ${welcomeBase} --startup "$*"; fi; }`,
        ]
        try {
          const agentsConfig = readAgentsConfig()
          if (agentsConfig && typeof agentsConfig === 'object') {
            for (const [id, cfg] of Object.entries(agentsConfig)) {
              if (!cfg.startupCommand) continue
              aliasLines.push(`${id}() { start ${cfg.startupCommand} "$@"; }`)
            }
          }
        } catch { /* empty */ }
        const aliasFile = join(envDir, `${widgetId}.aliases.sh`)
        try { writeFileSync(aliasFile, aliasLines.join('\n') + '\n') } catch { /* empty */ }
      } catch { /* empty */ }
    }
    setTimeout(hideStatus, 200)

    // For new sessions, either run startupCommand (skip welcome) or show the welcome screen
    if (isNewSession) {
      const startupCommand = widgetStartupCommand ?? termCfg.startupCommand ?? null
      const resolvedAgent = resolveAgentConfig(startupCommand)
      const resolvedAgentCfg = resolvedAgent?.cfg || null

      // Build the welcome command base — used by all paths below
      const canvasArg = canvasId !== 'unknown' ? canvasId : ''
      const nameArg = prettyName ? ` --name "${prettyName}"` : ''
      const welcomeBase = `${STORYBOARD_CLI_CMD} terminal-welcome --branch "${branch}" --canvas "${canvasArg}"${nameArg}`

      if (usedWarmAgent) {
        // ── Hot pool path: session came from a pre-warmed agent pool ──
        // Re-apply postStartup at handoff and wait for readiness if configured.
        // This keeps reassigned sessions robust when warm-up readiness is flaky.
        // (Session-id capture is handled in the warm-handoff block above
        // via a watcher on the pool-keyed capture file — copilot's env is
        // pool-keyed for the life of the warm process, so the user-level
        // hook always writes there, not to the widget-keyed file.)
        const postStartup = resolvedAgentCfg?.postStartup || null
        // ── H2 fix: skip readiness re-poll on warm handoff.
        // The hot pool already verified readiness when it warmed this session.
        // Re-polling for a one-shot signal like Copilot's "Environment loaded:"
        // against an already-running TUI always misses (the echo has long
        // since scrolled off the visible pane, and `tmux capture-pane -p`
        // returns only the visible region). Falling through to the 30s
        // timeout fallback was delaying /allow-all, identity, role, and
        // bindWidget by ~30s for every warm Copilot widget.
        const readinessSignal = null
        setTimeout(() => {
          let completed = false
          const finalize = () => {
            if (completed) return
            completed = true
            // Identity/role MUST be injected before postStartup. Commands like
            // `/autopilot` flip the agent into a mode where the next message is
            // consumed as a task — so we want canvas context in conversation
            // history *first*, then the mode-toggling slash command.
            injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
            injectRoleMessageForWidget(tmuxName, widgetId)
            if (postStartup) {
              tmuxSubmit(tmuxName, postStartup)
            }
            // Bind to messaging bus for live delivery + backfill missed messages
            setTimeout(() => {
              migratePendingMessages(widgetId, branch, canvasId).then(() => {
                bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
              }).catch(() => {
                bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
              })
              joinPresence({ widgetId, senderName: prettyName || widgetId, branch, canvasId }).catch(() => {})
            }, 1000)
          }

          if (readinessSignal) {
            const pollInterval = setInterval(() => {
              if (completed) { clearInterval(pollInterval); return }
              try {
                const paneContent = execSync(
                  `tmux capture-pane -t "${tmuxName}" -p`,
                  { encoding: 'utf8', timeout: 1000 }
                )
                if (paneContent.includes(readinessSignal)) {
                  clearInterval(pollInterval)
                  finalize()
                }
              } catch { /* empty */ }
            }, 2000)
            setTimeout(() => {
              if (!completed) {
                clearInterval(pollInterval)
                finalize()
              }
            }, 30000)
          } else {
            setTimeout(finalize, 500)
          }
        }, 500)
      } else {
        // ── Cold path: standard startup flow ──

        // Export identity env vars + shell-config overrides into the shell via send-keys.
        // pty.spawn sets env on the tmux client process, but the session's
        // shell doesn't inherit those — it starts from the tmux server env.
        // send-keys is the only reliable way to set vars in the running shell.
        // Shell-config overrides (STARSHIP_CONFIG, etc.) must also be sent here
        // because the shell's .zshrc has already run by the time tmux global env
        // overrides are applied.
        const envParts = [
          `export STORYBOARD_WIDGET_ID="${widgetId}"`,
          `export STORYBOARD_CANVAS_ID="${canvasId}"`,
          `export STORYBOARD_BRANCH="${branch}"`,
          `export STORYBOARD_SERVER_URL="${serverUrl}"`,
          `export STORYBOARD_PROJECT_ROOT="${cwd}"`,
          ...Object.entries(TMUX_SHELL_OVERRIDES).map(([k, v]) => `export ${k}="${v}"`),
        ]

        // Prepend the bin dir to PATH for the initial shell (tmux set-environment
        // handles future shells, but the first shell is already running)
        const binDir = join(cwd, '.storyboard', 'terminals', 'bin')
        envParts.push(`export PATH="${binDir}:$PATH"`)

        // Write env exports to a per-widget shell script and source it via a
        // short send-keys. Avoids tmux send-keys -l truncation when the env
        // soup (especially PATH expansion) gets large — observed: a 4 KB+
        // chain stops mid-line on macOS, the agent command never runs.
        const envScriptDir = join(cwd, '.storyboard', 'terminals')
        try { mkdirSync(envScriptDir, { recursive: true }) } catch { /* empty */ }
        const envScriptPath = join(envScriptDir, `${widgetId}.env.sh`)
        // The Copilot/Claude/Codex sessionStart hooks touch this file once
        // the agent is fully loaded with an interactive prompt — much more
        // reliable than scanning the pane for a transient shell echo.
        // See agent-session.js:buildCaptureBashScript.
        const readyFilePath = join(envScriptDir, `${widgetId}.ready`)
        try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
        try {
          writeFileSync(envScriptPath, envParts.join('\n') + '\n')
        } catch { /* empty */ }
        // Source env script; the trailing readiness echo MUST remain on
        // the pane so the post-startup poller can match it. Don't append
        // a `clear` here — the welcomeCmd that runs next clears the pane
        // itself before launching the agent.
        const envSourceCmd = `source ${JSON.stringify(envScriptPath)}`

        setTimeout(() => {
          try {
            execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envSourceCmd)}`, { stdio: 'ignore' })
            execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
          } catch { /* empty */ }
        }, 300)

        if (startupCommand) {

          // Look up agent config for this startup command
          const agentCfg = resolvedAgentCfg

          if (startupCommand === 'shell') {
            // Plain shell — route through welcome with --startup shell so it
            // returns to the welcome screen on exit
            setTimeout(() => {
              const cmd = `${welcomeBase} --startup shell`
              try {
                execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(cmd)}`, { stdio: 'ignore' })
                execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
              } catch { /* empty */ }
            }, 800)
          } else if (agentCfg || startupCommand !== 'shell') {
            // Agent or custom command — route through welcome with --startup
            // so the welcome screen appears when the agent exits
            let cmd = agentCfg?.startupCommand || startupCommand
            const postStartup = agentCfg?.postStartup || null
            const readinessSignal = agentCfg?.readinessSignal || null
            // Agents with a sessionStart hook (Copilot/Claude/Codex) touch
            // the .ready marker once the agent is fully loaded with an
            // interactive prompt. Wait for the marker even when no
            // readinessSignal is configured — much more accurate than
            // pane scanning.
            const useReadyFile = !!agentCfg?.sessionIdEnv

            // ── Resume: if we previously captured a session id for this
            // widget and it's still resumable (UUID + session-state dir
            // exists), inject the resume args. Otherwise launch fresh.
            if (agentCfg?.sessionIdEnv) {
              try {
                const saved = getLastAgentSession({ branch, canvasId, widgetId })
                if (saved?.sessionId) {
                  cmd = buildResumeStartupCommand({
                    startupCommand: cmd,
                    sessionId: saved.sessionId,
                    agentCfg,
                  })
                }
              } catch { /* resume is best-effort */ }
            }

            // ── Capture: install a watcher on this widget's capture file.
            // The user-level Copilot hook (~/.copilot/hooks/storyboard-capture.json)
            // writes the agent's sessionId there using STORYBOARD_WIDGET_ID +
            // STORYBOARD_PROJECT_ROOT, both exported above. Works for fresh
            // and resumed sessions alike (sessionStart fires on both).
            if (agentCfg?.sessionIdEnv) {
              try {
                const captureFile = captureFilePath(cwd, widgetId)
                watchSessionIdFile(captureFile, (sessionId) => {
                  try {
                    recordAgentSession({
                      branch,
                      canvasId,
                      widgetId,
                      agentId: resolvedAgent?.id || null,
                      sessionId,
                    })
                  } catch { /* empty */ }
                })
              } catch { /* empty */ }
            }

            setTimeout(() => {
              const welcomeCmd = `${welcomeBase} --startup ${JSON.stringify(cmd)}`
              try {
                execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(welcomeCmd)}`, { stdio: 'ignore' })
                execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
              } catch { /* empty */ }

              if (readinessSignal || useReadyFile) {
                // Poll for readiness, then send postStartup command and deliver messages.
                // The .ready marker is the accurate signal (Copilot/Claude/Codex
                // sessionStart hook touches it once the TUI is interactive). On
                // consumer repos with cold caches + resume mode, that can easily
                // take 30–60s — well past any reasonable hard timeout. So we:
                //   1. Keep polling indefinitely at 3s intervals for .ready.
                //   2. At 30s, do a "soft commit": inject identity and bind to
                //      the messaging bus so the widget is at least addressable
                //      for hub broadcast and live messages.
                //   3. When .ready finally appears, run the real postStartup.
                let softBound = false
                let postStartupSent = false
                const softBind = () => {
                  if (softBound) return
                  softBound = true
                  injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
                  injectRoleMessageForWidget(tmuxName, widgetId)
                  setTimeout(() => {
                    migratePendingMessages(widgetId, branch, canvasId).then(() => {
                      bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
                    }).catch(() => {
                      bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
                    })
                    joinPresence({ widgetId, senderName: prettyName || widgetId, branch, canvasId }).catch(() => {})
                  }, 2000)
                }
                const runPostStartup = (reason) => {
                  if (postStartupSent) return
                  postStartupSent = true
                  clearInterval(pollInterval)
                  try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
                  setTimeout(() => {
                    // Identity/role/messaging-bind MUST happen BEFORE postStartup.
                    // Slash commands like `/autopilot` flip the agent into a mode
                    // where the next message is consumed as a task — so we want
                    // canvas context (widgetId, configFile, role) in conversation
                    // history first, then the mode-toggling slash command last.
                    softBind()
                    if (postStartup) {
                      setTimeout(() => tmuxSubmit(tmuxName, postStartup), 1500)
                    }
                  }, 500)
                  if (reason === 'soft-bind-timeout') {
                    devLog(`[terminal-server] readiness slow for ${widgetId} (${tmuxName}); soft-binding now, will run postStartup when .ready arrives`)
                  }
                }
                const pollInterval = setInterval(() => {
                  if (postStartupSent) { clearInterval(pollInterval); return }
                  // Primary: sessionStart hook touches the marker once the
                  // agent is fully loaded and interactive.
                  if (useReadyFile && existsSync(readyFilePath)) { runPostStartup('file'); return }
                  if (!readinessSignal) return
                  try {
                    // Fallback for agents without a hook: scan pane + 200
                    // lines of scrollback (so the signal survives a TUI repaint).
                    const paneContent = execSync(
                      `tmux capture-pane -t "${tmuxName}" -p -S -200`,
                      { encoding: 'utf8', timeout: 1000 }
                    )
                    if (paneContent.includes(readinessSignal)) runPostStartup('signal')
                  } catch { /* empty */ }
                }, 500)
                // Soft-bind at 30s so the widget is addressable for hub/live
                // messages even if Copilot's TUI is still warming up. The poller
                // keeps running and will fire postStartup once .ready appears.
                setTimeout(softBind, 30000)
              } else {
                // No readiness signal — inject identity and bind to bus after a delay
                setTimeout(() => {
                  injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
                  injectRoleMessageForWidget(tmuxName, widgetId)
                  setTimeout(() => {
                    migratePendingMessages(widgetId, branch, canvasId).then(() => {
                      bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
                    }).catch(() => {
                      bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
                    })
                    joinPresence({ widgetId, senderName: prettyName || widgetId, branch, canvasId }).catch(() => {})
                  }, 2000)
                }, 5000)
              }
            }, 900)
          }
        } else {
          // No startupCommand — show the welcome screen as before.
          // Use tmux send-keys (not ptyProcess.write) so the command goes through
          // the same input path as the env exports, avoiding interleave races.
          // Prepend 'clear' so the exported env vars are cleared from the screen.
          setTimeout(() => {
            try {
              execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(`clear && ${welcomeBase}`)}`, { stdio: 'ignore' })
              execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
            } catch { /* empty */ }
          }, 800)
        }
      }

      // Execute startup sequence if configured (after welcome or startupCommand)
      const startupSeq = termCfg.defaultStartupSequence
      if (startupSeq?.steps?.length) {
        setTimeout(() => {
          executeStartupSequence(tmuxName, ws, startupSeq)
        }, startupCommand ? 1500 : 1500)
      }
    }

    // Write conflict warning if session was live elsewhere
    if (conflict) {
      setTimeout(() => {
        const warning = [
          '',
          `\x1b[33m⚠ Session conflict\x1b[0m`,
          `\x1b[2mThis session was\x1b[0m \x1b[34mLive\x1b[0m \x1b[2mon branch\x1b[0m \x1b[34m${conflict.currentBranch}\x1b[0m \x1b[2m(canvas: ${conflict.currentCanvas})\x1b[0m`,
          `\x1b[2mDetached from there and attached here.\x1b[0m`,
          '',
        ].join('\r\n')
        if (ws.readyState === ws.OPEN) {
          ws.send(warning)
        }
      }, 300)
    }
  } else {
    const noRcFlag = shell.endsWith('/zsh') ? '--no-rcs' : shell.endsWith('/bash') ? '--norc' : ''
    const shellArgs = noRcFlag ? [noRcFlag] : []
    ptyProcess = spawnWithCleanup(shell, shellArgs, {
      name: 'xterm-256color',
      cols: 80,
      rows: 24,
      cwd,
      env: directEnv,
    })
  }
  } catch (spawnErr) {
    devLog().logEvent('error', 'Terminal spawn failed', { error: spawnErr.message })

    // Roll back registry — mark as background (not live) since spawn failed
    disconnectSession(tmuxName, entry.generation)

    if (ws.readyState === ws.OPEN) {
      if (spawnErr.resourceLimited) {
        // PTY exhaustion — send structured error so browser can show cleanup UI
        sendJson(ws, {
          type: 'resource-limited',
          message: 'No PTY devices available. Too many terminal sessions are open.',
          counts: spawnErr.stats || getSessionStats(),
        })
      } else {
        ws.send(`\r\n\x1b[31m✖ Terminal failed to start: ${spawnErr.message}\x1b[0m\r\n`)
        ws.send(`\x1b[2mTry: chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper\x1b[0m\r\n`)
      }
      ws.close()
    }
    return
  }

  const generation = entry.generation
  ptyProcesses.set(tmuxName, ptyProcess)

  ptyProcess.onData((data) => {
    if (ws.readyState === ws.OPEN) {
      ws.send(data)
    }
    // Maintain time-windowed rolling buffer
    appendToRollingBuffer(tmuxName, data)
  })

  // Start periodic snapshot capture (works for both tmux and direct pty —
  // tmux capture-pane fails gracefully, rolling buffer provides content either way)
  const snapshotOpts = { tmuxName, widgetId, canvasId, prettyName, cols: 80, rows: 24, createdAt: new Date().toISOString() }
  startSnapshotCapture(snapshotOpts)

  ptyProcess.onExit(() => {
    ptyProcesses.delete(tmuxName)
    if (ws.readyState === ws.OPEN) {
      ws.close()
    }
  })

  ws.on('message', (msg) => {
    const str = typeof msg === 'string' ? msg : msg.toString('utf-8')
    try {
      const parsed = JSON.parse(str)
      if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
        ptyProcess.resize(parsed.cols, parsed.rows)
        // Update snapshot dimensions
        snapshotOpts.cols = parsed.cols
        snapshotOpts.rows = parsed.rows
        return
      }
    } catch {
      // Not JSON — raw stdin
    }

    ptyProcess.write(str)
  })

  // On disconnect: final snapshot, kill the pty (detaches from tmux) but leave the tmux session alive
  ws.on('close', () => {
    stopSnapshotCapture(tmuxName, snapshotOpts)
    if (wsConnections.get(tmuxName) === ws) {
      wsConnections.delete(tmuxName)
    }
    const proc = ptyProcesses.get(tmuxName)
    if (proc === ptyProcess) {
      try { ptyProcess.kill() } catch { /* empty */ }
      ptyProcesses.delete(tmuxName)
    }
    unbindWidget(widgetId)
    leavePresence(widgetId)
    disconnectSession(tmuxName, generation)
  })

  ws.on('error', () => {
    stopSnapshotCapture(tmuxName, snapshotOpts)
    if (wsConnections.get(tmuxName) === ws) {
      wsConnections.delete(tmuxName)
    }
    try { ptyProcess.kill() } catch { /* empty */ }
    ptyProcesses.delete(tmuxName)
    unbindWidget(widgetId)
    leavePresence(widgetId)
    disconnectSession(tmuxName, generation)
  })
}

/** Send a JSON message over WebSocket */
function sendJson(ws, data) {
  if (ws.readyState === ws.OPEN) {
    ws.send(JSON.stringify(data))
  }
}

/**
 * Migrate any old pendingMessages from terminal config to the messaging bus.
 * One-time migration — reads and clears pendingMessages, publishes to bus.
 * Called automatically when binding a widget to the delivery bridge.
 */
async function migratePendingMessages(widgetId, branch, canvasId) {
  try {
    const config = readTerminalConfigById(widgetId)
    if (!config?.pendingMessages?.length) return

    const { publish } = await import('../messaging/bus.js')
    const { terminalChannel } = await import('../messaging/delivery.js')
    const channel = terminalChannel(branch, canvasId, widgetId)

    const messages = config.pendingMessages
    // Clear pending messages from config
    config.pendingMessages = []
    config.updatedAt = new Date().toISOString()

    const symPath = join(process.cwd(), '.storyboard', 'terminals', `${widgetId}.json`)
    try { writeFileSync(symPath, JSON.stringify(config, null, 2)) } catch { /* empty */ }

    // Publish each to the bus
    for (const msg of messages) {
      try {
        await publish(channel, {
          type: 'message:request',
          senderId: msg.from || 'legacy',
          senderName: msg.fromName || msg.from || 'unknown',
          body: msg.message,
          widgetId,
          migratedFrom: 'pendingMessages',
          originalTimestamp: msg.createdAt,
        })
      } catch { /* empty */ }
    }

    devLog().logEvent('info', `Migrated ${messages.length} pending messages to bus for ${widgetId}`)
  } catch { /* empty */ }
}

/**
 * Execute a startup sequence for a new terminal session.
 * Runs server-side via tmux send-keys. Only called for new sessions.
 *
 * Step types:
 *   command   — send text + \n to the shell
 *   keystroke — send raw keys (e.g. {enter}, {tab})
 *   wait      — pause for ms or until output matches a pattern
 *   tmux      — run a tmux command against the session
 *   env       — set env var (must be before shell starts, so this is a pre-step)
 *
 * @param {string} tmuxName — tmux session name
 * @param {object} ws — WebSocket connection
 * @param {object} sequence — { steps: [], renderAfterStep?: number }
 */
async function executeStartupSequence(tmuxName, ws, sequence) {
  if (!sequence?.steps?.length) return
  if (!hasTmux) return

  const { steps, renderAfterStep } = sequence
  const shouldGateRender = typeof renderAfterStep === 'number' && renderAfterStep >= 0

  for (let i = 0; i < steps.length; i++) {
    const step = steps[i]

    try {
      switch (step.type) {
        case 'command':
          // Use -l for literal text to avoid shell interpretation issues
          execSync(
            `tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(step.value)}`,
            { stdio: 'ignore' }
          )
          execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
          break

        case 'keystroke': {
          const keyMap = { '{enter}': 'Enter', '{tab}': 'Tab', '{escape}': 'Escape', '{space}': 'Space' }
          const key = keyMap[step.value] || step.value
          execSync(`tmux send-keys -t "${tmuxName}" ${key}`, { stdio: 'ignore' })
          break
        }

        case 'wait':
          if (step.until === 'ready' || step.until === 'output') {
            const timeout = step.timeout || 10000
            const start = Date.now()
            const match = step.match || null
            while (Date.now() - start < timeout) {
              await new Promise(r => setTimeout(r, 500))
              if (match) {
                try {
                  const capture = execSync(
                    `tmux capture-pane -t "${tmuxName}" -p`,
                    { encoding: 'utf8', timeout: 2000 }
                  )
                  if (capture.includes(match)) break
                } catch { /* continue waiting */ }
              }
            }
          } else {
            await new Promise(r => setTimeout(r, step.ms || 1000))
          }
          break

        case 'tmux':
          execSync(`tmux ${step.value}`, { stdio: 'ignore' })
          break

        default:
          devLog().logEvent('warn', `Unknown startup step type: ${step.type}`, { stepType: step.type })
      }
    } catch (err) {
      devLog().logEvent('warn', `Startup sequence step ${i} (${step.type}) failed`, { step: i, stepType: step.type, error: err.message })
      // Non-fatal — continue to next step
    }

    // Send render signal after the specified step
    if (shouldGateRender && i === renderAfterStep) {
      sendJson(ws, { type: 'render' })
    }
  }

  // If renderAfterStep was beyond all steps, send it now
  if (shouldGateRender && renderAfterStep >= steps.length) {
    sendJson(ws, { type: 'render' })
  }
}

// Re-export for backwards compat (canvas server uses this name)
export { killSession as killTerminalSession }

// Export for REST endpoint in canvas server
export { legacySnapshotDir as terminalSnapshotDir }

/**
 * Read a terminal buffer file by widget ID.
 * Returns the parsed JSON or null if not found.
 * Optionally truncates scrollback to `maxLength` chars.
 */
export function readTerminalBuffer(widgetId, { maxLength } = {}) {
  const filePath = join(bufferDir(), `${widgetId}.buffer.json`)
  try {
    if (!existsSync(filePath)) return null
    const data = JSON.parse(readFileSync(filePath, 'utf8'))
    if (maxLength && typeof maxLength === 'number') {
      if (data.scrollback && data.scrollback.length > maxLength) {
        data.scrollback = data.scrollback.slice(-maxLength)
      }
      if (data.paneContent && data.paneContent.length > maxLength) {
        data.paneContent = data.paneContent.slice(-maxLength)
      }
    }
    return data
  } catch {
    return null
  }
}

/**
 * Read a terminal public snapshot by widget ID from the consolidated index.
 * Falls back to legacy nested path if not present.
 */
export function readTerminalSnapshot(widgetId, canvasId) {
  // New: consolidated index at agents.snapshot.json
  const indexPath = join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON)
  try {
    if (existsSync(indexPath)) {
      const parsed = JSON.parse(readFileSync(indexPath, 'utf8'))
      const entry = parsed?.agents?.[widgetId]
      if (entry) return entry
    }
  } catch { /* empty */ }

  // Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
  if (canvasId) {
    const legacyPath = join(legacySnapshotDir(canvasId), `${widgetId}.json`)
    try {
      if (existsSync(legacyPath)) {
        return JSON.parse(readFileSync(legacyPath, 'utf8'))
      }
    } catch { /* empty */ }
  }

  return null
}
