import type { Stage } from "@threadbase-sh/agent-types"; import type { ProgressDedupeLRU } from "./agent/dedupe"; import type { ClaudeFlagValues, EffortLevel, PermissionMode } from "./claude-flags"; import type { FeatureFlagValues } from "./feature-flags"; import type { ProviderName } from "./providers"; import type { PromptEvent, PromptSnapshot } from "./services/prompts/promptRegistry"; export type SessionStatus = "running" | "waiting_input" | "idle"; /** * Phase axis *inside* `status === "running"` — what the agent is doing during a * turn. Deliberately a separate field rather than new SessionStatus members: * VALID_STATUSES rejects unknown values and the store drops sessions outside * the requested set, so a new status string would make those sessions vanish * from already-shipped apps. Additive fields are safe; additive values in a * union a shipped client filters on are not. * * The full set is defined here even though Codex only ever emits `working` * (its status bar is binary — Ready/Working, and claiming otherwise would be * invention). Defining it up front keeps a two-valued provider from fixing the * field's shape before Claude's richer footer lands. Consumers must ignore an * unrecognised value rather than coerce it. * * This union lives in exactly one place. Two independently-maintained copies of * a TUI-derived grammar have already drifted once (tb-mobile PR #647). */ export type AgentPhase = "thinking" | "streaming" | "hooks" | "acting" | "working"; /** * Process-lifetime axis for a managed session (C1 durable session runtime). * Orthogonal to SessionStatus — see SessionResponse.lifecycle for why the two * are separate, and docs/architecture/2026-07-24-durable-session-runtime.md. */ export type SessionLifecycle = "attached" | "starting" | "detached" | "orphaned" | "resumable" | "completed" | "failed"; /** * How a SessionStatus was derived (C3). * See docs/architecture/2026-07-24-session-state-confidence.md. * * The runners already compute this at every transition — it was written to a log * line and discarded, so a status reached by a timer expiring was indistinguishable * on the wire from one reached by observing a prompt marker. */ export type StatusSource = "spawn" | "prompt-marker" | "screen-marker" | "user-input" | "process-exit" | "timeout-fallback" | "quiet-fallback" | "shutdown"; /** * How much to trust the status. * * `observed` — something in the stream or the process told us. * `inferred` — a timer expired and we picked the most likely state. * * Deliberately two buckets rather than a numeric score: a percentage would imply * a calibration we have no data to support. The point is that a guess must never * be presented as an observation. */ export type StatusConfidence = "observed" | "inferred"; /** Confidence implied by each source. Inference is exactly the timer-driven paths. */ export declare function confidenceForSource(source: StatusSource): StatusConfidence; export interface ManagedSession { isSubagent?: boolean; parentConversationId?: string | null; id: string; provider?: ProviderName; projectId?: string; projectPath: string; projectName: string; branch: string; status: SessionStatus; startedAt: Date; completedAt: Date | null; promptCount: number; lastOutput: string; failureReason?: string; /** * Machine-readable companion to `failureReason`, set only when the runner * recognised the failure. Currently just `codex_active_writer` (Codex's * single-writer lock), which the resume path maps to a structured 409 rather * than reporting a spawn as successful. Absent for every other failure. */ failureCode?: string; sessionName?: string; model?: string; /** * Reasoning-effort tier this session is running with. * * Seeded from the resolved spawn config rather than scraped, because * `session_update` is event-driven and has no screen to read. `GET * /api/sessions/:id` still prefers a live status-line scrape, which is * authoritative if the user changed it inside the terminal. */ effort?: string; account?: string; messageCount?: number; preview?: string; firstMessageText?: string; firstMessageAt?: Date; lastMessageText?: string; lastMessageAt?: Date; lastActivityAt?: Date; /** * How `status` was derived, and when (C3). Confidence is derived from the * source via confidenceForSource — storing both would let them disagree. */ statusSource?: StatusSource; statusUpdatedAt?: Date; /** * Agent phase within a running turn, scraped from the rendered screen. * Optional internally (every existing construction site predates it), but * managedToResponse emits it unconditionally as `?? null` — on the wire * absence must never be a third state, because the client merges session * frames and a merge cannot express a removed key. * * Cleared in markReady() for the running -> waiting_input turn end, which is * the only exit a runner observes on screen. Every other way out of `running` * — handleExit, putOnHold, failStartup — is enforced by SessionStore * .updateManaged() instead: a phase exists only while the status is * `running`, so leaving it clears the field. */ subStatus?: AgentPhase | null; /** * Claude Code's dim "next prompt" suggestion from the composer row, scraped * from cell attributes (see detectPromptSuggestion). Exists only while the * status is `waiting_input`; SessionStore.updateManaged() clears it on any * other status. Emitted unconditionally as `?? null` for the same reason as * `subStatus`. */ promptSuggestion?: string | null; filePath?: string; resumedFromConversationId?: string; /** * This session was seeded from the durable registry at boot, not spawned by * this run — there is no PTY behind it and never was one in this process. * * INTERNAL. It never reaches the wire: `managedToResponse` translates it into * the already-defined `ownership: "historical"` + `lifecycle: "resumable"` * pair, so no client needs to learn a new field to render a recovered session. * Cleared implicitly when a resume overwrites the stub with a real session. */ rehydrated?: boolean; /** * This live session was re-adopted from the pty-host during boot rather than * spawned by this streamer process. Internal lifecycle provenance only. */ reconciled?: boolean; /** * What this session was doing when the streamer stopped it, for a stub whose * `status` had to flatten to `idle`. Only ever set from a registry row whose * `status_source` is `shutdown` — the one source that means "we ended this", * as opposed to the agent finishing or a crash freezing the row mid-turn. */ interruptedStatus?: "running" | "waiting_input"; /** * Set once a live session's underlying persisted conversation file is * discovered after the fact (currently: fresh Codex sessions, whose * rollout id isn't known until the CLI creates its own JSONL). Distinct * from `resumedFromConversationId` (resume flow) and `conversationId` on * `SessionResponse` (stable mobile deep-link alias, always === id for the * lifetime of a live PTY) — must never be written into either of those. */ boundConversationId?: string; /** * Source conversation this session was FORKED from (`codex fork`). Distinct * from `resumedFromConversationId`: a resume continues one conversation, a * fork starts a second one whose history diverges from the source at the fork * point — and the source keeps its own owner, which is the entire point. */ forkedFromConversationId?: string; /** * Multi-agent mode only. Per-session in-memory LRU of progress event ids * seen by the webhook receiver. Used to drop Temporal-replay duplicates * before they reach the WebSocket. See spec §7.1. */ progressDedupeIds?: ProgressDedupeLRU; /** Multi-agent: current stage of the active turn (advisory; advisory wire field). */ stage?: Stage | string; /** Multi-agent: ms since the session last emitted a stage transition. */ stalledSinceMs?: number; /** Multi-agent: 1 or 2 when stage === "rework". */ reworkAttempt?: number; /** * Multi-agent: id of the in-flight turn, or null when idle. Set when * `POST /api/sessions/:id/input` accepts a request; cleared by the webhook * receiver on stage=done or terminal_failure. Undefined in PTY mode. */ currentTurnId?: string | null; /** * Multi-agent resume: stable identity of the underlying conversation * (the JSONL filename), distinct from `id` which is per-orchestrator-instance. * Undefined in PTY mode (PTY uses `resumedFromConversationId` instead). */ conversationId?: string; } export interface DiscoveredProcess { pid: number; /** * Which agent this process is running. Discovery used to find only Claude, so * every consumer could assume it; a discovered Codex session must not be * reported — or adopted — as a Claude one. */ provider: ProviderName; projectPath: string; projectName: string; branch: string; conversationId: string | null; startedAt: Date; } export interface AskOption { label: string; description: string; preview?: string; } export interface AskQuestion { question: string; header: string; multiSelect: boolean; options: AskOption[]; } export interface PermissionOption { index: number; label: string; answerKeys?: string; } export interface UserMessage { text: string; ts: number; } export type WSMessage = { type: "terminal_output"; sessionId: string; data: string; seq?: number; } | { type: "session_update"; session?: SessionResponse; sessionId?: string; turnId?: string; stage?: Stage | string; stalledSinceMs?: number; reworkAttempt?: number; } /** * Agent phase changed within a running turn. Scoped to that session's * subscribers, like terminal_output and user_message. * * A minimal frame rather than a SessionResponse copy, deliberately: * managedToResponse recomputes `elapsedMs` from `new Date()` on every call * for a live session, so a session copy would differ on every tick whether * or not the phase changed — and a client that merges frames would get a * fresh object identity several times a second, re-rendering every consumer * for the whole turn. * * `phase` is always present and is `null` when there is no phase. Absence * must never carry meaning: clients merge session state, and a merge cannot * express a removed key, so an omitted field would keep its previous value * and the indicator would latch on a finished turn. */ | { type: "session_phase"; sessionId: string; phase: AgentPhase | null; updatedAt: string; } /** * The composer's predicted next prompt changed. Scoped to the session's * subscribers and minimal like `session_phase`. `text` is always present and * `null` means cleared — absence must never carry meaning. */ | { type: "prompt_suggestion"; sessionId: string; text: string | null; updatedAt: string; } | { type: "session_list"; sessions: readonly SessionResponse[]; } | { type: "conversation_event"; sessionId: string; line: string; } | { type: "conversation_events"; sessionId: string; lines: string[]; seqs?: (number | null)[]; } | { type: "conversation_updated"; conversationId: string; messageCount: number; lastActivity: string; ownership: "external" | "managed"; } | { type: "question"; sessionId: string; toolUseId: string; questions: AskQuestion[]; } | { type: "question_cancelled"; sessionId: string; toolUseId: string; } | { type: "permission"; sessionId: string; prompt?: string; detail?: string; options: PermissionOption[]; cursor?: number; /** * Cursor-stripped identity of this gate (permissionGateKey). Echoed * verbatim, as an opaque token, to POST /:id/permission/answer. Always * present when the server has that route — its absence is how a client * detects an older server and falls back to POST /:id/input. */ contentKey: string; /** * Server-owned id of THIS gate instance. contentKey is content-derived * and cannot tell two consecutive identical gates apart; gateId can. * Echoed as an opaque token to POST /:id/permission/answer, which refuses * an answer whose gateId is not the pending instance. Additive: clients * that omit it fall back to contentKey-only identity. */ gateId: string; } | { type: "permission_cancelled"; sessionId: string; } | { type: "hold_session_result"; sessionId: string; ok: boolean; applied?: "held" | "armed" | "grace"; reason?: "permission_denied" | "unknown_when" | "no_session"; } | PromptEvent | PromptSnapshot | { type: "ping"; ts: number; } | { type: "user_message"; sessionId: string; text: string; ts: number; } | { type: "terminal_replay"; sessionId: string; lines: string[]; userMessages?: UserMessage[]; seq?: number; cols?: number; rows?: number; } | { type: "terminal_resize"; sessionId: string; cols: number; rows: number; } | { type: "session_ready"; session: SessionResponse; } | { type: "agent_output"; sessionId: string; turnId: string; role: "worker" | "reviewer" | "signoff"; content: string; partial?: boolean; reviewerOverruled?: boolean; stage?: Stage | string; reworkAttempt?: number; } | { type: "turn_failure"; sessionId: string; turnId: string; reason: string; } | { type: "cache_ready"; } | { type: "scan_progress"; scanned: number; total: number; } | { type: "cache_alert"; fingerprint: string; severity: "high" | "low"; missingCount: number; totalRows: number; detectedAt: string; sample: { id: string; title?: string; }[]; } | { type: "cache_alert_resolved"; fingerprint: string; action: CacheAlertResolveAction; } | { type: "host_pressure"; level: HostPressureLevel; reasons: HostPressureReason[]; liveAgents: number; updatedAt: string; /** Additive: Node `process.platform` of the host. Old clients ignore it. */ os?: HostPressureOs; } | { type: "host_pressure_cleared"; updatedAt: string; }; /** Coarse host-starvation level pushed on `host_pressure`. Never `ok` on the wire. */ export type HostPressureLevel = "elevated" | "critical"; /** Why the host is starved, worst-first. Enum, not English. */ export type HostPressureReason = "memory" | "event_loop" | "load" | "agents"; /** Host OS for client advice. win32 covers 32- and 64-bit Windows. */ export type HostPressureOs = "darwin" | "linux" | "win32"; /** The four cache-integrity resolution actions (POST /api/cache/alert/resolve). */ export type CacheAlertResolveAction = "prune_all" | "prune_selected" | "ignore" | "reset_rescan"; export type ServerWarmupState = "startup" | "cache_reset" | "conversation_refresh"; export interface ServerWarmingUpResponse { error: "Server is warming up"; code: "SERVER_WARMING_UP"; warmupState: ServerWarmupState; } export interface SessionResponse { isSubagent?: boolean; parentConversationId?: string | null; id: string; conversationId: string; provider?: ProviderName; projectId?: string; status: SessionStatus; projectPath: string; projectName: string; branch: string; lastOutput: string; elapsedMs: number; promptCount: number; startedAt: string; completedAt: string | null; ptyAttached: boolean; /** * Process-lifetime axis, orthogonal to `status` (C1). * * `status` answers "what is the agent doing" (running / waiting_input / * idle); `lifecycle` answers "does this process still exist and do we own * it". They were conflated before: `idle` meant finished, killed-to-save- * resources, and externally-discovered all at once, so a client could not * tell a completed session from one we terminated. * * Additive and optional — `ptyAttached` keeps its meaning (=== "attached"), * so a client that ignores this behaves exactly as it did before. `"starting"` * is additive in the same way: a session we hold no PTY for and have observed * no exit for reports it instead of the `"completed"` it used to, so a client * can tell "not attached yet" from "ended" (tb-mobile #508). */ /** * How `status` was derived and how far to trust it (C3). Additive: `status` * keeps its exact meaning, so a client ignoring these behaves as before. * An `inferred` confidence means a timer expired and we assumed — not that * anything in the stream confirmed the state. */ statusSource?: StatusSource; statusConfidence?: StatusConfidence; statusUpdatedAt?: string; lifecycle?: SessionLifecycle; /** How `lifecycle` was determined, so stale values are visible not implied. */ lifecycleSource?: "spawn" | "exit" | "probe" | "reconcile"; lifecycleUpdatedAt?: string; /** * ADDITIVE (plan Phase 5). What a recovered session was doing when the * streamer stopped it, so a client can say "interrupted mid-response" instead * of the `idle` its `status` is forced to report — a stub holds no PTY, and a * novel SessionStatus value would be dropped by `?status=` filtering on * already-shipped clients. Present only on rows whose `status_source` was * `shutdown`; older clients ignore it. */ interruptedStatus?: "running" | "waiting_input"; failureReason?: string; pid?: number; sessionName?: string; model?: string; /** * Reasoning-effort tier scraped from the live PTY status line (e.g. "high"). * Live sessions only — absent for historical/resumable conversations. */ effort?: string; /** * Active permission mode from the live PTY status line (e.g. "accept edits on"). * Live sessions only. */ permissionMode?: string; /** * Agent phase within a running turn, scraped from the rendered PTY screen. * * NOT optional, and always serialised — `null` when there is no phase. A * client that merges session frames (`{...prev, ...next}`) cannot express a * removed key, so an omitted field would keep its previous value and the * indicator would latch on a finished turn. That is the bug tb-mobile PR #647 * shipped; absence must never carry meaning here. * * Consequently this must NOT be moved into the `...(x != null && { x })` * guard block in managedToResponse: `!= null` catches null and undefined * alike and would convert an explicit clear back into absence. */ subStatus: AgentPhase | null; /** * Claude Code's predicted next prompt, or `null`. Always serialised, never * omitted — a merging client cannot express a removed key, so an absent * field would keep a stale suggestion on screen. Do not move it into a * `...(x != null && { x })` block. */ promptSuggestion: string | null; account?: string; messageCount?: number; preview?: string; firstMessageText?: string; firstMessageAt?: string; lastMessageText?: string; lastMessageAt?: string; lastActivityAt?: string; filePath?: string; resumedFromConversationId?: string; /** See `ManagedSession.forkedFromConversationId`. Additive; older clients ignore it. */ forkedFromConversationId?: string; /** See `ManagedSession.boundConversationId` — never repurposes `conversationId`. */ boundConversationId?: string; /** * Who owns the underlying process. Additive — older clients ignore it, and it * deliberately does NOT introduce a new `status` value: `VALID_STATUSES` * rejects unknown values in `?status=` and the store drops sessions outside * the requested set, so a new status string would make these sessions vanish * from already-shipped apps. * managed — this streamer spawned and holds the PTY * external — a process we discovered but do not own (read-only) * historical — a cached conversation, no process known */ ownership?: SessionOwnership; /** * Whether the process is believed to be running. Only ever "alive" when * discovery actually saw it; never guessed from file activity. */ processLiveness?: ProcessLiveness; /** * INFERRED from JSONL writes, never authoritative: "active_writing" means the * transcript grew recently, which cannot distinguish a generating agent from * one blocked on a permission gate (gates are screen-only). Absent for * sessions we own — their `status` is the authoritative signal. */ activity?: SessionActivity; } export type SessionOwnership = "managed" | "external" | "historical"; export type ProcessLiveness = "alive" | "gone" | "unknown"; export interface SessionActivity { state: "active_writing" | "quiet"; lastEventAt: string; source: "jsonl"; } export interface ConversationListResponse { conversations: unknown[]; hasMore: boolean; offset: number; total: number; } export type SessionSortKey = "startedAt" | "lastActivityAt" | "projectName" | "status"; export type SortOrder = "asc" | "desc"; export interface SessionListPage { /** Response copies, like `SessionStore.list()` — see the note there. */ sessions: readonly Readonly[]; nextCursor: string | null; total: number; } export interface SessionListQuery { limit: number; cursor?: string; sortBy: SessionSortKey; order: SortOrder; status?: SessionStatus[]; } export interface SessionCursor { k: string | number; id: string; } export interface ServerConfig { port: number; host?: string; apiKey?: string; /** 'cli' when --api-key was passed; rotation persists in-memory only and reverts on restart */ apiKeySource?: "config" | "cli"; localNoAuth?: boolean; verbose?: boolean; logMenubarRequests?: boolean; browseRoot?: string; publicUrl?: string; browserCors?: string; disableDb?: boolean; scanProfiles?: Array<{ id: string; label: string; configDir: string; enabled: boolean; emoji: string; }>; codexRoots?: string[]; cursorRoots?: string[]; scannerPersistent?: boolean; skipStartupWarmup?: boolean; ptyGracePeriodMs?: number; /** * Re-start interrupted sessions at boot instead of listing them for the user * to tap (plan Phase 7). A boolean here rather than the tri-state the loader * returns: by the time a ServerConfig is built the "never asked" case has * already resolved to false. Precedence: explicit here → `auto_resume_on_boot` * in server.yaml → false. Never enabled implicitly — it is the one setting * that starts an agent nobody asked for in that moment. */ autoResumeOnBoot?: boolean; cacheDir?: string; runtimeDbPath?: string; tailSize?: number; directoryScanDebounceMs?: number; defaultSystemPrompt?: string; codexSystemPromptEnabled?: boolean; featureFlags?: FeatureFlagValues; defaultPermissionMode?: PermissionMode; defaultModel?: string; defaultEffort?: EffortLevel; claudeFlags?: ClaudeFlagValues; claudeExtraArgs?: string; } export interface PTYManagerOptions { onOutput?: (sessionId: string, data: string) => void; onStatusChange?: (session: ManagedSession) => void; onReady?: (session: ManagedSession) => void; onPermissionChange?: (sessionId: string, gate: { prompt?: string; detail?: string; options: PermissionOption[]; cursor?: number; } | null, occurrenceId?: string) => void; /** * Fired when the agent's phase within a running turn changes, including to * `null` at turn end. Additive; absent in tests that omit it. * * Deliberately NOT routed through onStatusChange, even though that callback * already exists and is already relayed across the pty-host boundary. Its * handler writes a DB row per invocation with no same-status guard, refreshes * the scanner index, broadcasts globally, and pokes the APNs and push * notifiers — machinery built for a handful of transitions per session, not * for a signal that can fire every SCRAPE_THROTTLE_MS. * * The server must broadcast this to that session's subscribers only * (wsHub.broadcastToClients), as a minimal frame rather than a SessionResponse * copy: managedToResponse recomputes elapsedMs on every call, so a session * copy would differ every tick and re-render every client consumer of that * session for the whole turn. */ onPhaseChange?: (sessionId: string, phase: AgentPhase | null) => void; onPromptSuggestionChange?: (sessionId: string, text: string | null) => void; onLiveQuestion?: (sessionId: string, questions: AskQuestion[], occurrenceId?: string) => void; onLiveQuestionGone?: (sessionId: string) => void; onUserMessage?: (sessionId: string, text: string, ts: number) => void; logger?: import("./logger").Logger; } export interface StartSessionOptions { projectPath: string; projectName?: string; branch?: string; /** * Provider-side id to resume from, when it differs from `sessionId`. Codex * keys a fresh session by a local placeholder UUID and only learns its real * rollout id once it writes the file, so resuming needs the rollout id in * argv while the session keeps the placeholder the client navigated to. * Absent means "resume by the session id", which is what every Claude session * does — PTYManager ignores this field entirely. */ resumeId?: string; permissionMode?: PermissionMode; model?: string; effort?: EffortLevel; claudeFlags?: ClaudeFlagValues; claudeExtraArgs?: string; } export interface StartForkSessionOptions { /** * Provider-side id of the conversation to fork FROM (for Codex, the rollout * id — never a local placeholder). The forked session gets its own id; the * two identities are deliberately kept distinct. */ forkFromId: string; projectPath: string; projectName?: string; branch?: string; } export interface StartFreshSessionOptions { projectPath: string; projectName?: string; systemPrompt?: string; permissionMode?: PermissionMode; model?: string; effort?: EffortLevel; claudeFlags?: ClaudeFlagValues; claudeExtraArgs?: string; } export interface SessionRunner { start(sessionId: string, options: StartSessionOptions): Promise; startFresh(options: StartFreshSessionOptions): Promise; sendInput(sessionId: string, input: string): number; sendKeys(sessionId: string, keys: string): void; sendRawKeys(sessionId: string, keys: string): void; /** * Resize the session's PTY. A no-op for a session this runner does not own, * so a caller racing a session's exit does not have to guard the call. * * Sessions still SPAWN at the fixed `PTY_COLS`/`PTY_ROWS`: those are the size * every headless consumer (mobile's VirtualTerminal, the replay ring buffer) * assumes, and nothing here changes that default. This exists for an attached * local terminal, which has a real size of its own and is the only caller * that can know it. */ resize(sessionId: string, cols: number, rows: number): void; cancel(sessionId: string): void; killPid(pid: number): void; putOnHold(sessionId: string, signal?: NodeJS.Signals): void; getOutput(sessionId: string): string; getOutputLines(sessionId: string, maxLines: number): Promise; getInputHistory(sessionId: string): UserMessage[]; getPid(sessionId: string): number | null; getSession(sessionId: string): ManagedSession | null; hasSession(sessionId: string): boolean; listSessions(): ManagedSession[]; dispose(): void; } //# sourceMappingURL=types.d.ts.map