import type { EventEmitter } from "events"; import type { IncomingMessage, ServerResponse } from "http"; import type { WebSocket } from "ws"; import type { AgentClient } from "../../agent/agent-client"; import type { AgentConfig } from "../../agent/agent-config"; import type { ClaudeFlagValues, EffortLevel, PermissionMode } from "../../claude-flags"; import type { ConversationCache } from "../../conversation-cache"; import type { createPool } from "../../db"; import type { ExternalTailManager } from "../../external-tails"; import type { LiveSessionManager } from "../../live-session-manager"; import type { Logger } from "../../logger"; import { type ProviderName } from "../../providers"; import type { ScannerManager } from "../../scanner-manager"; import type { ResumeFailure, ResumeOutcome } from "../../server"; import type { PendingPermission, PendingQuestion } from "../../server-wiring"; import { PromptRegistry } from "../../services/prompts/promptRegistry"; import type { IdempotencyStore } from "../../services/sessions/idempotency"; import type { SessionRegistryBoot } from "../../session-registry-boot"; import type { SessionStore } from "../../session-store"; import type { SessionWatchers } from "../../session-watchers"; import type { AskQuestion, DiscoveredProcess, ManagedSession, PermissionOption, SessionResponse } from "../../types"; import type { WSHub } from "../../ws-hub"; export declare const RESUME_DISCOVERY_TIMEOUT_MS = 750; export declare const ADOPT_KILL_TIMEOUT_MS = 5000; export declare function waitForProcessExit(pid: number, timeoutMs: number, pollMs?: number): Promise; /** * Everything SessionHandlers reads from the server. Same split as * ConversationHandlersDeps: collaborators the server constructor already built * are passed by reference (Maps and Sets keep identity, so a mutation here is * the same mutation the server and its tests observe), anything bound later or * swapped by tests is a thunk, and the handful of methods that stay on * StreamerServer are late-bound calls back into it. * * Nothing here owns state. `pendingQuestions`, `pendingPermission`, * `contendedSessions`, `selfPtyEndedAt`, `sessionFileMap`, `sessionSubscribers` * and `idempotency` remain StreamerServer instance properties — tests reach * into them through `(server as any)`, and the WS/PTY callbacks that also read * them never moved. */ export type SessionHandlersDeps = { sessionStore: SessionStore; ptyManager: LiveSessionManager; wsHub: WSHub; scannerManager: ScannerManager; sessionWatchers: SessionWatchers; registryBoot: SessionRegistryBoot; externalTailManager: ExternalTailManager; idempotency: IdempotencyStore; sessionStatusBus: EventEmitter; sessionFileMap: Map; promptRegistry: PromptRegistry; pendingQuestions: Map; pendingQuestionKey: Map; pendingPermission: Map; pendingPermissionKey: Map; contendedSessions: Set; selfPtyEndedAt: Map; sessionSubscribers: Map>; agentConfig: AgentConfig; agentClient: AgentClient | null; defaultSystemPrompt: string; codexSystemPromptEnabled: boolean; cacheDir: string; cache: () => ConversationCache | null; includeSubagentSessions?: () => boolean; log: () => Logger; browseRoot: () => string | null; claudeFlags: () => ClaudeFlagValues; claudeExtraArgs: () => string | undefined; dbPool: () => Awaited> | null; dbInstanceId: () => string | null; discoveryCache: () => { entries: DiscoveredProcess[]; fetchedAt: number; } | null; setDiscoveryCache: (value: { entries: DiscoveredProcess[]; fetchedAt: number; } | null) => void; discoveryInFlight: () => Promise | null; setDiscoveryInFlight: (value: Promise | null) => void; rejectIfWarmingUp: (res: ServerResponse) => boolean; ptyAttachedIds: () => Set; withReconciledLifecycle: (sessions: readonly SessionResponse[]) => readonly SessionResponse[]; broadcastOrUnicastSessionList: (req: IncomingMessage) => void; checkSessionStartRateLimit: (ip: string) => boolean; checkSessionInputRateLimit: (sessionId: string) => boolean; spawnFlagOverrides: () => { permissionMode: PermissionMode; model: string; effort: EffortLevel; }; resolveConversationTarget: (sessionId: string) => Promise; waitForStartupOutcome: (sessionId: string, timeoutMs: number) => Promise<{ outcome: "ready" | "failed" | "timeout"; session: ManagedSession | null; }>; forgetSession: (sessionId: string) => void; abandonFailedStart: (sessionId: string) => void; armHoldWhenIdle: (sessionId: string, opts?: { ignoreWatchers?: boolean; deleteAfter?: boolean; }) => "held" | "armed" | "no_session"; enrichResumedSessionAsync: (sessionId: string, projectPath: string, conv: any) => void; findJsonlPath: (uuid: string) => string | null; readCwdFromJsonl: (filePath: string) => Promise; }; /** * The session lifecycle surface: list/get, start, resume, fork, adopt, input, * answers, permission gates, uploads, stop/cancel and session names. * * Extracted from StreamerServer so session work stops editing the server file * (see docs/plans/2026-07-12-server-ts-split.md, PR 8). State stays on the * server: this class only reads and mutates it through `deps`. */ export declare class SessionHandlers { private deps; constructor(deps: SessionHandlersDeps); private get sessionStore(); private get ptyManager(); private get wsHub(); private get scannerManager(); private get sessionWatchers(); private get registryBoot(); private get externalTailManager(); private get idempotency(); private get sessionStatusBus(); private get sessionFileMap(); private get pendingQuestions(); private get promptRegistry(); private get pendingQuestionKey(); private get pendingPermission(); private get pendingPermissionKey(); private get contendedSessions(); private get selfPtyEndedAt(); private get sessionSubscribers(); private broadcastToSession; private get agentConfig(); private get agentClient(); private get defaultSystemPrompt(); private get codexSystemPromptEnabled(); private get cacheDir(); private get cache(); private get log(); private get browseRoot(); private get claudeFlags(); private get claudeExtraArgs(); private get dbPool(); private get dbInstanceId(); private get discoveryCache(); private set discoveryCache(value); private get discoveryInFlight(); private set discoveryInFlight(value); handleListSessions(url: URL, res: ServerResponse): Promise; /** * Refresh the discovered-process list, sharing one in-flight enumeration * across concurrent callers and honouring the 15s TTL cache. */ private refreshDiscovery; handleGetSession(sessionId: string, res: ServerResponse): Promise; handleResume(req: IncomingMessage, res: ServerResponse): Promise; /** * `POST /api/sessions/:id/fork` — continue a conversation this streamer is * not allowed to resume, without touching whoever owns it. * * Codex only (`codex fork `): Claude Code has no equivalent, and there is * no safe generic fallback — quietly resuming instead would attach to the * exact writer the caller is trying to leave alone, which is the failure this * endpoint exists to avoid. * * NOT idempotent by default: every accepted call starts another Codex * process and another rollout. Clients that retry on timeout must send * `idempotencyKey`, which replays the first outcome for 10 minutes (same * store and semantics as `POST /:id/input`). */ handleFork(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; /** * Resume a session, from an HTTP request or from the boot path. * * Extracted from `handleResume` so both callers hit the **same collision * probe** (plan Phase 7c). The probe is what stops this streamer attaching to * a conversation an external terminal already owns; a second, hand-adapted * copy of this sequence in the boot path is how two agents end up appending * to one JSONL at 4am with nobody watching. * * Returns a typed reason rather than writing a response, so the HTTP caller * maps it to a status code and the boot caller logs it. */ resumeSession(opts: { sessionId: string; force?: boolean; projectName?: string; branch?: string; }): Promise; handleSendInput(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; handleLiveQuestion(sessionId: string, questions: AskQuestion[], occurrenceId?: string): void; handleJsonlQuestion(sessionId: string, toolUseId: string, questions: AskQuestion[], origin: "pty" | "jsonl"): void; handlePermissionChange(sessionId: string, gate: { prompt?: string; detail?: string; options: PermissionOption[]; cursor?: number; } | null, occurrenceId?: string): void; private permissionAnswerAdapter; private questionAnswerAdapter; /** * Answer a permission gate — the validated counterpart of POST /:id/input. * * `/input` is a raw-bytes conduit (arrow-key nav uses it too) and stays that * way; this route is the semantic one, mirroring the /answer split. Two * things make it more than validation theatre: * * - The client sends `{ contentKey, optionIndex }` and NO keystrokes. The * keys are derived here from our own copy of the gate, so the client's * key-derivation can never drift from the server's. * - `optionIndex` is a 0-based POSITION in the frame's `options[]`, NOT * `options[].index` (the digit painted on screen, 1-based and not always * contiguous). Both are small integers, so a client sending the digit * selects a different option — on "1. Yes / 2. Yes, don't ask again" the * digit for "Yes" is the position of "don't ask again". Optional * `optionLabel` binds the answer to the option the client displayed: when * present it must equal `options[optionIndex].label` or the answer is * refused as unknown_option. Absent, the position alone is trusted * (released clients). * - `contentKey` binds the answer to a specific gate. `isPermissionAnswer` * matches structurally, and approval gates repeat constantly ("2. Yes / * 3. No" for every tool call), so without this a delayed answer to gate A * could be written as gate B's answer — a user approving a bash command * they never saw, with a 200 and a normal permission_cancelled. Treat the * check as a security boundary. * * Every refusal happens BEFORE sendKeys. On success we deliberately broadcast * nothing: the PTY-side close (isPermissionAnswer in pty-manager) recognises * the bytes we just wrote and fires permission_cancelled itself. */ handlePermissionAnswer(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; /** * Is THIS gate still the one on screen? * * Deliberately stricter than questionMenuStillOpen's "is a menu up": the * staleness window this exists to cover (the ~300ms scrape throttle plus the * wait for the next PTY chunk) is exactly where pendingPermission still says * gate A while the screen has moved to gate B — and since approval gates * repeat their shape, "some gate is open" would wave that through. * * Reads 60 lines because that is the window the detector that produced the * pending gate uses (pty-manager's scrape); `detail` walks up to 6 lines * above the prompt, so a shorter window can truncate it and manufacture a * mismatch on a healthy gate. * * Best-effort, like questionMenuStillOpen: a session we hold no PTY for, or * one that raced away mid-read, is not ours to veto. */ private permissionGateStillOpen; /** * Is ANY Claude permission gate painted? Not permissionGateStillOpen's "is * THIS gate": an optionless entry has no content to compare against. The test * is pty-manager's own "box is still painted" rule (either detector sees a * gate), so this never retires an entry the detector would have kept — which * also means a numbered list left in prose reads as painted and keeps * refusing, the safe side. A shell prompt counts too: pty-manager raises it * as a card, and composer text typed over a `[y/N]` answers it. Best-effort * in the direction opposite to the answer routes: this may only UNBLOCK * input, so a session with no PTY, or a read that fails, reports a gate. */ private anyPermissionGateOnScreen; /** Deliberately narrower than /input { keys }: fixed actions, no arbitrary bytes. */ handleRawKey(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; handleSendAnswer(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; /** * Answer a normalized prompt by its opaque ids. * * Refusals are keyed by `code` — the stable machine taxonomy of the prompt * contract. The released legacy routes (`/answer`, `/permission/answer`) key * theirs by `reason` and keep doing so; a client reads whichever key belongs * to the route it called, and the two vocabularies are not merged. * * Status follows the same split as the legacy routes: a malformed or * unanswerable *request* is 400, a prompt whose *state* refuses the answer is * 409. A retry after PROMPT_TERMINAL_RETENTION_MS answers 404 * `prompt_not_found`, not the recorded outcome — the record it would replay * is gone by then. */ handlePromptAnswer(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; private questionMenuStillOpen; handleUploadFile(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; handleGetOutput(sessionId: string, res: ServerResponse): void; handleCancel(sessionId: string, res: ServerResponse): void; /** Force-kill: SIGKILL instead of /stop's graceful SIGINT. */ handleKillSession(sessionId: string, res: ServerResponse, opts?: { delete?: boolean; }): Promise; handleStopSession(sessionId: string, res: ServerResponse, opts?: { signal?: NodeJS.Signals; when?: "now" | "idle"; ignoreWatchers?: boolean; delete?: boolean; }): Promise; /** * An unused start: the user never submitted a prompt, and the conversation * cache has no row for this id (empty Codex/Claude often never write a JSONL). * `conversationId === sessionId` is not evidence of history — only the cache * is. promptCount > 0 or a cache hit keeps today's hold path. */ private shouldForgetEmptySession; private hasCachedConversationFor; /** * `when=idle`: hold now if the session is already settled, otherwise arm the * existing kill-on-idle latch (`armHoldWhenIdle`, the same one mobile's * `hold_session {when: "waiting_input"}` uses) to fire on the next natural * idle transition. Unlike `when=now`, this checks watchers UP FRONT and * reports the count rather than silently no-op'ing later — `ignoreWatchers` * skips that check (and the latch's own fire-time check) entirely. */ private handleStopSessionWhenIdle; private forgetEmptyStoppedSession; /** * Same empty-unused check stop uses (promptCount === 0 and no cache row, * including boundConversationId / resumedFromConversationId). Safe after * putOnHold: the runner may already have dropped the live session. */ forgetIfEmptyUnused(sessionId: string): void; /** * Soft-deletes the cached conversation(s) this session maps to — the * session id itself, plus any bound/resumed-from alias `hasCachedConversationFor` * already knows how to chase (a Codex placeholder id vs. its real rollout * id, etc.). Reads from `sessionStore`, not `ptyManager`, so it's safe to * call after `putOnHold` has already dropped the live session. */ softDeleteConversation(sessionId: string): void; /** * Take over a Codex conversation from the standalone TUI that holds it. * * The owner is re-probed HERE rather than trusted from the 409 that offered * the action: that pid was observed when resume was refused, possibly minutes * earlier, and pids are reused. Killing a stale pid would stop an unrelated * process, which is the one mistake this path must never make. * * Only a standalone `codex` TUI is eligible. A desktop / VS Code * `codex app-server` hosts unrelated conversations in the same process, and * an unidentified owner offers nothing to prove it does not — both refuse, * and forking stays the recovery path for them. */ private adoptCodexRolloutOwner; handleAdopt(sessionId: string, res: ServerResponse): Promise; /** * The destructive half of a takeover, shared by every path that reaches it: * stop the process that owns the conversation, prove it is gone, then respawn * the conversation under this streamer. * * Both halves matter. SIGTERM is asynchronous, and spawning a resume before * the old process has actually exited leaves two agents appending to one * transcript — the interleaved state this codebase has no way to repair. A * process that outlives the grace period aborts the takeover rather than * knowingly creating that. */ private killAndRespawn; handleStartSession(req: IncomingMessage, res: ServerResponse): Promise; handleSetSessionName(sessionId: string, req: IncomingMessage, res: ServerResponse): Promise; handleGetSessionNames(res: ServerResponse): void; } //# sourceMappingURL=sessions.handlers.d.ts.map