/** * Session-scoped streaming layer for `spectral serve`. * * Background: prior to this module each WebSocket owned its own `AgentBridge` * instance and the routes layer enforced single-writer-wins (4001 eviction) * to keep that bridge unique per session. That model lost data on browser * refresh — the WS close torn down the spectral process mid-stream, and a re-open * couldn't recover what hadn't yet hit `agent_end` (and thus SQLite). * * New model: * - Pi lifecycle is per **Spectral session**, not per WS. * - 0..N WebSockets may attach to the same session simultaneously. Each * gets the same broadcast stream of events. * - When a WS detaches (close, error, refresh), the spectral process keeps * running. Closing every tab does NOT cancel the in-flight turn — * it runs to completion and persists on `agent_end` as before. * - On `attach`, the manager hands back a replay payload: full DB history * plus a snapshot of the currently in-flight turn (if any). The client * replays the snapshot through the same reducer it uses for live events * and continues streaming naturally. * - Persistence shape is unchanged: only the final assistant message is * written to SQLite on `agent_end`. In-flight events live in memory only. * A server crash mid-turn discards the in-flight state — acceptable * for MVP. * * Failure modes: * - spectral throws synchronously in `prompt()` → bridge surfaces as `error` * event; manager broadcasts and clears `currentTurn`. * - One subscriber's `ws.send` throws → caught, logged, removed from the * subscriber set; broadcast continues to the rest. * - `agent_end` arrives without a current turn (defensive) → broadcast * anyway so any late attachers don't get stuck. * * TODO (future): idle GC. A `SessionStream` with `subscribers.size === 0` * and no current turn could be disposed after some grace window (e.g. 5 * minutes) to release spectral resources for chronically-idle sessions. Skipped * for now — streams accumulate for the lifetime of the server process. */ import { type AgentBridgeOptions } from "./agent-bridge.js"; import type { DevProcessRegistry } from "./dev-process-registry.js"; import type { CompactionResult } from "../sdk/coding-agent/index.js"; import type { SessionStore } from "./storage.js"; import type { ImageAttachment, InProgressTurnSnapshot, ServerEvent, WireDcpLiteStatus, WireMessage, WireRunState, WireSessionMemoryDetails } from "./wire.js"; import { type SessionMemoryStatus } from "./session-stream/memory.js"; import { type InProgressTurn } from "./session-stream/turn-events.js"; export { formatCompactionTokenStats, isSkippedCompactionResult, } from "./session-stream/compaction-events.js"; export { branchMessageRole, branchMessageSignature, branchMessageTimestampMs, contentAsPersistedText, getCompactionEndPruneSkipReason, getKeptBranchMessageSignatures, isRecord, matchKeptPersistedMessageIds, prunePersistedHistoryAfterCompaction, selectUniqueCandidate, } from "./session-stream/history-prune.js"; export { applyDcpLiteRuntimeStatus, buildDcpLiteThresholds, contextPercent, dcpLiteEventFromServerEvent, defaultMemoryStatus, loadCompactionPolicy, persistObservationalMemorySnapshot, type SessionMemoryStatus, } from "./session-stream/memory.js"; export { estimateStoredMessageReplayTokens, selectHistoryForBridge, } from "./session-stream/replay-tail.js"; export { assistantMessageIdsForTurn, extractCreditsFromEventsJsonl, extractMessageMetricsFromEventsJsonl, isReplayable, snapshotTurn, } from "./session-stream/turn-events.js"; export { resolveTurnSilenceMaxRearm, resolveTurnSilenceTimeoutMs, } from "./session-stream/turn-silence.js"; /** * Anything we can `send` and `close` on. The `ws` library's WebSocket fits; * tests can pass a stub. Kept minimal so we don't drag the `ws` types in. */ export interface Subscriber { /** Send a JSON-serialized event. Implementations should be best-effort. */ send(event: ServerEvent): void; /** True when the subscriber is no longer reachable (closed/closing). */ isOpen(): boolean; } /** * Bridge-shaped object — `AgentBridge` is the only real impl; tests duck-type * via this interface. Identical contract to `BridgeLike` in `routes.ts` but * defined here too so `session-stream.ts` doesn't depend on `routes.ts`. */ export interface BridgeLike { start(): Promise; prompt(text: string, images?: ImageAttachment[]): Promise; dispose(): void; /** * Manually compact the session context via spectral's built-in compaction. * Fires compaction_start / compaction_end events through the bridge's * emit callback. Used by the Fork & Compact flow after the first * assistant turn of a forked session completes. */ compact?(options?: string | StreamCompactionOptions): Promise; /** * Optional sticky reasoning effort. Maps frontend effort strings * (xhigh|high|medium|low|minimal|none) to spectral ThinkingLevel. */ setReasoningEffort?(effort: string | undefined): void; /** * Optional sticky model selection. Phase 3 (Available Models whitelist). * Real AgentBridge implements this; test stubs may omit it (the manager * skips the call when undefined, which is exactly what tests want). * Returns true when the model is in effect; false when the bridge * surfaced an error (the manager will then drop the prompt). */ setModel?(modelId: string | null | undefined): Promise; /** * Update the session-level primary-agent override without restarting the * bridge. Takes effect on the next `before_agent_start` (next user message). * Used by the dispatcher when the UI changes the active primary agent via * PUT /api/sessions/:id/active-agent. */ setActivePrimaryAgent?(agent: string | null | undefined): void; reloadProjectBinding?(): Promise; /** * Re-read settings.json and rebuild the extension runtime in place so * native-extension enable/disable takes effect in an already-running * session (hot-reload) instead of only in newly created sessions. * MUST NOT be called while a turn is in flight: the reload rebuilds the * resource loader and swaps the tool registry / extension runner * underneath the running turn, so a tool already dispatched can resolve * against the torn-down runner. (Note: `ExtensionRunner.invalidate()` * itself only runs from `AgentSession.dispose()`, not from `reload()` — * the hazard is the runner/registry swap.) * Also a no-op before `start()` resolves (there is no session yet, and it * reads fresh settings when it is created). */ reloadExtensions?(): Promise; /** * Return the modelId of the first available model from the backend * whitelist (preserving backend sortOrder). Used as defense-in-depth * default when neither the envelope nor SQLite supply a modelId. * Returns `undefined` when no models are available. */ getFirstAvailableModelId?(): string | undefined; /** * Return current session context usage from spectral's built-in estimator. * Returns `undefined` when the bridge hasn't started yet or the model * has no context window configured. */ getContextUsage?(): { tokens: number | null; contextWindow: number; percent: number | null; } | undefined; /** True while spectral is streaming a response. */ isStreaming?(): boolean; /** True while spectral is inside an auto-retry backoff/attempt. */ isRetrying?(): boolean; /** True while spectral is doing any agent work (streaming or retrying). */ isBusy?(): boolean; getSessionBranch?(): Array<{ type: string; id: string; timestamp?: string; message?: unknown; content?: unknown; customType?: string; summary?: unknown; fromId?: string; data?: unknown; details?: unknown; firstKeptEntryId?: string; }>; getMemoryActivity?(): { phase: "idle" | "observing" | "compacting" | "reflecting" | "pruning"; inFlight: { observer: boolean; compaction: boolean; reflection: boolean; pruner: boolean; }; }; } export interface StreamCompactionOptions { customInstructions?: string; phaseBoundary?: boolean; keepRecentTokens?: number; memoryHookMode?: "inline" | "skip"; } /** Factory signature shared with `routes.ts`. */ export type BridgeFactory = (args: AgentBridgeOptions) => BridgeLike; /** * Captured launch configuration for the interval scheduler. On each idle * tick the scheduler re-launches the task using this config: either a plain * prompt re-send (loop=false) or a fresh autonomous loop (loop=true). */ export interface IntervalLaunchConfig { prompt: string; loop: boolean; loopMaxIterations?: number; loopGoal?: string; modelId?: string; reasoningEffort?: string; images?: ImageAttachment[]; } export interface SessionStream { sessionId: string; /** Resolved cwd this stream's spectral runs in (project path or fallback). */ cwd: string; bridge: BridgeLike; /** Resolves once `bridge.start()` returns; rejects on start failure. */ ready: Promise; /** True once `bridge.start()` resolved. Lets callers detect a bridge that * is still starting (its `AgentSession` does not exist yet) without * awaiting `ready`. */ started?: boolean; /** True if the bridge already failed to start; new attaches should error. */ startError: Error | null; subscribers: Set; currentTurn: InProgressTurn | null; /** Mirrors `AgentBridge.turnGeneration` — bumped whenever a `message_start` * event is received from the bridge. Used to detect whether a replayable * event belongs to a turn that was already closed. */ bridgeTurnGeneration: number; /** When non-null, replayable events from this generation or earlier are * rejected — prevents a stray `message_start` from an aborted prompt from * re-arming `currentTurn` after the turn was closed. Cleared when a new * prompt is dispatched. */ closedTurnGeneration: number | null; /** * Authoritative run-state snapshot (busy + phase + flags). This is the * single source of truth used by WS `run_state` frames, REST `busy`, and * the frontend composer. Never mutated by phase handlers directly — always * recomputed through `resolveRunState()`. */ runState: WireRunState; /** True when the SDK signalled the turn will continue (retry / length * continuation / compact-and-retry) even though `currentTurn` may already * be cleared. Keeps `busy` true through the gap. */ pendingContinue: boolean; /** In-memory mirror of the session's prompt queue. Kept in sync by * `pushQueueState`; avoids a SQLite read inside `resolveRunState`. */ queued: boolean; currentTurnSilenceTimer: ReturnType | null; /** * `toolCallId`s of subagents that started (`subagent_start`) and have not * finished yet (`subagent_end`). While non-empty the silence timer must not * close the turn: closing it would drop the subagent's remaining events * (`subagent_tool_progress`, `subagent_end`, `tool_result`, `message_end`) * on the `closedTurnGeneration` fence while it keeps burning tokens. */ activeSubagentToolCallIds: Set; /** How many times the silence timer re-armed instead of closing this turn. */ currentTurnSilenceRearmCount: number; /** * Current assistant messageId (set on `message_start`, cleared on `error` * and `agent_end`). Used for batch-persisting in-flight events so a server * crash mid-turn recovers the partial message on restart. */ currentMessageId: string | null; /** * Number of events flushed to SQLite for the current message. Used to * delta-flush only new events (avoiding O(n²) re-write on every batch). * Reset to 0 on `message_start`. */ lastFlushedEventCount: number; /** Whether the autonomous iterative loop is active for this session. */ loopActive: boolean; /** Number of autonomous iterations completed in this session. */ loopIterationCount: number; /** Original user prompt that started the loop (replayed each iteration). */ loopOriginalPrompt: string | null; /** Per-session max loop iterations (absolute hard cap is MAX_LOOP_ITERATIONS). */ loopMaxIterations: number; /** Acceptance criteria / goal for the loop. Prepended to each iteration's prompt. */ loopGoal: string | null; /** Interval scheduler state (in-memory; dies with the CLI process). */ intervalMinutes: number | null; intervalTimer: ReturnType | null; intervalNextTickAt: number | null; intervalLaunchCount: number; intervalConfig: IntervalLaunchConfig | null; /** * When set (non-null), this session was forked from another session via * "Fork & Compact". After the first assistant turn completes, the manager * triggers compaction with custom instructions referencing the user's * new message, then clears this flag. One-shot. */ forkCompactSourceId: string | null; /** True while compaction is running — blocks new prompts. */ compacting: boolean; /** Cumulative context tokens used across the session (from spectral's getContextUsage()). */ contextWindowUsed: number | null; /** Model's total context window in tokens. */ contextWindowMax: number | null; /** Last DCP-lite notification surfaced by the memory extension, when known. */ lastDcpEvent: WireDcpLiteStatus["lastEvent"] | null; /** Unsubscribe from inter-agent broker messages for this session. */ interAgentUnsubscribe?: () => void; /** * Set when an extension-settings hot-reload was requested while this * session was busy (so the reload had to be skipped). Consumed and cleared * at the top of the next `prompt()` — see * `SessionStreamManager.reloadExtensions()`. */ pendingExtensionsReload?: boolean; /** * True while `prompt()` is dispatching a turn. Set synchronously — before * the first `await` in `prompt()` — and cleared once `currentTurn` is * armed (or on every early-return path). Without it, a hot-reload could * observe the stream as idle inside `prompt()`'s await window * (`await stream.ready` → deferred reload → `setModel`) and start a * reload right before the turn is armed. Counts as busy — see * `resolveRunState()`. */ dispatching?: boolean; /** * In-flight `reloadExtensions()` promise for this stream, if any. * Concurrent triggers (e.g. a `config_patch` racing a REST refresh) await * the same promise so the session is reloaded exactly once. Cleared when * the reload settles. */ reloadingExtensions?: Promise; } /** Outcome of one per-stream extension hot-reload (see `reloadExtensions`). */ type ExtensionReloadResult = { status: "reloaded"; } | { status: "skipped"; reason: string; } | { status: "failed"; error: unknown; }; export interface AttachResult { history: WireMessage[]; totalMessageCount: number; loadedMessageCount: number; hasEarlierMessages: boolean; currentTurn: InProgressTurnSnapshot | null; /** Resolves when the underlying spectral session is ready (or rejects on start failure). */ ready: Promise; /** True when this session was created via "Fork & Compact" and has not yet * triggered its first compaction. The dispatcher includes this in * `session_ready` so the frontend can adapt the send button. */ forkCompactPending: boolean; /** Cumulative context tokens used across the session (null initially, updated on first token_usage). */ contextWindowUsed: number | null; /** Model's total context window in tokens (null if model metadata not yet available). */ contextWindowMax: number | null; /** True when context compaction is currently running for this session. */ compacting: boolean; /** Authoritative run-state snapshot for reconnect/hydration. */ runState: WireRunState; /** Interval scheduler snapshot for reconnect/UI resync. */ intervalActive: boolean; intervalMinutes: number | null; intervalNextTickAt: number | null; intervalLaunchCount: number; intervalHasLoop: boolean; intervalGoal: string | null; } export interface SessionStreamManagerOptions { store: SessionStore; /** * Fallback cwd used ONLY when a session has no resolvable project (which * shouldn't happen in normal operation since FK enforces it). Sessions * with a valid project always run spectral against `project.path`. */ cwd: string; /** * Backend base URL — threaded into every AgentBridge so spectral proxies all * inference through `${backendUrl}/v1` instead of reading * `~/.spectral/agent/auth.json`. Required in production; tests that supply a * `bridgeFactory` may pass any non-empty placeholder. */ backendUrl: string; /** * Machine JWT — used as the Bearer credential spectral sends to the backend * proxy on every inference call. See `backendUrl` for context. */ machineJwt: string; /** * Machine teamId — fallback used to resolve cloud (team-scoped) agents * when a session's project has no Studio binding (and therefore no * `studioTeamId`). Without this, selecting a cloud primary agent such as * `agent007` in the UI has no effect on the running session because the * AgentBridge receives `teamId === undefined` and skips the cloud agent * refresh entirely. */ machineTeamId?: string; bridgeFactory?: BridgeFactory; agentDir?: string; /** Machine-level registry threaded into every primary-agent bridge. */ devProcessRegistry?: DevProcessRegistry; onTurnEnd?: () => void | Promise; onDispose?: () => void | Promise; /** * Called when an AgentBridge reports cloud-agent discovery failed due to * auth rejection (stale/invalid machine JWT). The caller (serve.ts) uses * this to force a machine re-registration so the next turn recovers. * When the callback returns a Promise, AgentBridge.start() awaits it so * cloud agents are populated before the session accepts prompts. */ onCloudAgentsAuthRejected?: (reason: string) => void | Promise; } export declare class SessionStreamManager { private readonly store; private readonly cwd; private readonly backendUrl; /** Mutable so serve.ts can update it after a forced machine re-registration. */ private machineJwt; private machineTeamId; private readonly bridgeFactory; private readonly agentDir; private readonly devProcessRegistry; private readonly unsubscribeDevProcessEvents; private readonly onTurnEnd; private readonly onDispose; private readonly onCloudAgentsAuthRejected; private readonly broker; private readonly streams; /** * In-flight `attach` per session. Serializes concurrent attaches so the * `subscribe` + first `client_message` race cannot create two independent * streams/bridges (and therefore two subscribers that would duplicate every * broadcast back to the browser). */ private readonly attachInFlight; private readonly managerCompactions; private disposed; constructor(opts: SessionStreamManagerOptions); /** * Update the machine JWT used by all future AgentBridge instances. * Called by serve.ts after a forced machine re-registration so new * sessions don't reuse a stale/expired JWT. * * Does NOT affect already-running sessions — those bridges were created * with the old JWT and will pick up the new one on their next reconnect. */ updateMachineJwt(jwt: string, teamId?: string): void; /** * Attach a subscriber to a session. Lazily creates the underlying spectral * session on first attach. The caller is responsible for sending the * initial `session_ready` frame using the returned replay payload (this * keeps wire-protocol concerns in the routes layer). * * The first-attach full-history read goes through the adapter's paged * async `queryAll` path so a large multi-MB event-blob scan does not block * the Node event loop for the duration of a synchronous `better-sqlite3` * `stmt.all()` call. * * Throws if the session id is unknown in SQLite (caller should turn this * into a wire-level error frame + close). */ attach(sessionId: string, subscriber: Subscriber): Promise; /** First attach for a session: create the stream/bridge if needed, then add * the subscriber and build the replay payload. */ private attachOnce; /** Fast path for a second (or later) concurrent/queued attach: the stream is * already live, so only add this subscriber and re-read the bounded replay * tail. */ private attachExisting; /** Shared replay-payload computation + subscriber registration. */ private buildAttachResult; /** * Detach a subscriber. Idempotent. Does NOT dispose the underlying spectral * session — even when subscribers reach zero, the in-flight turn must * complete and persist. */ detach(sessionId: string, subscriber: Subscriber): void; /** True if the session has an in-flight turn (manager-side; not WS-side). */ hasActiveTurn(sessionId: string): boolean; /** * Return the set of session IDs that currently have an in-flight turn. * Cheap O(streams) scan used by the list-project / list-sessions endpoints * to enrich responses with `hasActiveTurn` markers so the frontend can * render a yellow pulsating dot next to active sessions/projects. */ getActiveTurnSessionIds(): Set; /** True if the session currently has active agent work (not just an open turn). */ isBusy(sessionId: string): boolean; /** Current authoritative run-state snapshot, or undefined for unknown sessions. */ getRunState(sessionId: string): WireRunState | undefined; /** * Set of session IDs with active agent work (streaming, retrying, * compacting, looping, or a pending continuation). Superset of * `getActiveTurnSessionIds`; used by REST list enrichment. */ getBusySessionIds(): Set; /** * Re-read `.aexol/aexol.jsonc` on every active bridge so the `X-Project-Id` * header on proxied LLM calls reflects the latest studio binding. Called * after `/agent` bind/unbind so agent-session credits are attributed to the * correct project without restarting the session. */ reloadProjectBindings(): Promise; /** * Hot-reload extension settings (`settings.json`) on every idle, attached * session so enabling/disabling a native extension takes effect without * restarting the session or waiting for a new session to be created. * * Busy sessions (in-flight turn, compaction, loop, pending continuation, * or a `prompt()` dispatch in progress) are SKIPPED, never queued: the * reload swaps the tool registry / extension runner underneath the * running turn, so a dispatched tool can resolve against a torn-down * runner. Those sessions pick up the new settings the next time a * bridge/session is constructed (or on the next explicit refresh while * idle, or — via `pendingExtensionsReload` — at their next prompt). * * Failures are isolated per stream — one broken session (including one * whose bridge throws synchronously) must not abort the remaining * reloads. Failed streams are re-marked pending so the reload is retried * at their next `prompt()`. * * Concurrent triggers for the same stream share a single reload (the * in-flight promise is stored on the stream) — see * `reloadStreamExtensions()`. * * Returns the number of streams reloaded and deferred. */ reloadExtensions(): Promise<{ reloaded: number; deferred: number; }>; /** * True when an extension hot-reload must not touch this stream right now: * a turn is in flight, a `prompt()` dispatch is in progress (R1), or the * session is otherwise busy (compaction, loop, pending continuation). */ private isReloadBlocked; /** * Hot-reload extension settings on ONE stream, deduped. * * Dedupe (R5): while a reload for this stream is in flight, every caller * (REST refresh racing a `config_patch`, or `prompt()`) awaits the same * promise, so the session is reloaded exactly once. The slot is released * when the reload settles. * * Never rejects: the outcome is returned so the caller can decide how to * notify. On success `pendingExtensionsReload` is cleared (R3); on failure * it is re-armed so the reload is retried at the next `prompt()` (R4). */ private reloadStreamExtensions; /** * Push a new session-level primary-agent override to the running bridge * (if any) without restarting it. The `before_agent_start` hook reads the * override on every turn, so the change takes effect on the next user * message. When no bridge is live yet, the value is persisted to SQLite by * the caller and picked up when the bridge is lazily constructed. */ updateActivePrimaryAgent(sessionId: string, agent: string | null | undefined): void; getSessionMemoryStatus(sessionId: string): SessionMemoryStatus; getSessionMemoryDetails(sessionId: string): WireSessionMemoryDetails; private requestManagerCompaction; compactSession(sessionId: string, options?: StreamCompactionOptions): Promise; /** * Persist a user message and forward it to spectral. Resolves after the user * message is persisted + spectral is invoked (NOT after the turn completes — * the turn lifetime is observed via the broadcast stream). * * Broadcast ordering: * 1. user message persisted to SQLite * 2. `user_message_appended` broadcast to all subscribers (including * the originating tab) * 3. new `currentTurn` opened * 4. `bridge.prompt()` invoked (events arrive asynchronously and are * buffered + broadcast as they come) * * Sticky model selection (Phase 3 — Available Models whitelist): * - When `modelId` is provided, we apply it via `bridge.setModel()` and * persist to SQLite for cross-restart recovery, BEFORE invoking * `bridge.prompt()`. If `setModel` fails (unknown model, registry * unavailable, agent-side error) the bridge has already emitted an * `error` wire event and we drop the prompt to avoid running it * against the wrong model. * - When `modelId` is omitted, we look up SQLite. If a previous turn * persisted a value, we reapply it on this turn (this is the * cross-restart recovery path: a fresh server process has lost spectral's * in-memory model state, so we re-pin from durable storage). * - When neither envelope nor SQLite have a value, we leave model * selection to spectral's own settings file (pre-Phase-3 behaviour). */ prompt(sessionId: string, content: string, modelId?: string, images?: ImageAttachment[], reasoningEffort?: string, opts?: { skipPersistence?: boolean; }): Promise; /** * Body of `prompt()` — everything that happens after the stream is * resolved and the dispatch marker is armed. Split out so `prompt()` can * clear the marker in a `finally` (see R1 in `prompt()`). */ private dispatchPrompt; /** * Tear down everything. Best-effort: disposes every bridge, drops all * subscribers. After this the manager is unusable. */ dispose(): void; /** Test/inspection helper: how many streams are currently tracked. */ streamCount(): number; /** * Count of sessions with an in-flight turn (i.e. a `currentTurn` set). * Used by `gracefulShutdown` to decide whether to keep waiting before * tearing down — a non-zero count means at least one assistant response * is mid-stream and we'd rather let it finish (within the grace window) * than orphan a half-streamed message in the UI. * * Cheap O(streams) scan; we only call it ~50× during a 5 s graceful * shutdown so the linear walk is fine. */ activeTurnCount(): number; /** * Cancel the in-flight turn for a session (user pressed Stop in the UI). * Disposes the agent bridge and broadcasts `agent_end` so all subscribers * see the turn close. The stream itself is kept alive — the next user * message (via `prompt()`) will lazily create a fresh bridge. * * Idempotent: if no stream exists for the session, or no turn is in * flight, this is a no-op. */ cancelTurn(sessionId: string): void; /** * Tear down a single session's stream — disposes the agent bridge and clears * subscribers. Idempotent. Called by the routes layer right before * `DELETE /api/sessions/:id` so the SQL cascade doesn't leave a zombie * spectral process driving events at a session that no longer exists. * * Does NOT remove the session from the store — that's the caller's job. */ disposeSessionStream(sessionId: string): void; /** * Tear down every stream whose session belongs to the given list of ids. * Used by the project-delete path: the route layer reads the project's * session ids from `deleteProject()` and passes them here BEFORE the SQL * cascade fires, so no spectral process ever observes the FK cascade. */ disposeProjectStreams(sessionIds: readonly string[]): void; /** * Set the autonomous iterative loop state for a session. * * When `active` is true, the manager replays `originalPrompt` after each * `agent_end` event — the agent sees its own file changes from prior * iterations and iteratively improves its solution (Ralph Wiggum pattern). * The loop stops when the agent emits `` in its response or the * safety iteration limit is reached. */ setLoopActive(sessionId: string, active: boolean, originalPrompt?: string, maxIterations?: number, goal?: string): void; /** * Arms the interval scheduler for a session. The first launch is assumed * to have already happened (via the normal prompt path); this only sets * up the recurring `setInterval` that re-launches on each idle tick. * Replaces any previously-armed scheduler for the session. */ armInterval(sessionId: string, config: IntervalLaunchConfig, intervalMinutes: number): void; /** * Fires the next interval occurrence immediately if the session is idle, * and resets the cadence so the following tick is +N minutes from now. * If the session is busy, this is a no-op (the cadence is still reset). */ triggerIntervalNow(sessionId: string): void; /** Disarms the interval scheduler for a session. */ stopInterval(sessionId: string): void; /** True when the interval scheduler is armed for this session. */ isIntervalArmed(sessionId: string): boolean; private getIntervalEventSnapshot; private scheduleIntervalTick; private clearIntervalTimer; private clearCurrentTurnSilenceTimer; /** Clear subagent-in-flight bookkeeping (used by the silence safety net). */ private resetSubagentTracking; private armCurrentTurnSilenceTimer; private handleCurrentTurnSilenceTimeout; private intervalTick; /** * Fork & Compact: trigger compaction after the first assistant turn of a * forked session. Uses spectral's built-in DCP compaction, which generates a summary * of older context, retaining the most recent ~20K tokens (including the * user's new message + the assistant's response). * * Custom instructions reference the most recent user message so compaction * hooks can prioritize information relevant to the current task. */ private triggerForkCompact; /** Build the next autonomous-loop prompt after optional policy-driven compaction. */ private buildLoopPrompt; private sendNextLoopIteration; private createStream; private handleBridgeEvent; /** * Resolve the authoritative run-state snapshot for a stream. This is the * ONLY place `phase`/`flags`/`busy` are computed. Phase precedence: * retrying > compacting > looping > streaming > queued > idle. */ private resolveRunState; private computeBusy; /** Recompute and broadcast the run-state snapshot to all subscribers. */ private emitRunState; /** * Deferred settle after `agent_end`/`error`/`compaction_end`. The SDK emits * continuation signals (`auto_retry_start`, `length_continuation`, * `compaction_start`) from `_handlePostAgentRun()` AFTER the `agent_end` * listener returns. `setImmediate` gives those signals a chance to set * `pendingContinue`/`compacting`/`isBusy` before we declare the run idle. */ private settleRunState; private runTurnCleanup; /** * Flush the current in-flight turn's events to SQLite for crash recovery. * Only the events accumulated since the last flush are written — we append * them to the already-stored JSONL via INSERT OR REPLACE. Called every * `BATCH_FLUSH_INTERVAL` events from `handleBridgeEvent`. * * Errors are caught, logged, and swallowed: batch persistence is a * best-effort hardening, never a failure path that should block the stream. */ private flushInFlightTurn; private broadcast; /** * Fan a machine-level event out to every currently-attached session stream. * Used for dev-process status/output frames, which are intentionally * machine-wide rather than session-scoped. */ broadcastToAllStreams(event: ServerEvent): void; /** * Push the current prompt queue state to all subscribers of a session. * Called after every queue mutation (enqueue, remove, clear, dequeue). * Safe to call when no stream exists or no subscribers — silently no-ops. */ pushQueueState(sessionId: string): void; /** * Auto-dequeue the next prompt from the persistent queue and start a * new turn. Called from the `agent_end` handler when no loop is active. * Returns true if a prompt was dequeued and a turn started. */ private maybeAutoDequeue; } //# sourceMappingURL=session-stream.d.ts.map