/** * client.ts, the SDK session-spine surface client. * * The in-process coordinator that mirrors a surface's OWN session identity * (create / resume / heartbeat / close) into the daemon-hosted session spine. * It sits NEXT TO a surface's local session truth, never replacing it: the local * store stays the offline read-model; this client mirrors identity to the daemon * and buffers ops when the daemon is unreachable. * * This is the ONE core extracted from two near-twin implementations, the TUI's * typed-client version (`goodvibes-tui` src/runtime/session-spine-client.ts) and * the agent's raw-REST version (`goodvibes-agent` src/runtime/session-spine-client.ts). * The union of their behaviors is the spec; their differences are parameterized: * * - TRANSPORT is injected. The core builds a canonical * {@link RegisterSharedSessionInput} and hands it to an injected * {@link SpineTransport}; the adapter performs the real wire call (a typed SDK * sessions client, or a hand-rolled version-tolerant REST mirror) and folds its * result into a {@link SpineResult}. The core NEVER assumes a typed client * exists, that is exactly why the agent, which compiles against a pinned npm * SDK that may predate `sessions.register`, can still use this core. * - ACTIVATION MODE is optional. Construct WITH a `transport` for * live-immediately mode (the agent, live for the whole process lifetime), or * WITHOUT one for dormant-until-`activate()` mode (the TUI, activated once its * bootstrap adopts a compatible external daemon, deactivated when the mode is * lost). * - PARTICIPANT identity, origin `kind`, queue bound and heartbeat window are * options with the verified defaults. * * Discipline (load-bearing for the interactive-latency budget): * - Every public method (register / reopen / heartbeat / close / * foldLegacyRecords) is fire-and-forget: it returns `void` SYNCHRONOUSLY and * never throws into the caller, even when the wire call rejects. Session * start/resume/heartbeat never block the render or turn path. * - Before a transport is attached every op is queued, never dropped-on-the-floor * and never attempted over a transport that does not exist. Attaching flushes * the backlog. * - Offline ops go into a bounded ring (drop-oldest); flush is idempotent because * register is an upsert. * - Heartbeat is debounced/coalesced to at most one wire call per window and omits * the title so it never renames a titled session. * - A timer-driven keepalive fires the heartbeat on a fixed cadence INDEPENDENT of * render/turn activity, so a live-but-idle surface keeps its participant * lastSeenAt fresh and never falls outside the daemon's freshness/reaper windows. */ import { logger } from '../../utils/logger.js'; import type { RegisterSharedSessionInput, SharedSessionKind, SharedSessionParticipant } from '../../control-plane/index.js'; /** * The canonical TUI participant (TRANSPORT axis). Pass as the `participant` option * when the surface is the operator terminal UI. */ export declare const TUI_SPINE_PARTICIPANT: Omit; /** * The canonical agent participant (TRANSPORT axis). `surfaceKind` stays 'service'; * the record origin `kind` ('agent') is stamped by the REST mirror server-side, not * here, so the agent leaves `recordKind` unset. */ export declare const AGENT_SPINE_PARTICIPANT: Omit; /** Honest reachability posture derived from this client's own wire attempts. */ export type SpineReachability = 'unknown' | 'online' | 'offline'; /** * Outcome of a single injected-transport op, folding the two real backends' result * vocabularies into one common core: * - `'ok'` , the daemon applied it. Reachability → online; flush the queue. * - `'offline'` , a transient connectivity fault (host unreachable). Reachability * → offline; enqueue for idempotent replay on reconnect. * - `'rejected'`, a DURABLE refusal (auth required / route missing / server error). * NOT a connectivity fault: logged, NEVER enqueued (so it can't * retry-forever), reachability left unchanged. */ export type SpineOutcome = 'ok' | 'offline' | 'rejected'; export interface SpineResult { readonly outcome: SpineOutcome; readonly error?: string | undefined; } /** * The injected async transport. Structurally satisfied by a thin adapter over the * SDK's typed HTTP sessions client (TUI) or over a hand-rolled REST mirror (agent). * The core only ever calls these two methods and reads the folded {@link SpineResult}. */ export interface SpineTransport { register(input: RegisterSharedSessionInput): Promise; close(sessionId: string): Promise; } export interface SessionSpineRecord { readonly sessionId: string; readonly project: string; readonly title?: string | undefined; readonly userId?: string | undefined; } /** * The injected log sink. `warn` is OPTIONAL rather than required: surfaces * outside this repo already construct `{ debug, info }` literals for this slot * against a published SDK, and making `warn` mandatory would break every one of * them at compile time for the sake of one disclosure line. Call it through * {@link spineWarn}, which falls back to `info` when a caller supplied a * two-method sink; the default sink (the real `logger`) always has `warn`. */ type SpineLogger = Pick & Partial>; export interface SessionSpineClientOptions { /** * The participant identity stamped onto every register/heartbeat (TRANSPORT axis). * Required, each surface passes its own (e.g. {@link TUI_SPINE_PARTICIPANT} / * {@link AGENT_SPINE_PARTICIPANT}). */ readonly participant: Omit; /** * Attach the transport at construction for LIVE-IMMEDIATELY mode (the agent is * live for the whole process lifetime). Omit for DORMANT-UNTIL-`activate()` mode * (the TUI activates once its bootstrap adopts a compatible external daemon). */ readonly transport?: SpineTransport | undefined; /** * Origin record `kind` stamped into every built input. The TUI stamps `'tui'`; * the agent leaves it unset (its REST mirror stamps `'agent'` server-side). */ readonly recordKind?: SharedSessionKind | undefined; /** * Optional reachability probe backing {@link SessionSpineClient.probeReachability} * (the agent's deferred-startup GET /status). Returns true when the host answered. * Omitted for the TUI (whose reachability rides its wire calls). Runs OFF the * interactive path. */ readonly probe?: (() => Promise) | undefined; readonly now?: () => number; readonly queueLimit?: number; readonly heartbeatMinIntervalMs?: number; readonly log?: SpineLogger; } export declare class SessionSpineClient { private readonly participant; private readonly recordKind; private readonly probeImpl; private readonly now; private readonly queueLimit; private readonly heartbeatMinIntervalMs; private readonly log; private transport; private reachability; private readonly records; private readonly queue; private lastHeartbeatAt; private heartbeatTimer; private flushing; /** The most recently registered/reopened session, the keepalive heartbeat target. */ private lastSessionId; /** * Timer-driven keepalive: fires the heartbeat on a fixed cadence INDEPENDENT of * render/turn activity, so a live-but-render-silent surface keeps its participant * lastSeenAt fresh. Each tick is just a heartbeat() call, so it rides the SAME * bounded offline-queue/reconnect handling as every other op, no new retry loop, * no faster-than-cadence attempts against a dead daemon. */ private keepaliveTimer; constructor(options: SessionSpineClientOptions); /** Honest reachability: 'unknown' until a wire call resolves, then online/offline. */ status(): SpineReachability; /** Whether a transport is currently attached. */ get active(): boolean; /** Current bounded offline-queue depth (for diagnostics / tests). */ get pendingOps(): number; /** The session the keepalive heartbeat currently targets (diagnostics/tests). */ get keepaliveSessionId(): string | null; /** * The CANONICAL "which wire rows are mine" set: every sessionId this client * has register()/reopen()'d and not yet close()'d, regardless of whatever id * a caller's own local store separately uses for the same conceptual * session. register()/reopen() send exactly the `sessionId` the caller * passes in, this client never assumes that string also matches a local * broker's own idea of the session's id. A cross-surface read facade (the * SDK's SessionUnionCache) uses this to recognize its own wire mirror by * the id ACTUALLY SENT, not by hoping a local reader's id happens to agree. */ get mirroredSessionIds(): ReadonlySet; /** * DORMANT-MODE activation: attach the transport once a compatible external daemon * has been adopted. Flushes anything queued while dormant, starts the keepalive. * Reachability stays 'unknown' until the first wire call resolves. */ activate(transport: SpineTransport): void; /** * Detach the transport (daemon mode resolved to non-external, or was lost). Ops * continue to be queued (bounded, drop-oldest) rather than dropped. */ deactivate(reason: string): void; /** CREATE: fire-and-forget initial registration (title stamped once). */ register(record: SessionSpineRecord): void; /** RESUME: fire-and-forget reopen (reopen:true, no title), the only reopen path. */ reopen(record: SessionSpineRecord): void; /** * HEARTBEAT: debounced re-register, coalesced to one wire call per window, no title, * no reopen. Safe to call on every turn/render tick, internally a no-op unless the * window has elapsed. */ heartbeat(sessionId: string): void; /** CLOSE: best-effort, fire-and-forget; tolerate a racing daemon stop. */ close(sessionId: string): void; /** * LEGACY FOLD: register each per-project record; a record whose id is in `closedIds` * is registered then closed so a locally-closed session STAYS closed in the daemon * (honest history). Idempotent, register is an upsert; closing an already-closed * record is a no-op. */ foldLegacyRecords(records: readonly SessionSpineRecord[], closedIds: ReadonlySet): void; /** Reachability probe, runs OFF the interactive path (deferred startup). Flushes on * success. A no-op returning the current status when no `probe` option was supplied. */ probeReachability(): Promise; /** Clears the pending heartbeat + keepalive timers; call on shutdown. */ dispose(): void; private startKeepalive; private stopKeepalive; private cacheHeartbeatRecord; private buildInput; private withFreshLastSeen; private dispatchRegister; private runRegister; private runClose; private enqueue; private flush; } export interface FoldLegacySpineStoreOptions { readonly storePath: string; readonly markerPath: string; readonly project: string; readonly now?: () => number; readonly log?: SpineLogger; } export interface FoldLegacySpineStoreResult { readonly folded: number; readonly skipped: boolean; } /** * Reads a surface's OWN project-scoped control-plane sessions.json and folds each * record into the daemon via the client (register upsert; closed records also * closed). Writes a marker file so subsequent runs are a no-op. Register is * idempotent, so even a marker-less re-run is safe. Only folds the store for the * project it is invoked from, the per-project discovery scope is documented, not * silently "complete" across every project a surface has ever run in. * * The marker is validated by CONTENT on every boot (see {@link hasCompletedFold}) * and written via temp-file-plus-rename (see {@link writeCompletedFoldMarker}), so * a crash mid-write can no longer leave a carcass that suppresses the fold * forever. An unreadable/absent legacy store still writes NO marker, so a later * run with a real store folds it. */ export declare function foldLegacySpineStore(client: Pick, options: FoldLegacySpineStoreOptions): FoldLegacySpineStoreResult; export {}; //# sourceMappingURL=client.d.ts.map