/**
 * src/types.ts — FROZEN shared contracts (T2).
 *
 * Single source of truth, imported READ-ONLY by every other module.
 * Transcribed from ARCHITECTURE §3. **Dependency-free on purpose**: no `ws`,
 * no `@types/node` ambient (`NodeJS.*`), no DOM — so BOTH the backend and the
 * browser frontend can import it without dragging in each other's type space.
 *
 * Need a new shared type? Change it HERE (a coordination point). Do not
 * redeclare it locally in another module.
 *
 * Three deliberate refinements vs ARCHITECTURE §3 (for the portability goal):
 *   1. `loadConfig` takes `EnvLike` instead of `NodeJS.ProcessEnv` (process.env is assignable).
 *   2. `Session.attachedWs` is `WebSocketLike` instead of `ws`'s `WebSocket` (a real ws socket
 *      is structurally assignable); `WS_OPEN` exported for the M5 readyState guard.
 *   3. `Config.wsPath` added (invariant 8 requires the WS path to be config, not hardcoded).
 */

/* ──────────────────────── config (§3.1) ──────────────────────── */

export interface Config {
  readonly port: number; // PORT, default 3000
  readonly bindHost: string; // BIND_HOST, default '0.0.0.0'
  readonly shellPath: string; // SHELL_PATH, default process.env.SHELL ?? '/bin/zsh'
  readonly homeDir: string; // PTY cwd, default os.homedir()
  readonly idleTtlMs: number; // IDLE_TTL, default 24h
  readonly scrollbackBytes: number; // ring buffer capacity, default 2MB
  readonly maxPayloadBytes: number; // max WS frame bytes, default 1MB (L5)
  readonly wsPath: string; // WS upgrade path, default '/term' (L3; invariant 8)
  readonly maxSessions: number; // cap on concurrent PTY sessions (DoS guard), default 50
  readonly maxMsgsPerSec: number; // per-connection WS message rate cap (DoS guard), default 2000
  readonly permTimeoutMs: number; // H3: how long a held PermissionRequest waits before fallback
  readonly reapIntervalMs: number; // idle-reaper sweep interval
  readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered
  readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart
  readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1)
  // w5-access-token — optional shared access token gate (ADDITIVE, in front of the
  // Origin/CSRF model; never replaces it). undefined/empty ⇒ auth DISABLED, so LAN
  // zero-config is preserved. When set: validated at load (16–512 URL/cookie-safe
  // chars). SECRET — never log; never return over /config/ui. See src/http/auth.ts.
  readonly webtermToken: string | undefined; // WEBTERM_TOKEN
  // v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3)
  readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir]
  readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4
  readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache)
  readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo)
  readonly editorCmd: string; // EDITOR_CMD, default 'code' — POST /open-in-editor launches `<editorCmd> <repoPath>` on the host
  // v0.7 Walk-away Workbench — 21 new fields (impl: src/config.ts T-config)
  // A1 push notifications (vapid* are SECRETS — never log/expose to clients)
  readonly vapidPublicKey: string | undefined; // VAPID_PUBLIC_KEY
  readonly vapidPrivateKey: string | undefined; // VAPID_PRIVATE_KEY (secret)
  readonly vapidSubject: string | undefined; // VAPID_SUBJECT (mailto:/url)
  readonly pushStorePath: string; // PUSH_STORE_PATH, default ~/.web-terminal-push-subs.json
  readonly pushMaxSubs: number; // PUSH_MAX_SUBS, default 50
  readonly prefsStorePath: string; // PREFS_STORE_PATH, default ~/.web-terminal-prefs.json (Projects UI prefs)
  readonly notifyDone: boolean; // NOTIFY_DONE, default true
  readonly notifyDnd: boolean; // NOTIFY_DND, default false
  readonly decisionTokenTtlMs: number; // DECISION_TOKEN_TTL_MS, default = permTimeoutMs
  // A4 activity timeline
  readonly timelineMax: number; // TIMELINE_MAX, default 200
  readonly timelineEnabled: boolean; // TIMELINE_ENABLED, default true
  // A5 stuck detection
  readonly stuckTtlMs: number; // STUCK_TTL (seconds env) → ms, default 600s
  readonly stuckAlert: boolean; // STUCK_ALERT, default true
  // B1 git diff
  readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000
  readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB
  readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300
  // W3 PR + CI status chip (gh)
  readonly ghEnabled: boolean; // GH_ENABLED, default true (false → never spawns gh)
  readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff)
  // B2 statusLine telemetry
  readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000
  // W3 quick-wins (b) cost budget guard
  readonly costBudgetUsd: number; // COST_BUDGET_USD, default 0 (0/unset = disabled)
  // B3 git worktree creation
  readonly worktreeEnabled: boolean; // WORKTREE_ENABLED, default true
  readonly worktreeRoot: string | undefined; // WORKTREE_ROOT (undefined → computed)
  readonly worktreeTimeoutMs: number; // WORKTREE_TIMEOUT_MS, default 10000
  // W5 fan-out board — cap on lanes one task may be fanned across (grid-6 capacity)
  readonly maxFanoutLanes: number; // MAX_FANOUT_LANES, default 6
  // W4 git write (stage / commit / push) — the highest-risk write channel
  readonly gitOpsEnabled: boolean; // GIT_OPS_ENABLED, default true (kill-switch → all 3 routes 403)
  readonly gitOpsTimeoutMs: number; // GIT_OPS_TIMEOUT_MS, default 10000 (stage/commit exec bound)
  readonly gitPushTimeoutMs: number; // GIT_PUSH_TIMEOUT_MS, default 120000 (network-bound push)
  readonly commitMsgMaxLen: number; // COMMIT_MSG_MAX_LEN, default 5000 (message length cap)
  // B4 permission-mode relay
  readonly defaultPermissionMode: PermissionMode; // DEFAULT_PERMISSION_MODE, default 'default'
  readonly allowAutoMode: boolean; // ALLOW_AUTO_MODE, default false (SEC-M5)
  // W2 server-side PTY-inject follow-up queue
  readonly queueEnabled: boolean; // QUEUE_ENABLED, default true (routes 503 when off)
  readonly queueMaxItems: number; // QUEUE_MAX_ITEMS, default 10 (bounded depth, DoS guard)
  readonly queueItemMaxBytes: number; // QUEUE_ITEM_MAX_BYTES, default 4096 (bounded size)
  readonly queueSettleMs: number; // QUEUE_SETTLE_MS, default 1500 (drain-after-idle delay)
}

/** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */
export type EnvLike = Readonly<Record<string, string | undefined>>;

// impl anchor [src/config.ts]:
//   export function loadConfig(env: EnvLike): Config   // invalid values → throw (fail-fast)

/* ─────────────────────── protocol (§3.2) ─────────────────────── */

/** client → server. approve/reject (H3) resolve a held PermissionRequest.
 *  attach.cwd (M6) = spawn a new session in this directory ("new tab here").
 *  approve.mode (B4) = permission mode to write back when resolving a `plan`
 *  gate (e.g. approve+auto → 'acceptEdits'); omitted for ordinary tool gates. */
export type ClientMessage =
  | { type: 'attach'; sessionId: string | null; cwd?: string }
  | { type: 'input'; data: string }
  | { type: 'resize'; cols: number; rows: number }
  | { type: 'approve'; mode?: PermissionMode }
  | { type: 'reject' };

/** Claude Code activity, derived from hooks (H2). 'unknown' = no hook signal yet.
 *  'stuck' (A5) = output silent past STUCK_TTL while not idle/exited; set by
 *  manager.sweepStuck and re-armed to 'working' on the next pty output. */
export type ClaudeStatus = 'working' | 'waiting' | 'idle' | 'unknown' | 'stuck';

/** Which kind of held approval a `status` carries (B4). 'plan' = an ExitPlanMode
 *  gate (three-way approve/auto/keep-planning); 'tool' = an ordinary tool gate. */
export type PermissionGate = 'tool' | 'plan';

/** W1: a compact, BOUNDED preview of what a held tool approval would run, so a
 *  remote one-tap approval is no longer blind. Derived server-side from the hook
 *  `tool_input` (attacker-influenced) — sanitized (control/ANSI chars stripped),
 *  line-capped and byte-capped in src/http/approval-preview.ts. Discriminated on
 *  `kind`:
 *   - 'command' → a shell command string (Bash). Newlines are PRESERVED; every
 *     other control/ANSI char is stripped. The FE renders it in a <pre> via
 *     textContent only (never innerHTML).
 *   - 'diff'    → ONE synthetic DiffFile (Edit/Write/MultiEdit/NotebookEdit),
 *     rendered by public/diff.ts renderDiffFile (textContent-only, SEC-H4).
 *  `truncated` = the source exceeded the line/byte cap and was clipped. */
export type ApprovalPreview =
  | { kind: 'command'; text: string; truncated?: boolean }
  | { kind: 'diff'; file: DiffFile; truncated?: boolean };

/** server → client. exit.code = shell code; -1 when spawn never succeeded (M4).
 *  exit.reason optional normally, REQUIRED on spawn failure / abnormal exit.
 *  status (H2/H3) = Claude Code activity; `pending` true when a tool approval
 *  is held server-side and the client can approve/reject it; `gate` (B4) tells
 *  the client whether the held approval is a 'tool' or 'plan' gate.
 *  telemetry (B2) = latest statusLine telemetry broadcast for this session. */
export type ServerMessage =
  | { type: 'attached'; sessionId: string }
  | { type: 'output'; data: string }
  | { type: 'exit'; code: number; reason?: string }
  | {
      type: 'status';
      status: ClaudeStatus;
      detail?: string;
      pending?: boolean;
      gate?: PermissionGate;
      /** W1: bounded preview of the held tool's command/diff; present only on a
       *  held (pending) waiting status for a Bash/Edit-family tool. Older clients
       *  ignore it (additive + optional). */
      preview?: ApprovalPreview;
    }
  | { type: 'telemetry'; telemetry: StatusTelemetry }
  /** W2: the current pending-inject queue depth for this session. Broadcast to
   *  every attached device so all mirrors show the same "N queued" badge. Older
   *  clients ignore it (additive variant). */
  | { type: 'queue'; length: number };

/** parseClientMessage result — never throws; errors flow here (§5.3). */
export type ParseResult =
  | { ok: true; message: ClientMessage }
  | { ok: false; error: string };

export interface Dims {
  cols: number;
  rows: number;
}

// impl anchors [src/protocol.ts]:
//   export const SESSION_ID_RE: RegExp                              // UUID v4 (M7)
//   export function parseClientMessage(raw: string): ParseResult    // NEVER throws
//   export function serialize(msg: ServerMessage): string

/* ──────────────────────── origin (§3.3) ──────────────────────── */

// impl anchor [src/http/origin.ts]:
//   export function isOriginAllowed(origin: string | undefined, allowed: readonly string[]): boolean
//   host+port must both match; undefined Origin rejected by default.

/* ─────────────────── PTY abstraction (§3.4) ──────────────────── */

export interface IDisposable {
  dispose(): void;
}

/** node-pty's IEvent shape: subscribe a listener, get a disposable back. */
export type PtyEvent<T> = (listener: (e: T) => void) => IDisposable;

/** Subset of node-pty's IPty that we use. A real node-pty IPty is assignable to
 *  this; test/helpers/mock-pty.ts (T3) implements exactly this. */
export interface IPty {
  readonly pid: number;
  readonly cols: number;
  readonly rows: number;
  onData: PtyEvent<string>;
  onExit: PtyEvent<{ exitCode: number; signal?: number }>;
  write(data: string): void;
  resize(cols: number, rows: number): void;
  kill(signal?: string): void;
}

/* ────────────────────── ring buffer (§3.4) ───────────────────── */

export interface RingBuffer {
  append(chunk: string): void;
  /** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */
  snapshot(): string;
  /** last ~maxBytes of recent output, on whole-chunk boundaries (never splits an
   *  ANSI/UTF-8 sequence), for a read-only session preview. No soft-reset prefix. */
  tail(maxBytes: number): string;
}

// impl anchor [src/session/ring-buffer.ts]:
//   export function createRingBuffer(maxBytes: number): RingBuffer
//   M2: measure REAL bytes (Buffer), evict the whole oldest chunk — never split a
//   multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way.

/* ──────────────────────── session (§3.4) ─────────────────────── */

/** The minimal WebSocket the server holds per session. A real `ws` WebSocket is
 *  structurally assignable; keeping this local makes types.ts portable. */
export interface WebSocketLike {
  readonly readyState: number;
  send(data: string): void;
  close(): void;
}

/** WebSocket.OPEN. Guard every send with `ws.readyState === WS_OPEN` (M5). */
export const WS_OPEN = 1;

/** Immutable snapshot, fixed at creation (§5.2). */
export interface SessionMeta {
  readonly id: string; // crypto.randomUUID() — UUID v4
  readonly createdAt: number; // caller-supplied timestamp (injectable for tests)
  readonly shellPath: string;
}

export interface Session {
  readonly meta: SessionMeta;
  readonly buffer: RingBuffer;
  /** All currently-attached clients (multi-device mirror sharing). Empty set =
   *  detached but PTY still alive (vibe-coding core). Output/exit/status are
   *  broadcast to every client; any client can send input (shared control). */
  readonly clients: Set<WebSocketLike>;
  /** Time the LAST client left (clients became empty); null while ≥1 attached. */
  detachedAt: number | null;
  /** last pty.onData timestamp; reapIdle liveness proxy (M3). */
  lastOutputAt: number;
  /** PTY exit time; null = alive. Once set: writeInput/resize are ignored (L4),
   *  and attach replays the buffer then re-sends `exit` (L1). */
  exitedAt: number | null;
  exitCode: number | null;
  /** Claude Code activity from hooks (H2); updated by manager.handleHookEvent. */
  claudeStatus: ClaudeStatus;
  /** Spawn directory, for the /live-sessions label; null when unknown. */
  cwd: string | null;
  /** tmux session name (H1) when running under tmux, else null. */
  readonly tmuxName: string | null;
  /** Bounded, timestamped activity ring (A4); newest appended, oldest evicted at
   *  cfg.timelineMax. Mutable runtime handle on the immutable meta (like buffer).
   *  Replaced wholesale via appendEvent (never mutated in place). */
  timeline: readonly TimelineEvent[];
  /** A5: true once a stuck alert fired this round; re-armed (→false) by the next
   *  pty.onData so each silent round alerts at most once. */
  stuckNotified: boolean;
  /** W3 quick-wins (b): true once the cost-budget alert fired for this session.
   *  One-shot latch — NEVER re-armed (cost is monotonic), so the budget push +
   *  warning broadcast happen at most once per session. */
  budgetNotified: boolean;
  /** B2: latest statusLine telemetry for this session; null until first report. */
  telemetry: StatusTelemetry | null;
  /** W2: bounded FIFO of verbatim byte strings to inject when Claude next goes
   *  idle. Head fires first. Mutable runtime handle on the immutable meta (like
   *  timeline): replaced wholesale (Object.freeze of a new array), never mutated
   *  in place. Capped by cfg.queueMaxItems; each entry capped by queueItemMaxBytes. */
  queue: readonly string[];
  readonly pty: IPty;
}

// impl anchors [src/session/session.ts]:
//   createSession(cfg: Config, dims: Dims, now: number, onExit: (s: Session) => void): Session
//                                                  // spawn failure THROWS, not swallowed (M4)
//   attachWs(session: Session, ws: WebSocketLike): void             // adds a client, replays buffer (no size vote until it resizes)
//   detachWs(session: Session, ws: WebSocketLike, now: number): void // removes one client; never kills PTY
//   writeInput(session: Session, data: string): void                // no-op after exit (L4)
//   setClientDims(session: Session, ws: WebSocketLike, cols, rows): void  // PTY = latest-writer-wins (L4 no-op after exit)
//   kill(session: Session): void

/* ──────────────────────── manager (§3.5) ─────────────────────── */

/** A live (or just-exited) server session, for the /live-sessions discovery list. */
export interface LiveSessionInfo {
  id: string;
  createdAt: number;
  clientCount: number; // how many devices are currently attached
  status: ClaudeStatus;
  exited: boolean;
  cwd: string | null;
  cols: number; // current PTY size (for the manage page)
  rows: number;
  telemetry?: StatusTelemetry | null; // B2: latest telemetry for the thumbnail wall
  /** T-iOS-37: last pty.onData timestamp (epoch ms; = createdAt until first
   *  output — createSession inits it to spawn time). Serialized from the M3
   *  runtime record so clients can derive an unread watermark
   *  (lastOutputAt > local last-seen). Additive OPTIONAL: older consumers
   *  that build or read LiveSessionInfo without it stay valid. */
  readonly lastOutputAt?: number;
  /** W2: number of pending inject-queue entries (0 when empty). Additive OPTIONAL
   *  so the manage grid and /live-sessions can surface queue depth per session. */
  readonly queueLength?: number;
}

/**
 * A tmux session on this host that the running server does NOT have in its table
 * (A). It outlived the process that created it — a server restart, a different
 * server process, or the desktop app quitting.
 *
 * It has no PTY, no ring buffer and no live stream here, so it is deliberately a
 * DIFFERENT type from LiveSessionInfo rather than a flag on it: everything the
 * manage grid does with a live session (replay, status, telemetry, inject queue)
 * is unavailable, and a shared shape would invite callers to assume otherwise.
 * What it does support: being listed, previewed via `tmux capture-pane` (no
 * attach), killed, and joined — attaching with this id revives it in place
 * through the existing re-attach path, at which point it becomes a real
 * LiveSessionInfo.
 *
 * Before this existed such a session was unreachable from the UI forever: nothing
 * enumerated tmux, so an id no client still remembered could not be listed,
 * previewed, joined or killed, and the idle reaper never saw it either.
 */
export interface OrphanSessionInfo {
  id: string;
  createdAt: number; // epoch ms
  /** tmux's OWN activity clock (epoch ms) — survives server restarts, unlike the
   *  in-memory lastOutputAt, so it is the only usable age for a cleanup policy. */
  lastActivityAt: number;
  /** tmux client count > 0: attached from a real terminal or another server
   *  process. "Untracked here" does NOT mean "abandoned". */
  attached: boolean;
  cols: number;
  rows: number;
}

/* ───────────────── W5 fan-out board (parallel agent lanes) ───────────────── */

/** A group of running sessions sharing a repo/worktree-root (fan-out discovery).
 *  Derived STRING-ONLY from each session's cwd (no git exec): fan-out worktrees
 *  live under `<repo>-worktrees/`, so sessions whose cwd shares that parent
 *  cluster into one group. impl: src/http/session-groups.ts groupSessionsByRepo. */
export interface SessionGroup {
  repoRoot: string; // derived repo dir (parent of the *-worktrees folder, or the cwd)
  label: string; // basename(repoRoot)
  sessions: LiveSessionInfo[]; // members, newest-first (already the manager.list order)
}

/** Launch options the FE passes to TabApp.launchFanout (FE-internal, but typed
 *  shared so the form and the launcher agree on ONE shape). */
export interface FanoutLaunchOpts {
  prompt: string;
  lanes: number; // 2..maxFanoutLanes (clamped to grid-6 capacity + the DoS cap)
  branchBase: string; // slug; lanes get `${branchBase}-lane-${i}`
  mode?: PermissionMode; // reuse PermissionMode
}

/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */

/** One running session belonging to a project (live-sessions归并 by cwd).
 *  Mirrors LiveSessionInfo fields so buildProjects can map directly. */
export interface ProjectSessionRef {
  id: string;
  title?: string; // tab title / derived label (e.g. 'claude', 'shell'); optional
  status: ClaudeStatus; // reuse hook status (working|waiting|idle|unknown)
  clientCount: number; // mirror devices currently attached
  createdAt: number;
  exited: boolean;
  cwd?: string; // w6/G7: where it runs — lets the UI attribute it to a worktree
}

/** A discovered project (git repo or recently-used cwd) for the Projects panel.
 *  impl: src/http/projects.ts buildProjects(cfg, liveSessions) — T-PM2. */
export interface ProjectInfo {
  name: string; // repo directory name
  path: string; // absolute path
  isGit: boolean;
  branch?: string; // current branch (git repos only)
  dirty?: boolean; // uncommitted changes (when projectDirtyCheck)
  lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key
  // W3 quick-wins (a) sync chip — best-effort git ahead/behind vs @{u} + last commit.
  ahead?: number; // commits on HEAD not on @{u} (git rev-list, right count)
  behind?: number; // commits on @{u} not on HEAD (git rev-list, left count)
  lastCommitMs?: number; // git log -1 --format=%ct * 1000 (HEAD commit time)
  sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty)
}

/** One entry from `git worktree list --porcelain` (project detail view). */
export interface WorktreeInfo {
  path: string; // worktree absolute path
  branch?: string; // branch name (refs/heads/x → x); undefined when detached
  head?: string; // short HEAD sha
  isMain: boolean; // the repo's primary worktree (listed first)
  isCurrent: boolean; // this worktree is the requested project path
  locked?: boolean; // `git worktree lock`ed
  prunable?: boolean; // git considers it prunable (gone working tree)
}

/** w6/G1: upstream sync state for one repo or worktree (impl: src/http/projects.ts
 *  readSyncState). Every field degrades independently — no upstream, detached HEAD,
 *  empty repo and never-fetched are all normal, and each leaves its field undefined.
 *
 *  `ahead` is always trustworthy (local refs only). `behind` is only as fresh as
 *  `lastFetchMs`, because `@{u}` is a locally cached remote ref that a fetch is the
 *  only thing that moves — the UI MUST NOT render a stale `behind: 0` as "in sync".
 *  Likewise `upstream === undefined` means "nothing to compare against", which is
 *  NOT the same as "nothing to push" and must never render as synced. */
export interface SyncState {
  upstream?: string; // e.g. 'origin/develop'; undefined ⇒ branch tracks nothing
  ahead?: number; // commits on HEAD not on @{u}
  behind?: number; // commits on @{u} not on HEAD — trust only with a fresh lastFetchMs
  lastFetchMs?: number; // FETCH_HEAD mtime; undefined ⇒ never fetched
  detached?: boolean; // HEAD is not on a branch ⇒ no branch, no ahead/behind
  head?: string; // short sha, ONLY when detached — the cell's sole identity
}

/** w6/G7: the git state of ONE worktree, fetched lazily per row.
 *  impl: src/http/projects.ts buildWorktreeState — GET /projects/worktree/state. */
export interface WorktreeState {
  path: string;
  branch?: string;
  sync?: SyncState;
  dirtyCount?: number;
}

/** Detailed view of one project: branch/worktrees + its running sessions.
 *  impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions).
 *  NOTE: fields here are decoded by the Android/iOS clients — additive only. */
export interface ProjectDetail {
  name: string;
  path: string;
  isGit: boolean;
  branch?: string;
  dirty?: boolean;
  dirtyCount?: number; // w6/G1: porcelain line count (same gate as `dirty`)
  sync?: SyncState; // w6/G1: git repos only; undefined for a non-git dir
  worktrees: WorktreeInfo[]; // empty for a non-git dir
  sessions: ProjectSessionRef[]; // running sessions under this path (fresh)
  hasClaudeMd: boolean; // a CLAUDE.md exists at the project root
  claudeMd?: string; // its content (truncated for display) when present
}

/** W2: outcome of SessionManager.enqueueFollowup. Success carries the new depth;
 *  failure names the reason so the HTTP route can pick the right status code
 *  (unknown/exited → 404, full → 409). */
export type EnqueueResult =
  | { ok: true; length: number }
  | { ok: false; reason: 'unknown' | 'full' | 'exited' };

export interface SessionManager {
  handleAttach(
    ws: WebSocketLike,
    sessionId: string | null,
    dims: Dims,
    now: number,
    cwd?: string,
  ): Session;
  get(id: string): Session | undefined;
  /** Live sessions for multi-device discovery (newest first). */
  list(): LiveSessionInfo[];
  /* ── A: tmux sessions NOT in this server's table ──────────────────────────
     All four are no-ops (empty / null / false) when cfg.useTmux is off, and all
     reject an id that is not a UUID v4 or that the table already owns — a
     tracked session must go through get()/killById so its PTY and table entry
     stay in step. None of them adopt: nothing is attached, so a running TUI is
     never resized.

     The tmux-spawning ones are ASYNC: they sit on a polled path and each spawn
     costs tens of milliseconds, which done synchronously is time the event loop
     is stopped and no PTY byte reaches any terminal. */
  /** Untracked `web_*` tmux sessions, most recently active first. */
  listOrphans(): Promise<OrphanSessionInfo[]>;
  /** An orphan's current screen via `tmux capture-pane` (no attach). */
  captureOrphan(id: string): Promise<string | null>;
  /** End an orphan's shell. False when it is not ours, gone, or tracked. */
  killOrphan(id: string): boolean;
  /** Kill every UNATTACHED orphan last active before `cutoffMs`; returns the ids. */
  killOrphansIdleSince(cutoffMs: number): Promise<string[]>;
  /** How many killOrphansIdleSince would kill — for a truthful confirmation. */
  countOrphansIdleSince(cutoffMs: number): Promise<number>;
  /** Kill a session by id (manage page): close its clients, kill the PTY, drop it.
   *  Returns true if a session was found and killed. */
  killById(id: string): boolean;
  /** Set a session's Claude status (from a hook), append a timeline event (A4)
   *  when eventClass is supplied + timeline is enabled, and broadcast to all
   *  attached clients (H2/H3). gate (B4) marks a 'tool'/'plan' approval gate. */
  handleHookEvent(
    sessionId: string,
    status: ClaudeStatus,
    detail?: string,
    pending?: boolean,
    gate?: PermissionGate,
    eventClass?: string,
    toolName?: string,
    preview?: ApprovalPreview,
  ): void;
  /** B2: store the latest statusLine telemetry for a session and broadcast a
   *  `telemetry` message to all attached clients. */
  handleStatusLine(id: string, telemetry: StatusTelemetry): void;
  /** A5: on the existing reaper tick (no new timer, H4), mark live non-idle
   *  sessions whose output has been silent past STUCK_TTL as 'stuck', broadcast,
   *  and notify once per silent round (re-armed by the next pty output). */
  sweepStuck(now: number): void;
  /** reclaim when now - max(detachedAt, lastOutputAt) > idleTtlMs (M3). Returns count. */
  reapIdle(now: number): number;
  /** W2: append verbatim `text` to a session's inject queue (bounded by
   *  queueMaxItems); broadcasts the new depth. The route validates/sizes `text`
   *  before calling. Never spawns; only mutates the queue handle. */
  enqueueFollowup(id: string, text: string): EnqueueResult;
  /** W2: pop the head entry and write it to the PTY (byte-identical to a
   *  keystroke, broadcast to all mirrors); broadcasts the new depth. Returns the
   *  injected string, or null when the queue is empty / session is unknown or
   *  exited (double-guards L4). */
  drainOne(id: string): string | null;
  /** W2: clear all pending entries (cancel-all escape hatch); broadcasts depth 0.
   *  Returns false for an unknown session. */
  clearQueue(id: string): boolean;
  shutdown(): void;
}

// impl anchor [src/session/manager.ts]:
//   export function createSessionManager(cfg: Config, notifyService?: NotifyService): SessionManager
//   notifyService is injected (DI, M5) so the manager never imports push-service
//   directly (avoids a manager↔push-service cycle); sweepStuck uses it for 'stuck'.

/* ───────────── v0.7 Walk-away Workbench (FEATURE_WALKAWAY_WORKBENCH) ─────────────
 *
 * The five FE↔BE contract resolutions from PLAN_WALKAWAY_WORKBENCH §3, frozen
 * here so the frontend and backend agree on ONE spelling/shape. Field names for
 * StatusTelemetry (B2) and the value set for PermissionMode (B4) are derived
 * from the R0 statusLine / --permission-mode research findings.
 */

/* ── B4 permission-mode relay (§3.5) ── */

/** --permission-mode values the relay exposes (R0-confirmed). 'auto' is its own
 *  Claude Code mode (background safety checks), distinct from bypassPermissions
 *  (the high-risk modes dontAsk/bypassPermissions are intentionally not surfaced).
 *  Validated at the WS boundary; gated server-side by ALLOW_AUTO_MODE (SEC-M5). */
export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'auto';

/* ── A1 push notifications (§3.3, §A1) ── */

/** The proactive signals pushed to the phone (§3.3 / §A1). 'budget' (W3
 *  quick-wins b) fires once when a session's cost crosses COST_BUDGET_USD. */
export type NotifyClass = 'needs-input' | 'done' | 'stuck' | 'budget';

/** Outbound push body — ONE shape: push-service sends it, sw-push.js reads `cls`
 *  (§3.3 review #3). Minimal by design: no raw terminal output, no secrets.
 *  `token` is the per-decision capability token (only on needs-input). */
export interface PushPayload {
  sessionId: string;
  toolName?: string;
  detail?: string;
  token?: string;
  cls: NotifyClass;
}

/** A stored browser PushSubscription (persisted to a 600-perm JSON, never via GET). */
export interface PushSubscriptionRecord {
  endpoint: string;
  keys: { p256dh: string; auth: string };
  createdAt: number;
}

/** The notification provider, injected into the manager (DI, M5) so it never
 *  imports push-service directly. isEnabled() is false when VAPID keys are unset
 *  (graceful disable). notify() honors DND/NOTIFY_DONE internally. */
export interface NotifyService {
  isEnabled(): boolean;
  notify(session: Session, cls: NotifyClass, token?: string): Promise<void>;
}

/* ── B2 statusLine telemetry (§3.5, derived from R0 schema) ── */

/** Per-session telemetry derived server-side from the Claude Code statusLine
 *  stdin JSON (R0). All metric fields are optional — parsing is tolerant (missing
 *  field → undefined, never throw); `at` is the server receive time (Date.now()).
 *  Field mapping (R0 → here): context_window.used_percentage→contextUsedPct,
 *  cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
 *  model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate. */
export interface StatusTelemetry {
  contextUsedPct?: number;
  costUsd?: number;
  linesAdded?: number;
  linesRemoved?: number;
  model?: string;
  effort?: string;
  pr?: { number: number; url: string; reviewState?: string };
  rate?: { fiveHourPct?: number; sevenDayPct?: number };
  at: number;
}

/* ── A4 activity timeline (§3.1) ── */

/** Server-DERIVED semantic class for a timeline event (review #1). The raw hook
 *  name is mapped by timeline.ts deriveClass(); the FE consumes `class` directly
 *  for icon/color and never re-derives from hook names. */
export type TimelineClass = 'tool' | 'waiting' | 'done' | 'stuck' | 'user';

/** One bounded, timestamped activity entry (A4). `label` is the server-derived
 *  human phrase ("ran Bash", "edited 3 files"); `toolName` is sanitized (≤200,
 *  control chars stripped). `at` is the server Date.now() at ingest. */
export interface TimelineEvent {
  at: number;
  class: TimelineClass;
  toolName?: string;
  label: string;
}

/* ── B1 read-only git diff (§3.2) ── */

/** ONE spelling (semantic words, review #2). Parsing lives ONLY in the backend
 *  (src/http/diff.ts); public/diff.ts is render-only. */
export type DiffLineKind = 'added' | 'removed' | 'context' | 'hunk' | 'meta';

export interface DiffLine {
  kind: DiffLineKind;
  text: string;
}

export interface DiffHunk {
  header: string;
  lines: DiffLine[];
}

export type FileStatus =
  | 'modified'
  | 'added'
  | 'deleted'
  | 'renamed'
  | 'binary'
  | 'untracked';

export interface DiffFile {
  oldPath: string;
  newPath: string;
  status: FileStatus;
  added: number;
  removed: number;
  binary: boolean;
  hunks: DiffHunk[];
}

export interface DiffResult {
  files: DiffFile[];
  staged: boolean;
  truncated: boolean;
  base?: string; // echoed when the diff was against a base revision (?base=<rev>)
}

/* ── W3 PR + CI status chip (gh) ── */

/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */
export type PrAvailability =
  | 'ok'              // a PR exists for the current branch; fields below are populated
  | 'no-pr'           // gh works but the branch has no PR (or no remote/default repo)
  | 'not-installed'   // `gh` binary not found on PATH (ENOENT)
  | 'unauthenticated' // gh present but not logged in (needs `gh auth login`)
  | 'disabled'        // GH_ENABLED=0 — feature off, never spawns gh
  | 'error';          // gh spawned but failed for another reason (timeout, etc.)

/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */
export interface PrCheckSummary {
  total: number;
  passing: number;   // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS
  failing: number;   // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE
  pending: number;   // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED
}

/** GET /projects/pr result. Only present-when-'ok' fields are optional. */
export interface PrStatus {
  availability: PrAvailability;
  number?: number;
  title?: string;
  url?: string;
  state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED
  isDraft?: boolean;
  mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh
  headRefName?: string;
  baseRefName?: string;
  checks?: PrCheckSummary;
}

/* ── B3 worktree creation (§3.5) ── */

/** Result of POST /projects/worktree (B3). `error` carries a safe message only
 *  (never raw git stderr, SEC-M10); `status` is the HTTP status to return. */
export interface CreateWorktreeResult {
  ok: boolean;
  path?: string;
  branch?: string;
  status?: number;
  error?: string;
}

/** Result of DELETE /projects/worktree (W4 remove). `error` is a SAFE message
 *  only (never raw git stderr, SEC-M10); `status` is the HTTP status to return.
 *  `path` echoes git's own canonical worktree path that was removed. */
export interface RemoveWorktreeResult {
  ok: boolean;
  path?: string;
  status?: number;
  error?: string;
}

/** Result of POST /projects/worktree/prune (W4). `pruned` lists best-effort
 *  human labels of the stale worktrees git reclaimed (empty = nothing to prune,
 *  idempotent). `error` is a SAFE message only (SEC-M10). */
export interface PruneWorktreesResult {
  ok: boolean;
  pruned?: string[];
  status?: number;
  error?: string;
}

/* ── W4 git write: stage / commit / push (§w4-commit-push) ── */

/** Result of the three W4 git-write routes (POST /projects/git/{stage,commit,push}).
 *  ONE interface for all three (like CreateWorktreeResult) to keep the shared
 *  contract small. `error` carries a SAFE message only — never raw git stderr
 *  (SEC-M10); `status` is the HTTP status to return on failure. Success payloads
 *  are route-specific and all optional. */
export interface GitOpResult {
  ok: boolean;
  status?: number; // HTTP status on failure
  error?: string; // SAFE message only (never raw git stderr)
  staged?: boolean; // stage: direction applied (true = added, false = unstaged)
  count?: number; // stage: number of files affected
  commit?: string; // commit: short SHA of the new commit
  branch?: string; // push: branch that was pushed
  remote?: string; // push/fetch: remote it talked to
  lastFetchMs?: number; // fetch: FETCH_HEAD mtime after the fetch (w6/G2)
}

/* ── v0.6 Projects UI preferences (server-persisted, cross-device) ── */

/** Cross-device UI preferences for the Projects launcher (impl: src/http/prefs-store.ts).
 *  Persisted host-side so favourites + group collapse-state follow the user from
 *  laptop to phone (localStorage was per-device). Holds NO secrets — just project
 *  paths and collapse booleans; served over GET /prefs, written via PUT /prefs. */
export interface UiPrefs {
  favourites: string[]; // favourited project paths (★), replaces per-device proj-favs
  collapsed: Record<string, boolean>; // namespace group-key → collapsed? (true = hidden)
}

/* ── GET /config/ui (review #4) ── */

/** Client-readable UI configuration (GET /config/ui). `allowAutoMode` mirrors
 *  the server ALLOW_AUTO_MODE gate so the FE can hide the high-risk 'auto'
 *  permission mode when the server forbids it (SEC-M5). */
export interface UiConfig {
  allowAutoMode: boolean;
  /** W3 quick-wins (b): the cost-budget threshold (USD). Present when > 0 so the
   *  FE can derive cost-overage warn styling client-side; omitted when disabled. */
  costBudgetUsd?: number;
  /** W5 fan-out board: server-controlled cap on fan-out lanes so the FE stepper
   *  max mirrors MAX_FANOUT_LANES. Additive OPTIONAL (older clients default to 6). */
  maxFanoutLanes?: number;
}

/* ── W3 quick-wins (c) reconnect digest (GET /digest) ── */

/** One session in the "while you were away" digest — a read-side projection of a
 *  live session plus its latest telemetry/status. All fields derived, no new state. */
export interface DigestSession {
  id: string;
  title?: string; // last cwd segment
  status: ClaudeStatus;
  costUsd?: number; // telemetry.costUsd
  lastOutputAt?: number;
  finished: boolean; // status==='idle' && lastOutputAt > since
  needsInput: boolean; // status==='waiting'
  stuck: boolean; // status==='stuck'
}

/** GET /digest result — an aggregate over manager.list() since a client's
 *  last-seen timestamp. Pure read (no new state); empty when no sessions. */
export interface DigestResult {
  since: number;
  generatedAt: number;
  total: number;
  finished: number;
  needsInput: number;
  stuck: number;
  working: number;
  totalCostUsd: number;
  sessions: DigestSession[];
}

/* ── W3 quick-wins (d) recent-commits log (GET /projects/log) ── */

/** One commit from `git log` (NUL-record, US-field delimited). `at` = %ct*1000. */
export interface CommitLogEntry {
  hash: string;
  at: number;
  subject: string;
  unpushed?: boolean; // w6/G4: reachable from HEAD but not from @{u}
}

/** GET /projects/log result. `truncated` = more commits exist beyond the cap. */
export interface GitLogResult {
  commits: CommitLogEntry[];
  truncated: boolean;
  /** w6/G4: upstream short name, used to label the pushed/unpushed boundary.
   *  Undefined ⇒ nothing to compare against, so no boundary may be drawn. */
  upstream?: string;
}

/* ─────────────────────── frontend (§5/§6.3) ──────────────────── */

/** keybar.ts (T10) exports a mount fn of this shape; main.ts (T11) calls it. */
export type MountKeybar = (onSend: (data: string) => void) => void;
