/**
 * SSH Console tools: SshConsoleEnsure / SshConsoleSend / SshConsoleSnapshot.
 *
 * These are the AI-facing wrappers around the in-memory ConsoleSession
 * registry. Each call goes through the same 12-step preflight as SshExec
 * (piece membership, access decision, enabled / abuse / host-key state)
 * via the exported `preflight` helper in ssh.ts. After that:
 *
 *   - SshConsoleEnsure: find-or-open the session keyed by
 *     (localTaskId, connectionId). A task can hold multiple concurrent
 *     sessions, one per connection: opening a DIFFERENT connection ADDS a
 *     session (it does not close the others). `force_replace` only restarts
 *     the SAME connection's session (close 'connection_change' + reopen).
 *     Enforces per-task, per-user, and per-connection session caps.
 *
 *   - SshConsoleSend: deny-list check the input lines, then forward the
 *     bytes to the session (which writes them straight to the PTY — same
 *     path as human keystrokes from the WS). Wait the caller-supplied
 *     waitMs (capped) and return a screen snapshot + new-output byte count
 *     so the LLM can read the post-action terminal state.
 *
 *   - SshConsoleSnapshot: kind=screen returns the rendered terminal view;
 *     kind=scrollback returns the (ANSI-stripped) raw byte history capped
 *     at max_bytes. Does not consume input.
 *
 * Plan: docs/superpowers/plans/2026-05-13-ssh-console.md (Phase 3).
 */
import type { ToolDef } from '../../llm/openai-compat.js';
import type { ToolContext, ToolResult } from './core.js';
import { ConsoleSession } from '../../ssh/console-session.js';
import { checkConsoleInput } from '../../ssh/console-deny-check.js';
import { clearBuffer } from '../../ssh/crypto.js';
import { logger } from '../../logger.js';
import { getSshSubsystem, preflight, type SshSubsystem } from './ssh.js';
import { makeNonce, makeMarkerCommand, parseMarker, extractOutput, detectWaitingForInput, shouldGuardInterrupt } from './console-run-lib.js';

// ──────────────────────────────────────────────────────────────────────
// openConsoleSession — the shared find-or-open core
// ──────────────────────────────────────────────────────────────────────
//
// Extracted from the SshConsoleEnsure tool so a future HTTP endpoint can
// create a session WITHOUT fabricating a ToolContext. The tool wrapper
// (ensureSessionInternal) builds OpenConsoleParams from its ToolContext
// (initiator: 'agent') and maps the structured result back to the tool's
// existing return/error shape. Every preflight gate, host-key check,
// disabled/abuse check, key decryption, shell-channel open, session
// register, per-connection cap, and the `ssh.console.open` audit are
// preserved verbatim — in the same order, with the same semantics.

/** Explicit collaborators the find-or-open core needs. `sub` carries the
 * sessionRegistry, connectionRepo, access resolver, key decryptors, audit
 * repo, abuse repo, config (cols/rows defaults + caps) and openShellChannel.
 * `preflight` is threaded explicitly (it is the shared access/state gate). */
export interface OpenConsoleDeps {
  sub: SshSubsystem;
  preflight: typeof preflight;
}

export interface OpenConsoleParams {
  taskId: string;
  connectionId: string;
  /** Connection-resolution / audit owner (job.ownerId ?? 'local' upstream). */
  ownerId: string | null;
  /** The acting principal (startedByUserId). */
  userId: string;
  /** For the grant check (access resolver matches against this piece). */
  pieceName: string;
  /** Workspace/space-scoped SSH connection IDs (['*'] for user-initiated console). */
  allowedConnections: string[];
  cols?: number;
  rows?: number;
  forceReplace?: boolean;
  /** Audit marker — distinguishes human-opened from agent-opened sessions. */
  initiator: 'agent' | 'user';
  /** Optional job id for the audit rows (agent path passes ctx.jobId). */
  jobId?: string | null;
  /**
   * Per-space isolation (spec §11): the space of the job/task opening the
   * console. Gates access to space-scoped connections in the SAME way SshExec
   * does via ctx.spaceId. MUST be the already-resolved space id (personal-WS
   * NULL→personal-space handled by the caller, mirroring the worker). When
   * omitted, resolveAccess treats the caller as a no-space (legacy) job and
   * denies every space-scoped connection with `space_mismatch`.
   */
  spaceId?: string | null;
}

export interface OpenConsoleResult {
  ok: boolean;
  /** True if a live session already existed and was reused (no open). */
  alreadyActive?: boolean;
  connectionId?: string;
  cols?: number;
  rows?: number;
  /** Structured error code on failure. */
  error?:
    | 'no_grant'
    | 'host_key_not_verified'
    | 'host_key_mismatch'
    | 'task_session_cap'
    | 'user_session_cap'
    | 'abuse_locked'
    | 'connection_disabled'
    | 'connection_not_found'
    | 'missing_connection_id'
    | 'missing_task_context'
    | 'no_user_context'
    | 'piece_not_configured'
    | 'piece_not_allowed'
    | 'preflight_denied'
    | 'decrypt_failed'
    | 'open_shell_failed';
  /** Human/LLM-readable message (mirrors the tool's existing error strings). */
  message?: string;
  /** The live session on success (consumed by the tool wrapper; not serialized). */
  session?: ConsoleSession;
}

// ──────────────────────────────────────────────────────────────────────
// Per-task open lock (TOCTOU guard for the find-or-open core)
// ──────────────────────────────────────────────────────────────────────
//
// Opening a console session is a check-then-act sequence: look up any
// existing (task, connection) session, enforce the per-task / per-user caps,
// open the SSH channel, then register. Two concurrent opens for the SAME task
// must not both pass the cap check, and must not both register a fresh session
// for the same (task, connection) — the later register() would silently
// overwrite (and orphan) the first, leaking its SSH client + PTY. We serialize
// the whole existence-check → cap-check → channel-open → register sequence per
// task with a minimal chained-promise mutex (opens are infrequent, so the
// per-task serialization cost is negligible).
const taskOpenLocks = new Map<string, Promise<void>>();

async function withTaskLock<T>(taskId: string, fn: () => Promise<T>): Promise<T> {
  const prev = taskOpenLocks.get(taskId) ?? Promise.resolve();
  let release!: () => void;
  const gate = new Promise<void>((r) => {
    release = r;
  });
  const chained = prev.then(() => gate);
  taskOpenLocks.set(taskId, chained);
  // Wait for the previous holder to release. Swallow its rejection — each
  // holder is independent, so one open failing must not wedge the queue.
  await prev.catch(() => {});
  try {
    return await fn();
  } finally {
    release();
    // If nobody queued behind us, drop the map entry so it can't grow
    // unbounded across tasks. Safe under JS's single-threaded model: any
    // later waiter's synchronous set() has already replaced this entry
    // before this finally runs.
    if (taskOpenLocks.get(taskId) === chained) {
      taskOpenLocks.delete(taskId);
    }
  }
}

/**
 * Find-or-open a ConsoleSession bound to (taskId, connectionId). Runs the
 * full preflight (piece membership, access decision, enabled / abuse /
 * host-key state), decrypts key material, opens the shell channel, builds +
 * registers the ConsoleSession, enforces the per-connection cap, and writes
 * the `ssh.console.open` audit (with `initiator` in the detail).
 *
 * Add-not-replace: a task may hold several sessions at once, one per
 * connection. Opening a NEW connection adds a session; it never closes the
 * task's other connections. Only `force_replace` on the SAME connection
 * restarts that one shell. The whole sequence is serialized per task
 * (`withTaskLock`) so concurrent opens can't exceed the caps or orphan a
 * session by double-registering the same (task, connection).
 *
 * Returns a structured OpenConsoleResult. On the happy path / reuse,
 * `ok: true` with `session` set; on any gate failure, `ok: false` with a
 * structured `error` code + `message`.
 */
export async function openConsoleSession(
  deps: OpenConsoleDeps,
  params: OpenConsoleParams,
): Promise<OpenConsoleResult> {
  const connectionId = params.connectionId;
  if (!connectionId) {
    return { ok: false, error: 'missing_connection_id', message: 'SshConsoleEnsure: connection_id is required.' };
  }
  const localTaskId = params.taskId ?? '';
  if (!localTaskId) {
    return {
      ok: false,
      error: 'missing_task_context',
      message: 'SshConsoleEnsure: this tool requires a local task context (ctx.taskId).',
    };
  }

  // Serialize the existence-check → cap-check → channel-open → register for
  // this task. See withTaskLock above for why (TOCTOU on caps + register).
  return withTaskLock(localTaskId, () => openConsoleSessionLocked(deps, params, connectionId, localTaskId));
}

async function openConsoleSessionLocked(
  deps: OpenConsoleDeps,
  params: OpenConsoleParams,
  connectionId: string,
  localTaskId: string,
): Promise<OpenConsoleResult> {
  const { sub } = deps;

  // Existence check is per (task, connection) now. Same connection already
  // live → reuse (or restart on force_replace). A DIFFERENT connection is
  // simply a new session below — opening it never touches other connections.
  const existing = sub.sessionRegistry.get(localTaskId, connectionId);
  if (existing) {
    if (params.forceReplace === true) {
      // Same-connection restart: close ONLY this connection's session, then
      // fall through to open a fresh one. Sibling sessions on other
      // connections of this task are left untouched.
      await sub.sessionRegistry.closeSession(localTaskId, connectionId, 'connection_change');
    } else {
      return {
        ok: true,
        alreadyActive: true,
        connectionId: existing.connectionId,
        cols: existing.cols,
        rows: existing.rows,
        session: existing,
      };
    }
  }

  // Per-task cap: how many live sessions this task already holds across all
  // its connections. At/over the cap → refuse, closing nothing.
  const maxSessionsPerTask = sub.config.console.maxSessionsPerTask;
  if (
    typeof maxSessionsPerTask === 'number' &&
    sub.sessionRegistry.listForTask(localTaskId).length >= maxSessionsPerTask
  ) {
    return {
      ok: false,
      error: 'task_session_cap',
      message:
        `SshConsoleEnsure: this task already has the maximum of ${maxSessionsPerTask} open console ` +
        `sessions. Close one (or reuse an existing connection) before opening another.`,
    };
  }

  // Per-user cap: how many live sessions this initiator owns across ALL
  // tasks/connections. 0 = unlimited. At/over the cap → refuse.
  const maxSessionsPerUser = sub.config.console.maxSessionsPerUser;
  if (typeof maxSessionsPerUser === 'number' && maxSessionsPerUser > 0) {
    const ownedByUser = sub.sessionRegistry
      .listAll()
      .filter((s) => s.startedByUserId === params.userId).length;
    if (ownedByUser >= maxSessionsPerUser) {
      return {
        ok: false,
        error: 'user_session_cap',
        message:
          `SshConsoleEnsure: you already have the maximum of ${maxSessionsPerUser} open console ` +
          `sessions across all tasks. Close one before opening another.`,
      };
    }
  }

  // Build the minimal ToolContext-shaped object the shared `preflight`
  // helper reads (userId, ownerId, pieceName, allowedSshConnections, jobId).
  // The HTTP caller never constructs a ToolContext — params are explicit and
  // this synthesis is internal to the shared core.
  const preCtx = {
    workspacePath: '',
    editAllowed: false,
    taskId: localTaskId,
    userId: params.userId,
    ownerId: params.ownerId,
    jobId: params.jobId ?? undefined,
    pieceName: params.pieceName,
    allowedSshConnections: params.allowedConnections,
    // Per-space isolation (spec §11): thread the task/job space so the shared
    // preflight's resolveAccess gates space-scoped connections identically to
    // SshExec. Without this every workspace-registered connection is denied
    // with `space_mismatch`.
    spaceId: params.spaceId ?? undefined,
  } as ToolContext;

  // Full 12-step preflight (same path as SshExec).
  const pre = deps.preflight({
    toolName: 'SshExec',
    connectionId,
    ctx: preCtx,
    sub,
    auditAction: 'ssh.console.open',
  });
  if (!pre.ok) {
    return {
      ok: false,
      error: 'preflight_denied',
      message: pre.error.output,
    };
  }
  const { connection, actingUserId, pieceName } = pre;

  // Console requires a verified host key — there is no LLM-actionable
  // recovery from first_observe / mismatch on a long-lived shell.
  if (connection.hostKeyVerifiedAt === null) {
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: preCtx.jobId ?? undefined,
        detail: { reason: 'host_key_not_verified', initiator: params.initiator },
      },
      'denied',
    );
    return {
      ok: false,
      error: 'host_key_not_verified',
      message: `SshConsoleEnsure: host key for connection ${connectionId} is not user-verified. Run SshExec first to surface the verify prompt.`,
    };
  }

  const cols = typeof params.cols === 'number' && params.cols > 0 ? Math.floor(params.cols) : sub.config.console.defaultCols;
  const rows = typeof params.rows === 'number' && params.rows > 0 ? Math.floor(params.rows) : sub.config.console.defaultRows;

  // Decrypt key material — same flow as SshExec; we clear on failure but
  // keep alive past this call because the ssh2 Client needs the PEM through
  // the entire session. ConsoleSession.close() does NOT clear these
  // buffers (it can't see them) — we accept that the PEM stays in memory
  // for the lifetime of the session, which already holds the decrypted
  // channel and host connection state.
  let pemBuf: Buffer | null = null;
  let passBuf: Buffer | null = null;
  try {
    pemBuf = sub.decryptKeyMaterial(connection.ownerId, connection.privateKeyEnc, connection.spaceId);
    passBuf = sub.decryptPassphrase(connection.ownerId, connection.passphraseEnc, connection.spaceId);
  } catch (e) {
    if (pemBuf) clearBuffer(pemBuf);
    if (passBuf) clearBuffer(passBuf);
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: preCtx.jobId ?? undefined,
        detail: { reason: 'decrypt_failed', msg: (e as Error).message, initiator: params.initiator },
      },
      'failed',
    );
    return { ok: false, error: 'decrypt_failed', message: 'SshConsoleEnsure: failed to decrypt stored key material.' };
  }

  // Open the channel. On failure clear the PEM and bail.
  let channel: import('ssh2').ClientChannel;
  let client: import('ssh2').Client;
  let hostFingerprint: string;
  try {
    const shellResult = await sub.openShellChannel({
      connection: {
        id: connection.id,
        ownerId: connection.ownerId,
        host: connection.host,
        port: connection.port,
        username: connection.username,
        privateKeyPem: pemBuf,
        passphrase: passBuf ?? undefined,
        hostKeyB64: connection.hostKeyB64,
        hostKeyVerified: true,
        allowPrivate: sub.config.allowPrivateAddresses || connection.allowPrivateAddresses,
      },
      cols,
      rows,
      timeoutMs: sub.config.callTimeoutSeconds * 1000,
    });
    channel = shellResult.channel;
    client = shellResult.client;
    hostFingerprint = shellResult.hostFingerprint;
  } catch (e) {
    clearBuffer(pemBuf);
    clearBuffer(passBuf);
    sub.abuseRepo.checkAndRecordFailure({
      connectionId,
      ownerId: connection.ownerId,
      userId: actingUserId,
      host: connection.host,
      username: connection.username,
    });
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.open',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: preCtx.jobId ?? undefined,
        detail: { reason: 'open_shell_failed', msg: (e as Error).message, initiator: params.initiator },
      },
      'failed',
    );
    return { ok: false, error: 'open_shell_failed', message: `SshConsoleEnsure: failed to open shell channel: ${(e as Error).message}` };
  }

  // Build the session and register it. From here on the channel + client
  // + PEM belong to the session; we don't clear them on the happy path.
  // The session ends the client (and thus releases the PEM-bound
  // connection) when it closes.
  const session = new ConsoleSession({
    localTaskId,
    connectionId,
    ownerId: connection.ownerId,
    startedByUserId: actingUserId,
    cols,
    rows,
    scrollbackCap: sub.config.console.scrollbackBytes,
    channel,
    client,
    auditRepo: sub.auditRepo,
  });
  sub.sessionRegistry.register(session);
  sub.abuseRepo.recordSuccess(connectionId);
  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.open',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: preCtx.jobId ?? undefined,
      detail: { cols, rows, host_fingerprint: hostFingerprint, initiator: params.initiator },
    },
    'success',
  );

  // Enforce the per-connection session cap (evict oldest). Close each evicted
  // session by its exact (task, connection) key — NOT closeForTask, which
  // would collaterally kill other connections the evicted task holds.
  const evict = sub.sessionRegistry.enforceCap(connectionId);
  for (const e of evict) {
    sub.sessionRegistry.closeSession(e.localTaskId, e.connectionId, 'session_cap_evict').catch((err) =>
      logger.warn(`[ssh-console] evict close error: ${(err as Error).message}`),
    );
  }

  return { ok: true, alreadyActive: false, connectionId, cols, rows, session };
}

// ──────────────────────────────────────────────────────────────────────
// Agent current-connection pointer + shared session routing (Task 4)
// ──────────────────────────────────────────────────────────────────────
//
// Task 3 made openConsoleSession add-not-replace, so a task can now hold
// several live console sessions at once (one per connection). Send / Run /
// Snapshot need to resolve an omitted connection_id to the RIGHT one rather
// than an arbitrary "the" active session. We track, per task, which
// connection the agent last acted on (Ensure/Send/Run/Snapshot) and use
// that as the default when connection_id is omitted and there's more than
// one live session.
//
// Populated by ensureSessionInternal (the agent-facing wrapper) — NOT by
// the shared openConsoleSession core, which is also used by the
// human-initiated HTTP console endpoint. Attributing a human's console
// open to "the agent's current connection" would be wrong.
const agentCurrentConnection = new Map<string, string>();

function markAgentConnection(taskId: string, connectionId: string): void {
  agentCurrentConnection.set(taskId, connectionId);
}

/** Test-only: clear pointer state between test cases (module-scope Map
 * would otherwise leak taskId reuse — e.g. 'task-1' — across test files). */
export function __resetAgentConnectionPointer(): void {
  agentCurrentConnection.clear();
}

export type ResolveSessionResult =
  | { ok: true; session: ConsoleSession; connectionId: string }
  | { ok: false; error: 'not_found'; connectionId: string }
  | { ok: false; error: 'ambiguous'; connectionIds: string[] }
  | { ok: false; error: 'none' };

/**
 * Shared routing for SshConsoleSend / SshConsoleRun / SshConsoleSnapshot
 * when connection_id may be omitted:
 *
 *   1. connection_id given → exact (taskId, connectionId) lookup. No live
 *      session there → 'not_found'. We do NOT auto-open here: silently
 *      opening a fresh session for an explicit-but-wrong id would mask a
 *      typo'd connection_id instead of surfacing it.
 *   2. else the agent's current-connection pointer, if set and still live.
 *      A pointer whose session has since closed is treated as unset (and
 *      dropped) rather than resolved.
 *   3. else, if the task has exactly one live session, that one
 *      (unambiguous single-session case — most tasks).
 *   4. else, if the task has more than one live session, 'ambiguous' — the
 *      agent must disambiguate with connection_id.
 *   5. else 'none' — no live session at all for this task.
 */
export function resolveTargetSession(
  sub: SshSubsystem,
  taskId: string,
  connectionIdMaybe: string | undefined,
): ResolveSessionResult {
  if (connectionIdMaybe) {
    const session = sub.sessionRegistry.get(taskId, connectionIdMaybe);
    if (!session) return { ok: false, error: 'not_found', connectionId: connectionIdMaybe };
    return { ok: true, session, connectionId: connectionIdMaybe };
  }

  const pointerConnId = agentCurrentConnection.get(taskId);
  if (pointerConnId) {
    const session = sub.sessionRegistry.get(taskId, pointerConnId);
    if (session) return { ok: true, session, connectionId: pointerConnId };
    // Stale pointer (session closed since) — drop it and fall through to
    // the listForTask-based resolution below.
    agentCurrentConnection.delete(taskId);
  }

  const sessions = sub.sessionRegistry.listForTask(taskId);
  if (sessions.length === 1) {
    const only = sessions[0]!;
    return { ok: true, session: only, connectionId: only.connectionId };
  }
  if (sessions.length > 1) {
    return { ok: false, error: 'ambiguous', connectionIds: sessions.map((s) => s.connectionId) };
  }
  return { ok: false, error: 'none' };
}

/**
 * Screen-auto-inject lookup consumed by buildGlobalPreamble (prompt.ts) via
 * __setActiveSessionLookup — shows the agent every one of its live console
 * screens at the top of each movement's system prompt (one block per open
 * connection). Returns ALL live sessions for the task, unfiltered; prompt.ts
 * is responsible for scoping the result down to the current movement's
 * `allowedSshConnections` (Task 5) — this function must not do that
 * filtering itself, since it has no visibility into which movement is
 * asking.
 */
export function listAgentConsoleSessions(sub: SshSubsystem, taskId: string): ConsoleSession[] {
  return sub.sessionRegistry.listForTask(taskId);
}

/**
 * Manually close a single (task, connection) console session on behalf of a
 * human actor (task owner/admin or the session's own starter — the HTTP route
 * enforces that gate). Closes via the registry with reason `user_close` AND
 * records an audit row naming the ACTOR.
 *
 * Why the extra audit row: ConsoleSession.close() already writes an
 * `ssh.console.close` audit, but it attributes the action to
 * `startedByUserId` — correct for idle/duration/transport teardown, WRONG for
 * a manual close by a different user (e.g. an admin killing someone's stray
 * session). This row captures "who actually pressed close" with the original
 * starter preserved in the detail for forensics.
 *
 * Returns `{ ok: false, error: 'not_found' }` when no live session exists for
 * the pair (raced with idle sweep / another close) so the caller can 404.
 */
export async function closeConsoleSessionByUser(
  sub: SshSubsystem,
  args: { taskId: string; connectionId: string; actorUserId: string },
): Promise<{ ok: boolean; error?: 'not_found' }> {
  const session = sub.sessionRegistry.get(args.taskId, args.connectionId);
  if (!session) return { ok: false, error: 'not_found' };
  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.close',
      connectionId: args.connectionId,
      ownerId: session.ownerId,
      actingUserId: args.actorUserId,
      detail: {
        reason: 'user_close',
        initiator: 'user',
        manual: true,
        started_by_user_id: session.startedByUserId,
      },
    },
    'success',
  );
  await sub.sessionRegistry.closeSession(args.taskId, args.connectionId, 'user_close');
  return { ok: true };
}

/** No-session-at-all wording differs slightly per tool (kept close to the
 * pre-Task-4 text so existing agent guidance / tests stay stable). */
function noActiveSessionMessage(toolName: string): string {
  if (toolName === 'SshConsoleSnapshot') {
    return `${toolName}: no live session for this task. Call SshConsoleEnsure({connection_id}) first.`;
  }
  return (
    `${toolName}: connection_id is required when this task has no active session. ` +
    `Call SshListConnections() to find the right UUID, then SshConsoleEnsure({connection_id}) to open one.`
  );
}

function notFoundMessage(toolName: string, connectionId: string): string {
  if (toolName === 'SshConsoleSnapshot') {
    return `${toolName}: no live session for connection ${connectionId} on this task. Call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`;
  }
  return `${toolName}: no session for connection ${connectionId}; call SshConsoleEnsure({connection_id: "${connectionId}"}) first.`;
}

function ambiguousMessage(toolName: string, connectionIds: string[]): string {
  return `${toolName}: connection_id required (multiple sessions open: ${connectionIds.join(', ')}).`;
}

function resolveErrorToToolResult(
  toolName: string,
  resolved: Exclude<ResolveSessionResult, { ok: true }>,
): ToolResult {
  switch (resolved.error) {
    case 'not_found':
      return err(notFoundMessage(toolName, resolved.connectionId));
    case 'ambiguous':
      return err(ambiguousMessage(toolName, resolved.connectionIds));
    case 'none':
      return err(noActiveSessionMessage(toolName));
  }
}

// ──────────────────────────────────────────────────────────────────────
// Tool definitions
// ──────────────────────────────────────────────────────────────────────

export const TOOL_DEFS: Record<string, ToolDef> = {
  SshConsoleEnsure: {
    type: 'function',
    function: {
      name: 'SshConsoleEnsure',
      description:
        'SSH console セッションを確保します（無ければ open、有れば再利用）。接続ごとに1セッションで、別の connection_id を指定すると既存を閉じずに新しいセッションを追加します（タスクあたりの上限あり）。force_replace=true は同じ接続のセッションを閉じて張り直す用途。詳細は ReadToolDoc({ name: "SshConsoleEnsure" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: 'SSH 接続の UUID (SshListConnections で取得した id)。label やホスト名ではない。' },
          cols: { type: 'number' },
          rows: { type: 'number' },
          force_replace: {
            type: 'boolean',
            description: '同じ connection_id の既存セッションを閉じて新規 open し直す（詰まり時の再接続用）。default=false。別の接続を開く場合は不要——自動で追加される。',
          },
        },
        required: ['connection_id'],
      },
    },
  },
  SshConsoleSend: {
    type: 'function',
    function: {
      name: 'SshConsoleSend',
      description:
        'console に入力を送る。通常のシェルコマンド実行には SshConsoleRun を使うこと（blocking + exit_code 取得）。SshConsoleSend は対話操作 (vim/REPL/sudo/TUI) と本当の中断専用。printable な shell コマンドには server が自動で末尾に "\\n" を付加して実行する (例: "ls -la" でも実行される)。TUI 操作 / sudo password / control byte (Ctrl-C 等) は raw のまま送られるので "\\n" を含めるかどうかは呼び出し側次第。connection_id は開いているセッションが1つなら省略可（複数なら必須）。詳細は ReadToolDoc({ name: "SshConsoleSend" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用（複数開いている場合は connection_id 必須）。明示するとその接続のセッションに送る。' },
          input: { type: 'string' },
          wait_ms: { type: 'number' },
          confirm_interrupt: {
            type: 'boolean',
            description: 'Ctrl-C (\\x03) を含む入力が interrupt_blocked=true で返った場合、true を付けて再送すると強制送信する。',
          },
        },
        required: ['input'],
      },
    },
  },
  SshConsoleSnapshot: {
    type: 'function',
    function: {
      name: 'SshConsoleSnapshot',
      description:
        'console の現在画面または scrollback を取得します。connection_id はタスクに既に active session があれば省略可 (推奨)。詳細は ReadToolDoc({ name: "SshConsoleSnapshot" })。',
      parameters: {
        type: 'object',
        properties: {
          connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用（複数開いている場合は connection_id 必須）。' },
          kind: { type: 'string', enum: ['screen', 'scrollback'] },
          max_bytes: { type: 'number' },
        },
        required: [],
      },
    },
  },
  SshConsoleRun: {
    type: 'function',
    function: {
      name: 'SshConsoleRun',
      description:
        'console でコマンドを実行し、完了まで待機して output と exit_code を返します（blocking）。通常のコマンドはこれを使う。長コマンドは timeout_ms を延ばすこと（max 600000）。詳細は ReadToolDoc({ name: "SshConsoleRun" })。',
      parameters: {
        type: 'object',
        properties: {
          command: { type: 'string', description: '実行するシェルコマンド。' },
          connection_id: { type: 'string', description: '省略時は、開いているセッションが1つならそれを自動採用（複数開いている場合は connection_id 必須）。' },
          timeout_ms: { type: 'number', description: 'タイムアウト（ms）。デフォルト 120000、最大 600000。タイムアウト時はコマンドを kill しない。' },
          idle_ms: { type: 'number', description: '出力が途切れた後のアイドル判定時間（ms）。0=無効（デフォルト）。' },
        },
        required: ['command'],
      },
    },
  },
};

const CONSOLE_TOOL_NAMES = new Set([
  'SshConsoleEnsure',
  'SshConsoleSend',
  'SshConsoleSnapshot',
  'SshConsoleRun',
]);

// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────

function err(output: string): ToolResult {
  return { output, isError: true };
}

function ok(output: string): ToolResult {
  return { output, isError: false };
}

/**
 * Subsystem + console.enabled gate. Console tools return a distinct error
 * vs the SshExec "not initialised" so the LLM can tell the feature is
 * off at the config layer (admin must toggle ssh.console.enabled).
 */
function checkConsoleGate(sub: SshSubsystem | null): ToolResult | null {
  if (!sub) {
    return err('SSH subsystem is not initialised (ssh.enabled=false or MCP_ENCRYPTION_KEY missing).');
  }
  if (sub.maintenance.isActive()) {
    return err('SSH subsystem is in maintenance — retry in a moment.');
  }
  if (!sub.config.console.enabled) {
    return err('SSH Console is disabled (ssh.console.enabled=false in config.yaml).');
  }
  return null;
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleEnsure
// ──────────────────────────────────────────────────────────────────────

interface EnsureResult {
  /** True if a fresh session was opened on this call. */
  opened: boolean;
  session: ConsoleSession;
}

/**
 * Internal find-or-open helper. Returns a live ConsoleSession bound to
 * (ctx.taskId, connectionId). Used by SshConsoleEnsure directly and by
 * SshConsoleSend / SshConsoleSnapshot when no session is attached yet.
 *
 * Thin wrapper over the shared `openConsoleSession` core: it builds
 * OpenConsoleParams from the ToolContext (initiator: 'agent') and maps the
 * structured result back to the tool's existing return/error shape so the
 * tool's external behavior is byte-for-byte unchanged.
 */
async function ensureSessionInternal(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<EnsureResult | ToolResult> {
  const connectionId = typeof input.connection_id === 'string' ? input.connection_id : '';
  const cols = typeof input.cols === 'number' ? input.cols : undefined;
  const rows = typeof input.rows === 'number' ? input.rows : undefined;

  const result = await openConsoleSession(
    { sub, preflight },
    {
      taskId: ctx.taskId ?? '',
      connectionId,
      ownerId: ctx.ownerId ?? null,
      userId: (ctx.userId ?? ctx.ownerId ?? '').toString(),
      pieceName: ctx.pieceName ?? '',
      allowedConnections: ctx.allowedSshConnections ?? [],
      cols,
      rows,
      forceReplace: input.force_replace === true,
      initiator: 'agent',
      jobId: ctx.jobId ?? undefined,
      // Per-space isolation (spec §11): the worker already resolved ctx.spaceId
      // (personal-WS NULL→personal-space). Pass it so the console gate matches
      // SshExec — otherwise workspace-registered connections fail space_mismatch.
      spaceId: ctx.spaceId ?? null,
    },
  );

  if (!result.ok || !result.session) {
    return err(result.message ?? 'SshConsoleEnsure: failed to open session.');
  }
  // Record this as the agent's current connection for this task — Send /
  // Run / Snapshot resolve an omitted connection_id to this pointer when
  // the task has more than one live session. ctx.taskId is guaranteed
  // non-empty here (openConsoleSession already rejected an empty taskId
  // with 'missing_task_context' before result.ok could be true).
  markAgentConnection(ctx.taskId as string, result.session.connectionId);
  return { opened: !result.alreadyActive, session: result.session };
}

async function ensureTool(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const r = await ensureSessionInternal(input, ctx, sub);
  if ('isError' in r) return r;
  const { opened, session } = r;
  return ok(
    JSON.stringify(
      {
        ok: true,
        opened,
        reused: !opened,
        connection_id: session.connectionId,
        cols: session.cols,
        rows: session.rows,
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleSend
// ──────────────────────────────────────────────────────────────────────

/** Cap waitMs to a sane range so the LLM can't burn an entire timeout
 * window on one Send call. Min 0 (no wait), max 5s. */
const MAX_WAIT_MS = 5_000;

/**
 * Decode common escape sequences in AI input strings. Some LLMs serialize
 * tool args such that "\n" arrives as a real LF (0x0a), others surface it
 * literally as the 2-byte "\\n". Real bytes are preserved (the regex only
 * matches the literal 2-char forms), so this is safe to apply uniformly.
 *
 * Recognized: \n \r \t \0 \\ \" \xHH (2-hex). Unknown sequences such as
 * \q pass through unchanged.
 */
export function unescapeAiInput(text: string): string {
  return text.replace(/\\(x([0-9a-fA-F]{2})|.)/g, (_match, full: string, hex?: string) => {
    if (hex) return String.fromCharCode(parseInt(hex, 16));
    switch (full) {
      case 'n': return '\n';
      case 'r': return '\r';
      case 't': return '\t';
      case '0': return '\0';
      case '\\': return '\\';
      case '"': return '"';
      case "'": return "'";
      default: return '\\' + full;
    }
  });
}

async function sendInput(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const rawText = typeof input.input === 'string' ? input.input : '';
  if (rawText.length === 0) {
    return err('SshConsoleSend: input is required (non-empty string).');
  }
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleSend: this tool requires a local task context (ctx.taskId).');
  }

  // Resolve the target session: explicit connection_id (exact lookup, no
  // auto-open), else the agent's current-connection pointer, else the
  // task's sole live session, else an ambiguity/no-session error. See
  // resolveTargetSession for the full precedence (Task 4: a task can now
  // hold several live sessions at once).
  const connectionIdInput =
    typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
  const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
  if (!resolved.ok) {
    return resolveErrorToToolResult('SshConsoleSend', resolved);
  }
  const { session, connectionId } = resolved;

  // Many LLMs serialize tool args correctly so "\n" in input means an
  // actual LF byte. But some surface escape sequences *literally*
  // (the tool args carry the 2-byte sequence "\\n" instead of "\n"),
  // and shells don't treat literal "\n" as Enter. Unescape common
  // sequences (\n \r \t \0 \\ \" \xNN) so AI input behaves as the
  // caller intended regardless of which encoding path the LLM took.
  // Real LF/CR bytes pass through unchanged (the regex only matches
  // the 2-byte "\\X" forms).
  const text = unescapeAiInput(rawText);

  // Auto-append \n when input clearly looks like a shell command missing its
  // newline (printable text, length >= 2, no existing line terminator, no
  // control byte that would indicate TUI / completion / SIGINT). The agent
  // frequently forgets to add \n and the shell then buffers the bytes as
  // partial readline without executing. Auto-append makes this the safe
  // default; agents that need raw partial input (sudo password, vim normal
  // mode keys, etc.) must include a control byte or shorter sequence.
  const hasLineTerminator = /[\r\n]/.test(text);
  const hasControlChar = /[\x00-\x08\x0b-\x1f\x7f]/.test(text);
  const autoNewlineAppended = text.length >= 2 && !hasLineTerminator && !hasControlChar;
  const sendText = autoNewlineAppended ? text + '\n' : text;

  // Length cap (UTF-8 byte length, not character count — matches the
  // raw bytes we'll write to the PTY).
  const bytesToSend = Buffer.byteLength(sendText, 'utf8');
  const maxBytes = sub.config.console.maxInputBytesPerSend;
  if (bytesToSend > maxBytes) {
    return err(
      `SshConsoleSend: input is ${bytesToSend}B but max_input_bytes_per_send=${maxBytes}.`,
    );
  }

  // Preflight to get the canonical connection for deny-list eval + audit
  // logging (latest snapshot in case admin policy changed since the
  // session was opened).
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.send',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  // Deny check (built-in + per-connection patterns). Line-wise so the
  // LLM gets actionable feedback ("line 2 matched rm-rf").
  const denyResult = checkConsoleInput(
    sendText,
    connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null,
    connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null,
  );
  if (!denyResult.ok) {
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.input_rejected',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: {
          reason: denyResult.reason,
          line_index: denyResult.lineIndex,
          matched: denyResult.matched,
        },
      },
      'denied',
    );
    return err(
      `SshConsoleSend: input rejected by ${denyResult.reason} (line ${denyResult.lineIndex + 1}, pattern ${denyResult.matched ?? 'n/a'}).`,
    );
  }

  // Record bytes_before so we can compute new_output_bytes after wait.
  const outputBytesBefore = session.totalOutputBytes;

  // Ctrl-C interrupt guard: if the payload contains \x03 and the session
  // looks like it is still running (small idleMs / recent AI input /
  // young session), block the write and ask the caller to confirm.
  if (sendText.includes('\x03')) {
    const liveness = {
      idleMs: session.idleMs(),
      msSinceAiInput: Date.now() - session.lastAiInputAt,
      sessionAgeMs: Date.now() - session.startedAt,
    };
    if (shouldGuardInterrupt(liveness, input.confirm_interrupt === true)) {
      return ok(
        JSON.stringify({
          ok: false,
          interrupt_blocked: true,
          require_confirm: true,
          reason: 'command appears to still be running',
          idle_ms: liveness.idleMs,
          hint: 'To wait for completion use SshConsoleRun. To really abort, resend with confirm_interrupt:true.',
        }),
      );
    }
  }

  // Write to the session as 'ai' source. ConsoleSession.write forwards
  // bytes straight to the PTY so the shell echoes them back the same
  // way it does for human keystrokes — partial input is allowed and
  // shows up on screen immediately. LF→CR normalization happens inside
  // ConsoleSession.write so the PTY treats \n as Enter.
  session.write(Buffer.from(sendText, 'utf8'), 'ai');

  // Wait so the LLM sees post-action terminal state. Cap at MAX_WAIT_MS;
  // 0 is allowed (return immediately, useful for control bytes like ^C).
  const waitMsRaw = typeof input.wait_ms === 'number' && Number.isFinite(input.wait_ms) ? input.wait_ms : 200;
  const waitMs = Math.max(0, Math.min(MAX_WAIT_MS, Math.floor(waitMsRaw)));
  if (waitMs > 0) {
    await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
  }

  const screen = session.snapshotScreen();
  const newOutputBytes = session.totalOutputBytes - outputBytesBefore;

  // (auto-newline was applied earlier — agent self-correction is no longer needed)

  // Record this as the agent's current connection now that the write
  // actually happened (the interrupt-guard early return above never
  // reaches here, since it didn't write anything).
  markAgentConnection(localTaskId, connectionId);

  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.send',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: {
        bytes_sent: bytesToSend,
        wait_ms: waitMs,
        new_output_bytes: newOutputBytes,
        ...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
      },
    },
    'success',
  );

  return ok(
    JSON.stringify(
      {
        ok: true,
        bytes_sent: bytesToSend,
        wait_ms: waitMs,
        new_output_bytes: newOutputBytes,
        screen_after: screen.text,
        cursor: screen.cursor,
        cols: screen.cols,
        rows: screen.rows,
        idle_ms: session.idleMs(),
        maybe_waiting_for_input: detectWaitingForInput(screen.text),
        ...(autoNewlineAppended ? { auto_newline_appended: true } : {}),
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleSnapshot
// ──────────────────────────────────────────────────────────────────────

/** Hard cap on scrollback bytes returned in one call — prevents the LLM
 * from blowing its context window by asking for the full ringbuffer. */
const MAX_SCROLLBACK_RETURN_BYTES = 64 * 1024;

async function snapshot(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleSnapshot: this tool requires a local task context (ctx.taskId).');
  }

  // Snapshot does NOT auto-Ensure — if there is no live session, the LLM
  // should call SshConsoleEnsure / Send first. resolveTargetSession routes
  // an omitted connection_id to the agent's current-connection pointer, the
  // task's sole live session, or a clear ambiguity/no-session error (Task 4:
  // a task can now hold several live sessions at once).
  const connectionIdInput =
    typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
  const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
  if (!resolved.ok) {
    return resolveErrorToToolResult('SshConsoleSnapshot', resolved);
  }
  const { session, connectionId } = resolved;

  // Preflight (for audit + access check; we still want to see read attempts
  // in the audit log).
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.snapshot',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  // Record this as the agent's current connection now that access has been
  // confirmed (the operation cannot fail past this point).
  markAgentConnection(localTaskId, connectionId);

  const kind = input.kind === 'scrollback' ? 'scrollback' : 'screen';

  if (kind === 'screen') {
    const screen = session.snapshotScreen();
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.snapshot',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: { kind: 'screen', cols: screen.cols, rows: screen.rows },
      },
      'success',
    );
    return ok(
      JSON.stringify(
        {
          ok: true,
          kind: 'screen',
          text: screen.text,
          cursor: screen.cursor,
          cols: screen.cols,
          rows: screen.rows,
        },
        null,
        2,
      ),
    );
  }

  // kind === 'scrollback'
  const maxBytesRaw = typeof input.max_bytes === 'number' && Number.isFinite(input.max_bytes) ? input.max_bytes : MAX_SCROLLBACK_RETURN_BYTES;
  const maxBytes = Math.max(1, Math.min(MAX_SCROLLBACK_RETURN_BYTES, Math.floor(maxBytesRaw)));
  const sb = session.snapshotScrollback({ maxBytes });
  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.snapshot',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: {
        kind: 'scrollback',
        max_bytes: maxBytes,
        byte_count: sb.byteCount,
        truncated: sb.truncated,
      },
    },
    'success',
  );
  return ok(
    JSON.stringify(
      {
        ok: true,
        kind: 'scrollback',
        text: sb.text,
        byte_count: sb.byteCount,
        truncated: sb.truncated,
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// SshConsoleRun
// ──────────────────────────────────────────────────────────────────────

/** Default timeout for SshConsoleRun: 2 minutes. */
const DEFAULT_RUN_TIMEOUT_MS = 120_000;
/** Maximum allowed timeout for SshConsoleRun: 10 minutes. */
const MAX_RUN_TIMEOUT_MS = 600_000;
/** Maximum output bytes returned to caller. */
const MAX_RUN_OUTPUT_BYTES = 32 * 1024;
/** Maximum screen_tail bytes returned to caller. */
const MAX_RUN_SCREEN_BYTES = 8 * 1024;

function clampTimeout(raw: unknown): number {
  const v = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_RUN_TIMEOUT_MS;
  return Math.min(MAX_RUN_TIMEOUT_MS, Math.max(1_000, Math.floor(v)));
}

function capTail(s: string, maxBytes: number): string {
  if (s.length <= maxBytes) return s;
  return s.slice(s.length - maxBytes);
}

/** Tiny ANSI stripper — delegates to the same patterns as ConsoleSession.stripAnsi. */
function stripAnsiSafe(s: string): string {
  return s
    .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
    .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
    .replace(/\x1b[@-Z\\^_]/g, '')
    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
}

type RunOutcome =
  | { kind: 'done'; exitCode: number }
  | { kind: 'human' }
  | { kind: 'idle' }
  | { kind: 'timeout' };

async function run(
  input: Record<string, unknown>,
  ctx: ToolContext,
  sub: SshSubsystem,
): Promise<ToolResult> {
  const command = typeof input.command === 'string' ? input.command.trim() : '';
  if (!command) {
    return err('SshConsoleRun: command is required (non-empty string).');
  }
  const localTaskId = ctx.taskId ?? '';
  if (!localTaskId) {
    return err('SshConsoleRun: this tool requires a local task context (ctx.taskId).');
  }

  // Resolve the target session: explicit connection_id (exact lookup, no
  // auto-open), else the agent's current-connection pointer, else the
  // task's sole live session, else an ambiguity/no-session error (Task 4:
  // a task can now hold several live sessions at once).
  const connectionIdInput =
    typeof input.connection_id === 'string' && input.connection_id ? input.connection_id : undefined;
  const resolved = resolveTargetSession(sub, localTaskId, connectionIdInput);
  if (!resolved.ok) {
    return resolveErrorToToolResult('SshConsoleRun', resolved);
  }
  const { session, connectionId } = resolved;

  // Preflight for audit + access check.
  const pre = preflight({
    toolName: 'SshExec',
    connectionId,
    ctx,
    sub,
    auditAction: 'ssh.console.run',
  });
  if (!pre.ok) return pre.error;
  const { connection, actingUserId, pieceName } = pre;

  // Record this as the agent's current connection now that access has been
  // confirmed (the run below can still time out / be interrupted, but it
  // did act on this connection).
  markAgentConnection(localTaskId, connectionId);

  // Deny-check the command.
  const denyResult = checkConsoleInput(
    command + '\n',
    connection.commandDenyPatterns ? connection.commandDenyPatterns.split('\n') : null,
    connection.commandAllowPatterns ? connection.commandAllowPatterns.split('\n') : null,
  );
  if (!denyResult.ok) {
    sub.auditRepo.beginAndComplete(
      {
        action: 'ssh.console.run',
        connectionId,
        ownerId: connection.ownerId,
        actingUserId,
        pieceName,
        jobId: ctx.jobId ?? undefined,
        detail: {
          reason: denyResult.reason,
          line_index: denyResult.lineIndex,
          matched: denyResult.matched,
        },
      },
      'denied',
    );
    return err(
      `SshConsoleRun: command rejected by ${denyResult.reason} (pattern ${denyResult.matched ?? 'n/a'}).`,
    );
  }

  // Build the marker command.
  const nonce = makeNonce();
  const { text: markerText } = makeMarkerCommand(command, nonce);
  const timeoutMs = clampTimeout(input.timeout_ms);
  const idleMs = typeof input.idle_ms === 'number' && Number.isFinite(input.idle_ms) ? Math.max(0, Math.floor(input.idle_ms)) : 0;

  const startAt = Date.now();
  const humanBaseline = session.lastHumanInputAt;
  const scrollStart = session.scrollbackBytes().length;

  // Block until done, human interrupt, idle, or timeout.
  const result = await new Promise<RunOutcome>((resolve) => {
    let acc = '';
    let lastChunkAt = Date.now();
    let finished = false;

    const finish = (outcome: RunOutcome) => {
      if (finished) return;
      finished = true;
      cleanup();
      resolve(outcome);
    };

    const unsub = session.onOutput((chunk: Buffer) => {
      acc += chunk.toString('utf8');
      lastChunkAt = Date.now();
      const m = parseMarker(stripAnsiSafe(acc), nonce);
      if (m.found) finish({ kind: 'done', exitCode: m.exitCode! });
    });

    const timer = setInterval(() => {
      if (session.lastHumanInputAt > humanBaseline) return finish({ kind: 'human' });
      if (idleMs > 0 && Date.now() - lastChunkAt >= idleMs) return finish({ kind: 'idle' });
      if (Date.now() - startAt >= timeoutMs) return finish({ kind: 'timeout' });
    }, 100);

    const cleanup = () => {
      unsub();
      clearInterval(timer);
    };

    // Send the marker command to the session.
    session.write(Buffer.from(markerText, 'utf8'), 'ai');
  });

  // Build output from scrollback since scrollStart.
  const rawAll = session.scrollbackBytes().slice(scrollStart).toString('utf8');
  const output = capTail(extractOutput(stripAnsiSafe(rawAll), command, nonce), MAX_RUN_OUTPUT_BYTES);
  const screen = session.snapshotScreen();
  const screenTail = capTail(screen.text, MAX_RUN_SCREEN_BYTES);

  sub.auditRepo.beginAndComplete(
    {
      action: 'ssh.console.run',
      connectionId,
      ownerId: connection.ownerId,
      actingUserId,
      pieceName,
      jobId: ctx.jobId ?? undefined,
      detail: {
        command,
        timeout_ms: timeoutMs,
        outcome: result.kind,
        exit_code: result.kind === 'done' ? result.exitCode : null,
        output_bytes: output.length,
      },
    },
    result.kind === 'done' ? 'success' : 'success',
  );

  return ok(
    JSON.stringify(
      {
        ok: true,
        done: result.kind === 'done',
        exit_code: result.kind === 'done' ? result.exitCode : null,
        output,
        screen_tail: screenTail,
        idle_ms: session.idleMs(),
        interrupted_by: result.kind === 'human' ? 'human' : null,
        timed_out: result.kind === 'timeout',
        maybe_waiting_for_input: detectWaitingForInput(screenTail),
        marker_found: result.kind === 'done',
      },
      null,
      2,
    ),
  );
}

// ──────────────────────────────────────────────────────────────────────
// Dispatcher
// ──────────────────────────────────────────────────────────────────────

export async function executeTool(
  name: string,
  input: Record<string, unknown>,
  ctx: ToolContext,
): Promise<ToolResult | null> {
  if (!CONSOLE_TOOL_NAMES.has(name)) return null;

  const sub = getSshSubsystem();
  const gate = checkConsoleGate(sub);
  if (gate) return gate;
  // checkConsoleGate guarantees sub is non-null when it returns null.
  const subsystem = sub as SshSubsystem;

  if (name === 'SshConsoleEnsure') return ensureTool(input, ctx, subsystem);
  if (name === 'SshConsoleSend') return sendInput(input, ctx, subsystem);
  if (name === 'SshConsoleSnapshot') return snapshot(input, ctx, subsystem);
  if (name === 'SshConsoleRun') return run(input, ctx, subsystem);
  return null;
}
