/** * SQLite-backed session store operations. * * CRUD operations for sessions and task work tracking backed by cleo.db. * Every entry captures project ownership before awaiting; global mirrors and * detached producers retain their independent lifecycle contracts. * * @epic T4454 * @task W1-T4 * @task T1609 insertHandoffEntry — write-once INSERT path for handoff data */ import type { Session } from '@cleocode/contracts'; import { type TerminalKey } from '../sessions/terminal-identity.js'; /** Create a new session. */ export declare function createSession(session: Session, cwd?: string): Promise; /** Get a session by ID. */ export declare function getSession(sessionId: string, cwd?: string): Promise; /** * A session's name and its T1118 owner-auth token (`cleo session start * --owner-auth`), which {@link getSession} does not project. The token is * `HMAC(password, name)`; null when the session was started without one. * * @param sessionId - Session to read. * @param cwd - Project root. * @returns The name and token, or null when the session does not exist. * @task T13787 */ export declare function getSessionOwnerAuth(sessionId: string, cwd?: string): Promise<{ name: string | null; token: string | null; } | null>; /** * How many sessions still hold a legacy `HMAC(password, name)` owner token, * for `cleo doctor` (review-2253 round 3). * * @param cwd - Project root. * @returns The count. * @task T13787 */ export declare function countLegacyOwnerTokens(cwd?: string): Promise; /** * Record a session's owner credential: the `--owner` mark (T13790), the * `--owner-auth` scrypt verifier (T13787), or both. Only the interactive * session start writes it; no env var or spawn path does. * * The owner mark is refused here, in the core write path, unless this process * has an interactive terminal and no `CLEO_AGENT_ROLE` (`ownerMarkRefusal`), * so an in-process caller of `session.start` cannot plant it. Writing a * credential also clears every legacy `HMAC(password, name)` token left by * sessions started before T13787: those are a cheap offline oracle for the * owner password. * * @param sessionId - Session to mark. * @param token - `OWNER_SESSION_MARK`, a `createOwnerAuthVerifier` record, or * `|`. * @param cwd - Project root. * @throws CleoError when the owner mark is refused. * @task T1118 * @task T13787 * @task T13790 */ export declare function setSessionOwnerAuthToken(sessionId: string, token: string, cwd?: string): Promise; /** Update a session. */ export declare function updateSession(sessionId: string, updates: Partial, cwd?: string): Promise; /** * Record the orchestrator session that spawned `sessionId` (T12502). * * This is the ONLY writer of `tasks_sessions.spawned_by_session_id` — the * trusted spawn edge the task-claim chokepoint uses to let a spawn family * pass a live lease between its members. It is write-once: an edge already * recorded is never replaced, so a later caller cannot re-parent a session * into its own family. * * @param sessionId - The spawned session. * @param spawnerSessionId - The orchestrator session performing the spawn. * @param cwd - Project root. * @returns `true` when the edge was recorded now, or already names `spawnerSessionId`. * @example * ```ts * await setSessionSpawnedBy(child.sessionId, orchestrator.id, projectRoot); * ``` * @task T12502 */ export declare function setSessionSpawnedBy(sessionId: string, spawnerSessionId: string, cwd?: string): Promise; /** * Insert a handoff entry for a session (write-once, append-only). * * The underlying `session_handoff_entries` table enforces: * - One handoff per session, checked in the insert's transaction (the * DDL has no UNIQUE on `session_id`, T13777). * - BEFORE UPDATE trigger — rows are immutable after insertion. * - AFTER INSERT trigger — mirrors value to `sessions.handoff_json` * so existing read paths continue to work without change. * * Throws `CleoError(ALREADY_EXISTS)` if a handoff already exists for this * session. The store's DDL has no UNIQUE on `session_id` (T13777), so the * check and the insert share one transaction. * * @param sessionId - The session that is being handed off. * @param handoffJson - Serialised HandoffData or DebriefData JSON string. * @param cwd - Optional working directory (resolves tasks.db location). * @task T1609 */ export declare function insertHandoffEntry(sessionId: string, handoffJson: string, cwd?: string): Promise; /** List sessions with optional filters. */ export declare function listSessions(filters?: { active?: boolean; limit?: number; }, cwd?: string): Promise; /** End a session. */ export declare function endSession(sessionId: string, note?: string, cwd?: string): Promise; /** Start working on a task within a session. */ export declare function startTask(sessionId: string, taskId: string, cwd?: string): Promise; /** Get current task for a session. */ export declare function getCurrentTask(sessionId: string, cwd?: string): Promise<{ taskId: string | null; since: string | null; }>; /** Stop working on the current task for a session. */ export declare function stopTask(sessionId: string, cwd?: string): Promise; /** Get work history for a session. */ export declare function workHistory(sessionId: string, limit?: number, cwd?: string): Promise>; /** Garbage collect old sessions (mark ended sessions as orphaned after threshold). */ export declare function gcSessions(maxAgeDays?: number, cwd?: string): Promise; /** * Get the currently active session — the most-recent `status='active'` row. * * @internal LEGACY single-process TTY fallback ONLY (T11640 · Epic T11638). * * This resolves "whoever wrote an active session to the DB most recently", * which is the WRONG identity in any multi-tenant context: under the warm * daemon (many concurrent connections) and under multi-agent spawn isolation * (many worktrees writing the same DB) it collapses every caller's identity * onto the last writer, causing session-bleed and memory scope-leakage. * * Identity-meaning callers — anything that means "the session of whoever is * calling THIS request" — MUST use {@link resolveCurrentSession} / * {@link resolveCurrentSessionId}, which resolve, in order: * 1. the daemon connection handle ({@link getCurrentConnectionSessionId}), * 2. the env-named session (`CLEO_SESSION_ID`, via * {@link resolveSessionIdFromEnv}), * 3. and only THEN fall back to this most-recent-active row. * * `getActiveSession` survives as that final tier-3 fallback and for genuine * SCAN-meaning callers (e.g. "is there any active session at all?", * `gcSessions`, the data-accessor pass-throughs). The * `lint-no-bare-get-active-session.mjs` gate bans NEW bare callsites so the * identity-vs-scan split cannot silently regress — annotate a justified scan * callsite with a trailing `// get-active-session-allowed: `. * * @param cwd - Working directory for DB resolution. * @returns The most-recent active session, or `null`. */ export declare function getActiveSession(cwd?: string): Promise; /** * Bind the calling terminal to a session (T12499 · T12500 · epic T12497). * * Keys are bound per tier ({@link resolveTerminalKeys} → provider / pane / tab): * * - the **provider** key (an agent process — `CLAUDE_CODE_SESSION_ID`, …), when present; * - the **pane** key (`TMUX_PANE`, …), when present; * - the **tab** key (`TERM_SESSION_ID`, …, or the harness-ancestor fallback) only when there * is NO pane key — a pane is its own identity and never shares its tab's. * * Every row records `bound_by_provider` = "a provider key was present". That is * what lets an agent in a tab adopt a session a HUMAN started in that tab while * two agents that each started their own session stay isolated (see * {@link resolveTerminalBoundSession}). * * Refuses to bind a session id that has no row. Re-binding a key moves it to * the newer session. * * @param sessionId - The session the terminal just started, resumed or switched to. * @param cwd - Working directory for DB resolution. * @param keys - Identity keys, most specific first (defaults to the live terminal's). * @returns The keys that were bound. * @task T12499 * @task T12500 */ export declare function bindTerminalToSession(sessionId: string, cwd?: string, keys?: readonly TerminalKey[]): Promise; /** * Bind the calling terminal to a session it just started, resumed or switched * to (T12499 · T12500). Best-effort: a binding failure never fails the * lifecycle command. * * Skipped only when the process already carries an env session id that NAMES * AN ACTIVE SESSION (e.g. a spawned worker's `CLEO_SESSION_ID`): that identity * already governs the process, and binding would let a worker sharing its * harness's `CLAUDE_CODE_SESSION_ID` overwrite the orchestrator's binding. A * harness that exports `CLAUDE_SESSION_ID` / `AIDER_SESSION_ID` holding a * non-CLEO id still binds — otherwise every later mutation from it would be * refused with `E_SESSION_UNBOUND`. * * @param sessionId - The started / resumed / switched-to session. * @param cwd - Working directory for DB resolution. * @returns `true` when at least one binding row was written. * @task T12500 */ export declare function bindCallingTerminal(sessionId: string, cwd?: string): Promise; /** A terminal-bound session and whether this caller only ADOPTED it (T12500). */ export interface TerminalBinding { /** The bound active session. */ readonly session: Session; /** * `true` when an agent (provider-keyed caller) reached a session a HUMAN * started in its pane/tab, instead of one it started or resumed itself. An * adopter may act in the session (attribution, focus) but may not END it * without naming it explicitly (`--session `). */ readonly adopted: boolean; } /** * Resolve the session bound to the calling terminal, with ownership (T12499 · T12500). * * 1. **Provider key bound** → that session (the agent's own; `adopted` when * the row itself was written by an adoption, `bound_by_provider = 0`). * 2. Else the **pane** key, when present. A pane never falls through to its * tab: a sibling pane sharing the tab id resolves nothing. * 3. Else the **tab** key (or the harness-ancestor fallback). * * Steps 2-3 are unconditional for a caller WITHOUT a provider key (a human * shell: it may end a session an agent started in its tab). For a caller WITH * a provider key, a coarse binding is adopted only when it was written without * a provider key (a human-started session, `bound_by_provider = 0`) AND no * other provider key owns an explicit binding to that session. On adoption the * provider key is bound too (`bound_by_provider = 0`: adopted, not owned) so * later calls resolve directly. * * Only `active` sessions are returned; a binding to an ended session is ignored. * * @param cwd - Working directory for DB resolution. * @param keys - Identity keys, most specific first (defaults to the live terminal's). * @returns The bound active session and its ownership, or `null`. * @task T12499 * @task T12500 */ export declare function resolveTerminalBinding(cwd?: string, keys?: readonly TerminalKey[]): Promise; /** * Resolve the session bound to the calling terminal (T12499 · T12500). * * Session-only form of {@link resolveTerminalBinding}. * * @param cwd - Working directory for DB resolution. * @param keys - Identity keys, most specific first (defaults to the live terminal's). * @returns The bound active session, or `null`. * @task T12499 * @task T12500 */ export declare function resolveTerminalBoundSession(cwd?: string, keys?: readonly TerminalKey[]): Promise; /** Which active sessions block the caller from starting another one (T12530). */ export interface SessionStartConflicts { /** * The active session the caller already OWNS, or `null`: the session its * daemon connection or env session id names, or the one its own terminal * binding row names. Not held: a session an agent merely ADOPTED from a * human's pane/tab (the agent may start its own), and, for a caller with no * provider key, a coarse row an AGENT wrote (`bound_by_provider = 1`) — the * human's start only rewrites that coarse row, and must never be told to end * the agent's session (T12500). Reported regardless of the candidates' scope. */ readonly held: { readonly session: Session; readonly via: SessionBindingSource; } | null; /** * `true` when the caller has a STABLE identity to compare against: an active * connection or env session, or a provider / pane / tab key from the * environment or the harness-ancestor walk (T12864), and the binding table * was readable. A legacy `ppid` key (pre-T12864, stopped at the per-call * `bash -c`, so every call got a new key) does NOT count; the walk returns no * key at all when it finds no long-lived ancestor. When `false`, every * candidate blocks (the pre-T12530 single-session guard). */ readonly identified: boolean; /** * Every candidate that blocks the start, the held one first: the held * session (when it is a candidate), plus each session that no terminal * binding names and that carries no `agentHandle` — nobody provably owns it, * so it may be the caller's own. A session bound to ANOTHER terminal, or * tagged with an agent handle, never blocks. When {@link identified} is * `false`, every candidate blocks. */ readonly blocking: readonly Session[]; } /** * Decide which active sessions block `session start` from the calling * terminal (T12530 · epic T12497). * * Before T12530 the start guard refused whenever ANY session was active, so a * second terminal needed `--agent ` even though T12499 already gave it * its own identity. The guard now conflicts only with a session the caller * already owns — the SAME terminal key (or env / connection identity) — and * with sessions nobody provably owns. Sessions bound to other terminals are * theirs, and starting here never touches them. * * Read-only: unlike {@link resolveTerminalBinding} this never writes an * adoption row. * * @param candidates - Active sessions to classify (the caller pre-filters by status / scope). * @param cwd - Working directory for DB resolution. * @param keys - Identity keys, most specific first (defaults to the live terminal's). * @returns The held session, whether the caller is identified, and the blocking set. * @throws The underlying error when reading bindings fails for any reason other * than a missing binding table or column. * @task T12530 */ export declare function findSessionStartConflicts(candidates: readonly Session[], cwd?: string, keys?: readonly TerminalKey[]): Promise; /** * Remove every terminal binding that points at `sessionId` (T12499). * * Called when a session ends so a terminal stops resolving it. * * @param sessionId - The session whose bindings to drop. * @param cwd - Working directory for DB resolution. * @returns Number of bindings removed. * @task T12499 */ export declare function unbindSessionTerminals(sessionId: string, cwd?: string): Promise; /** * Resolve the CALLER's current session (T11344/T11640 · Epics T11284, T11638). * * THE canonical identity-resolution helper. Replaces bare {@link getActiveSession} * for any consumer that means "the session of whoever is calling THIS request". * Resolution precedence (first hit wins): * * 1. **Daemon connection handle** ({@link getCurrentConnectionSessionId}) — when * dispatching a request frame on a warm-daemon connection, the connection's * accept-time-bound session is authoritative. A row with that id is honoured * when present; when the bound id has no row yet, resolution proceeds (the * connection asserted an identity that the env/active tiers may still * satisfy). This is the seam that gives the multi-connection daemon a stable * per-connection identity (T11640). * 2. **Env-named session** — `resolveSessionIdFromEnv()` (`CLEO_SESSION_ID` * injected by spawn isolation, T11343); the spawned agent's OWN session. * An env id with no row is rejected and resolution continues. * 3. **Terminal binding** — {@link resolveTerminalBoundSession}: the active * session that this terminal / harness started (T12499). * 4. **Most-recent active row** — {@link getActiveSession}, the legacy * single-process fallback. READ-ONLY consumers only (T12500): from an * unbound terminal this is another agent's session. Mutations resolve * through {@link resolveBoundSession} / {@link requireBoundSession}, and * read surfaces that want to label the guess use {@link resolveSessionForRead}. * * Making most-recent-active the FALLBACK rather than the default identity is * what dissolves multi-agent session-bleed AND memory scope-leakage: a * short-lived `cleo` call in agent A's worktree no longer resolves agent B's * session just because B wrote to the DB more recently. * * The connection-handle and env-named sessions are honoured even when their * status is not `active` (the caller explicitly asserted that id), so callers * that need an active-only guarantee should check `.status` on the result. * * @param cwd - Working directory for DB resolution. * @returns The resolved session, or `null` when none can be resolved. * @task T11344 * @task T11640 */ export declare function resolveCurrentSession(cwd?: string): Promise; /** * Resolve the CALLER's current session id (T11344/T11640). * * Thin id-only convenience over {@link resolveCurrentSession} sharing its * connection-handle → env → terminal-binding → most-recent-active precedence. * READ-ONLY consumers only: the last tier may name another agent's session. A * caller that attributes or mutates must use {@link resolveBoundSessionId}. * * @param cwd - Working directory for DB resolution. * @returns The resolved session id, or `null`. * @task T11344 * @task T11640 */ export declare function resolveCurrentSessionId(cwd?: string): Promise; /** Which identity tier bound the caller to its session (T12500). */ export type SessionBindingSource = 'connection' | 'env' | 'terminal'; /** A session the caller is provably bound to (T12500). */ export interface BoundSessionResolution { /** The caller's own session row. */ readonly session: Session; /** The identity tier that named it. */ readonly via: SessionBindingSource; /** * `true` when the caller only ADOPTED a session a human started in its * pane/tab (see {@link TerminalBinding.adopted}). It may act in it but not * end it implicitly. */ readonly adopted?: boolean; } /** * Resolve the session the CALLER is bound to — never a guess (T12500 · epic T12497). * * Tiers 1-3 of {@link resolveCurrentSession} only: the daemon connection * handle, an env-named session (`CLEO_SESSION_ID` & co.) whose row exists, and * the terminal binding written by `session start` / `session resume`. The * newest-active-row fallback is deliberately absent: from an unbound terminal * it names whichever agent wrote the DB last, so a mutation resolved through it * ends, attributes to, or suspends ANOTHER agent's session. * * Every mutation that means "the caller's session" resolves through this (or * {@link requireBoundSession}); only read-only surfaces may use the fallback, * and they label it via {@link resolveSessionForRead}. * * @param cwd - Working directory for DB resolution. * Never creates the project store: with no `cleo.db` the caller is unbound (T12501). * @param env - Environment naming the caller (defaults to `process.env`); a * hook passes the harness's, so `CLAUDE_CODE_SESSION_ID` binds it (T13667). * * @returns The bound session and the tier that bound it, or `null` when unbound. * @task T12500 */ export declare function resolveBoundSession(cwd?: string, env?: Readonly>): Promise; /** * Id-only form of {@link resolveBoundSession} (T12500). * * For attribution (audit rows, decisions, memory writes): an unbound caller is * attributed to NO session rather than to the newest active one. * * @param cwd - Working directory for DB resolution. * @param env - Environment naming the caller (defaults to `process.env`). * @returns The bound session id, or `null` when the caller is unbound. * @task T12500 */ export declare function resolveBoundSessionId(cwd?: string, env?: Readonly>): Promise; /** Result of a read-only session resolution (T12500). */ export interface ReadSessionResolution { /** The resolved session, or `null` when no session exists at all. */ readonly session: Session | null; /** * `true` when the caller is NOT bound and `session` is merely the newest * active row — possibly another agent's. Read-only envelopes surface this as * `unbound: true` so the reader does not mistake it for its own session. */ readonly unbound: boolean; } /** * Resolve a session for a READ-ONLY surface, labelling a guess (T12500). * * Bound tiers first ({@link resolveBoundSession}); when the caller is unbound, * falls back to the newest active row and reports `unbound: true`. Status, * briefing and show may display that row — they change nothing — but must * pass the label through to their envelope. * * @param cwd - Working directory for DB resolution. * @returns The session (bound or newest-active) plus the `unbound` label. * @task T12500 */ export declare function resolveSessionForRead(cwd?: string): Promise; /** * How recently an `active` session must have started or recorded activity to * make an unbound caller ambiguous (T12500). Sessions leak `active` — an agent * that crashes never ends its own — so an ancient row must not turn every * unbound call into `E_SESSION_UNBOUND` forever. */ export declare const SESSION_LIVE_TTL_MS: number; /** * Whether any LIVE session is active in this project (T12500). * * An existence scan, not an identity: it decides whether an unbound caller is * ambiguous (`E_SESSION_UNBOUND`) or simply has no session to act on. Only * sessions whose newest of `lastActivity` / `startedAt` falls within * {@link SESSION_LIVE_TTL_MS} count; a stale, never-ended row does not. * * @param cwd - Working directory for DB resolution. * @param nowMs - Clock override for tests. * @returns `true` when at least one live session row is `active`. * @task T12500 */ export declare function hasActiveSession(cwd?: string, nowMs?: number): Promise; /** * When a session was last seen alive, in epoch milliseconds (T12540): the * newer of its `lastActivity` heartbeat and `startedAt`. Liveness and gc * measure idleness from this, so a long-running session that keeps working * stays live however long ago it started. * * @param session - Any shape carrying the two timestamps. * @returns Epoch ms, or `0` when neither timestamp parses. * @example * ```ts * const idleMs = Date.now() - sessionLastSeenMs(session); * ``` * @task T12540 */ export declare function sessionLastSeenMs(session: { readonly startedAt?: string | null; readonly lastActivity?: string | null; }): number; /** * Minimum interval between two `lastActivity` writes for one session, in * milliseconds (T12540). Every bound mutation is activity, but the heartbeat * writes at most once per window so a busy agent does not add a write per * command. */ export declare const SESSION_ACTIVITY_THROTTLE_MS: number; /** * Session activity heartbeat (T12540 · epic T12497): refresh an ACTIVE * session's `lastActivity`, at most once per {@link SESSION_ACTIVITY_THROTTLE_MS}. * * Cheap and best-effort by construction: * - one primary-key read decides whether the window has elapsed, so a * throttled beat takes no write lock at all; * - the write is ONE single-row UPDATE by primary key, whose `WHERE` repeats * the throttle so two racing processes cannot both land a beat; * - it runs through {@link runHeartbeatWrite}: a short `busy_timeout`, and a * contended lock or an open transaction skips the beat instead of waiting. * * @param sessionId - The bound session that just did work. * @param cwd - Working directory for DB resolution. * @param nowMs - Clock override for tests. * @returns `true` when `lastActivity` was written, `false` when throttled, * skipped, or the session is not active. * @task T12540 */ export declare function touchSessionActivity(sessionId: string, cwd?: string, nowMs?: number): Promise; /** How to bind a caller to a session — shared by every E_SESSION_UNBOUND (T12500). */ export declare const SESSION_UNBOUND_FIX: string; /** Copy-paste remedies attached to every E_SESSION_UNBOUND (T12500). */ export declare const SESSION_UNBOUND_ALTERNATIVES: ReadonlyArray<{ action: string; command: string; }>; /** * Message for an E_SESSION_UNBOUND refusal (T12500). * * @param operation - What the caller tried to do (e.g. `end the session`). * @returns Human-readable refusal naming why the newest session was not used. * @task T12500 */ export declare function sessionUnboundMessage(operation: string): string; /** * Resolve the caller's bound session for a MUTATION, refusing to guess (T12500). * * - Bound → the caller's session. * - Unbound and NO session is active → `null` (nothing is ambiguous; callers * keep their existing "no session" behaviour). * - Unbound while some session IS active → throws `E_SESSION_UNBOUND` * ({@link ExitCode.SESSION_UNBOUND}) — the legacy fallback would have picked * that other session. * * @param operation - What the caller is attempting, for the error message. * @param cwd - Working directory for DB resolution. * @returns The bound session, or `null` when no session is active at all. * @throws CleoError with `ExitCode.SESSION_UNBOUND` when unbound but sessions exist. * @task T12500 */ export declare function requireBoundSession(operation: string, cwd?: string): Promise; /** * Message for refusing to END a session the caller only adopted (T12500). * * @param sessionId - The adopted session. * @returns Human-readable refusal naming the explicit form. * @task T12500 */ export declare function sessionAdoptedEndMessage(sessionId: string): string; /** * Resolve the caller's session for ENDING it (T12500). * * {@link requireBoundSession}, plus: a session this caller only ADOPTED (a * human started it in the agent's tab/pane) is refused with * `E_SESSION_UNBOUND`, so one of several agents working in a human's session * cannot end it — and strip the others' bindings — without naming it. * * @param cwd - Working directory for DB resolution. * @returns The caller's own session, or `null` when no session is active at all. * @throws CleoError with `ExitCode.SESSION_UNBOUND` when unbound or adopted. * @task T12500 */ export declare function requireOwnedSessionForEnd(cwd?: string): Promise; //# sourceMappingURL=session-store.d.ts.map