import type { AgentRecord, AgentWithStatus, MessageRecord, TaskRecord, TaskAction, WebhookRecord } from "./types.js"; import type { AuthStateInput } from "./auth.js"; import { type CompatDatabase } from "./sqlite-compat.js"; import { type SchemaCheck } from "./task-schema-validator.js"; export declare function getDbPath(): string; /** * Derive the observed agent_status. CANONICAL PRECEDENCE (single source of * truth; the table-driven test in tests/v2-13-0-presence-liveness.test.ts * exercises every stored × verdict cell). * * v2.15.0 — AGE IS GONE from the derivation. Staleness alone can NEVER produce * a terminal state (closed/offline/abandoned) again — only a DECLARATION or a * POSITIVE dead probe can. Inputs are just the STORED declared state (a * re-register has already normalized it via `statusAfterReregister`, so a * stored 'offline'/'closed' here is a CURRENT-session declaration, not * carried-over staleness) and the three-way liveness `verdict`. * * Precedence, top wins: * R1. stored 'offline' (declaration: set_status / force-rotation) → offline. * A declared-unavailable agent stays offline even with a live process — * a live PID doesn't un-declare intent. (Not 'closed' → never falsely closed.) * R2. verdict 'alive' (process confirmed up) → the declared ACTIVE state * (idle default). AUTHORITATIVE regardless of how stale last_seen is — * this is the rate-limited-but-alive case. Never 'closed'. * R3. verdict 'dead' (agent_pid present + process confirmed gone) → closed. * The ONLY probe-derived death (a crash). * R4. verdict 'unknown' + stored 'closed' (a clean SIGINT close DECLARES * closed and clears agent_pid) → closed. A declaration, not a guess. * R5. verdict 'unknown', no declaration → 'unknown'. No liveness data + * no death declaration ⇒ we DON'T KNOW; never guess dead. * * 'abandoned' + 'stale' remain in the enum for back-compat but are NEVER * derived here; the 30-day last_seen purge handles retired-row cleanup. */ export declare function deriveAgentStatus(storedRaw: string | null | undefined, verdict: LivenessVerdict): AgentWithStatus["agent_status"]; /** * THE ROUTING PREDICATE — single source (audit HIGH #2, ADR-0015 L4). An agent * can RECEIVE a task iff it holds a live session AND is not in a terminal status. * postTaskAuto's SQL candidate query (ENFORCEMENT) and the `routable` / * `routability` fields on the agent surface (what the OPERATOR reads) both * derive from this, so the operator can never read a different predicate than * the router enforces. This is DELIBERATELY not `computeLivenessVerdict`: that * asks "is the process alive?", a strictly different question — a live process * whose session dropped is alive-but-unroutable, and conflating the two is what * this split fixes. */ export declare const ROUTABLE_TERMINAL_STATUSES: readonly ["offline", "closed", "abandoned", "stale"]; export declare function isAgentRoutable(row: { session_id?: string | null; agent_status?: string | null; }): boolean; export type Routability = "routable" | "unroutable_alive" | "unroutable_offline"; /** * Classify an agent's routability as ONE named state (not two fields to diff). * `unroutable_alive` is the loud diagnostic: the process is alive yet holds no * routable session, so the router will SILENTLY never give it work — the * canonical failure shape wearing a healthy badge (audit HIGH #2). */ export declare function agentRoutability(row: { session_id?: string | null; agent_status?: string | null; }, verdict: LivenessVerdict): Routability; /** * Initialize the database. Call once at process startup. For native driver * (default): sync under the hood. For wasm driver: loads the wasm binary * async then returns a better-sqlite3-compatible adapter. * * v1.11: replaces the lazy-init that was in getDb(). Now eager-init so the * wasm path can load asynchronously before any tool call happens. */ export declare function initializeDb(): Promise; export declare function getDb(): CompatDatabase; export declare function closeDb(): void; /** * v2.1 Phase 4c.3: authoritative schema-version constant. Bump this AND * register an entry in `applyMigration` whenever a schema change ships. * v2.1 Phase 4b.1 v2 bumped 1 → 2 alongside migrateSchemaToV2_1 * (auth_state + revoked_at + recovery_token_hash columns on agents). * v2.1 Phase 4p bumped 2 → 3 alongside migrateSchemaToV2_2 (one-shot * encryption of existing plaintext webhook_subscriptions.secret rows). * v2.1 Phase 4b.2 bumped 3 → 4 alongside migrateSchemaToV2_3 (managed * column + rotation_grace state + previous_token_hash + CHECK rewrite). * v2.1 Phase 4b.3 bumped 4 → 5 alongside the reencryption_progress table * (new table only, no column changes; version bump is a semantic marker * so backup/restore flag the post-rotation shape explicitly). * v2.1.3 (I6) bumped 6 → 7 alongside migrateSchemaToV2_5 (agent_status enum * widened + legacy value remap: online→idle, busy→working, away→blocked). * * v2.1.6 bumped 7 → 8 alongside migrateSchemaToV2_6 (agents.session_started_at * nullable column, anchors the `session_start` sentinel in the `since` filter * on get_messages / get_messages_summary). * * v2.2.0 bumped 8 → 9 alongside migrateSchemaToV2_7 (agents.terminal_title_ref * nullable column, used by the dashboard's click-to-focus driver to find the * agent's live terminal window across iTerm2 / wmctrl / AppActivate). * * v2.2.1 bumped 9 → 10 alongside migrateSchemaToV2_8 (dashboard_prefs * single-row table holding the server-side default theme for the v2.2.1 * set_dashboard_theme MCP tool). * * v2.1 Phase 7q bumped 5 → 6 alongside migrateSchemaToV2_4 (agents.visibility * column reserved for v2.3 hub federation, mailbox + agent_cursor tables * reserved for Phase 4s v2.2 delivery-seq protocol). All additions empty / * unused in v2.1.0 — pure namespace reservation so downstream phases don't * require a breaking migration. * Migrations are idempotent and run unconditionally at init; the version * bump is the semantic marker visible to backup/restore. */ export declare const CURRENT_SCHEMA_VERSION = 24; /** * Read the live DB's recorded schema version. Throws if the table is * missing (shouldn't happen post-initSchema; fail-loud over silent-zero). */ export declare function getSchemaVersion(): number; /** * Hook point for backup/restore's schema-version dispatcher. Each * registered (from, to) pair acknowledges a known migration AND syncs * schema_info.version to `to` so the recorded version doesn't drift * behind the actual DB shape after a restore round-trip. Throws for * unregistered pairs so callers see a clear actionable error rather * than a silent no-op. * * Init-time mutations are applied via the migrateSchemaToV2_X * functions, which run unconditionally at startup. applyMigration is * NOT on the init path; it is invoked by src/backup.ts during restore. * The same advance helper runs at the end of the init chain * ({@link finalizeSchemaVersion}) so neither path leaves * schema_info.version stale. */ export declare function applyMigration(from: number, to: number): void; export interface DashboardPrefs { theme: "catppuccin" | "dark" | "light" | "custom"; custom_json: string | null; updated_at: string; } /** v2.2.1: read the server-side default dashboard theme. */ export declare function getDashboardPrefs(): DashboardPrefs; /** * v2.2.1: write the server-side default dashboard theme. `custom_json` must * be a pre-serialized JSON string when theme='custom'; null otherwise. The * tool-layer Zod schema validates the JSON shape before this is called. */ export declare function setDashboardPrefs(theme: DashboardPrefs["theme"], custom_json: string | null): DashboardPrefs; /** * Does this CLI profile OWE a verdict at session start? * * Derived from the registry rather than listed here, so the recording side and * the expectation side cannot drift apart: a profile owes a verdict iff it * installs a hook, because only then is there something that could have emitted * one. An unknown / absent profile returns false — under-cover deliberately * rather than train anyone to ignore a false alarm. */ export declare function profileOwesVerdict(cliProfile: string | null | undefined): boolean; /** * Normalize a caller-supplied CLI profile id against the registry. Anything the * registry does not know becomes NULL (= UNKNOWN), never a default. */ export declare function normalizeCliProfile(raw: string | null | undefined): string | null; /** * ADR-0003 — read the current auth generation. A single-row PK read * (~microseconds), cheap enough to run on every auth (the verified-token cache * saves the bcrypt, not this read). Returns 0 if the row is somehow absent * (fail-safe: a 0 never matches a stamped positive gen → forces a re-verify). */ export declare function getAuthGeneration(): number; /** * ADR-0003 — bump the auth generation. MUST be called by every sanctioned * mutator that can change a token's validity: a write to token_hash / * auth_state / previous_token_hash / recovery_token_hash / * rotation_grace_expires_at, or a delete of an agent row. It invalidates the * ENTIRE verified-token cache logically — any entry stamped with a now-stale * generation is rejected on its next hit, giving INSTANT revocation regardless * of the entry's TTL. Coverage is enforced adversarially by the sanctioned- * mutation drift guard (scripts/auth-gen-guard.mjs + its negative fixture). */ export declare function bumpAuthGeneration(): void; /** * ADR-0003 — SHARED identification primitive for BOTH token-auth call sites * (server.ts resolveCallerByToken via resolveAgentByToken, and status.ts * checkToken). Finds the agent row a token belongs to in O(1) via the indexed * HMAC digest, with an O(N) fallback that guarantees a legacy NULL-digest row * (or a keyring-rotation straggler whose digest was computed under the old key) * is never missed. `fromLocator` distinguishes the index hit from the fallback * (drives lazy self-heal). Returns the row for ANY auth_state — the caller * decides what the state means. Feeding both sites from here is what keeps the * two auth paths from diverging. */ export declare function findAgentRowByToken(token: string): { row: AgentRecord; matched: "current" | "previous"; fromLocator: boolean; } | null; /** * ADR-0005 lifecycle refinement — THE reap-invariant stamp. Called the first time * a row becomes a legitimate IDENTITY via ANY establishment path (a successful * token auth OR recovery completion; a future SSO/pairing flow would call it too). * Sets `established_at` (idempotent, WHERE established_at IS NULL) + retires the * orphan handle. Does NOT bump the auth generation — establishment is not a * token-validity change (outside the auth-gen guard's scope). * * The reap invariant (REAPABLE_ORPHAN_WHERE) keys on `established_at`, NOT * first_authed_at: recovery + spawn establish a real identity WITHOUT a token * auth, and anchoring reapability to token-auth alone reintroduced deletion risk * (codex #115 recovery + spawn blockers). HONEST CEILING: there is NO single * write-chokepoint — this is called at N sites, so it is call-site discipline. * tests/v2-22-0-establishment-invariant.test.ts is a MANUALLY ENUMERATED checklist * that proves every LISTED path stamps; it does NOT auto-discover an unlisted * path (a new establishment path is silently absent until its case is added). */ export declare function markEstablished(name: string): void; /** * ADR-0005 — a successful TOKEN auth. Stamps first_authed_at (the forensic, * literal "first TOKEN auth" marker; idempotent) AND establishes the identity * via markEstablished (which carries the reap invariant + handle retirement). */ export declare function markAgentAuthenticated(name: string): void; /** * ADR-0005 — self-serve abandon of the caller's OWN botched (orphaned) * registration, WITHOUT the destructive operator endpoint. SAFE BY * CONSTRUCTION via the keystone invariant: it deletes ONLY a row that has NEVER * authenticated (`first_authed_at IS NULL`) — a working agent (authed ≥1x) * self-excludes and can NEVER be reached, handle or not. The name-scoped, * one-time, TTL'd handle is the AUTHORIZATION (proves the caller is the * registrant + covers the just-registered-not-yet-authed race). The keystone is * RE-ASSERTED in the DELETE WHERE so a concurrent auth between the read and the * delete cannot let us remove a now-authed row (TOCTOU-safe). */ export declare function abandonRegistration(name: string, handle: string): { abandoned: boolean; reason?: string; }; /** * ADR-0003 — O(1) token-only caller resolution (server.ts resolveCallerByToken). * * Path: shared verified-token cache → shared indexed locator * (findAgentRowByToken) → active/grace decision → shared store-or-heal. On a * locator MISS the shared fallback still finds the row; we then lazily self-heal * the digest (which bumps the generation) and skip caching this call. * * v2.20.1: refactored onto the SAME verifiedTokenCacheGet/Put helpers the * explicit-caller path uses — one cache layer, one invalidation. bcrypt stays * the sole verifier (the digest only narrows candidates; the cache replays an * already-verified, still-current verdict). */ export declare function resolveAgentByToken(token: string): { name: string; capabilities: string[]; } | null; /** * v2.20.1 — verified-token cache GET for the EXPLICIT-CALLER auth path * (server.ts enforceAuth: send_message.from, get_messages.agent_name, …). * Impersonation-gated: returns a cached verdict ONLY when it belongs to * `claimedName`. On a hit the caller skips its per-call bcrypt; on null the * caller runs the full authenticateAgent flow (preserving all * revoked/recovery/grace/legacy semantics), then calls explicitCallerCachePut. */ export declare function explicitCallerCacheGet(token: string, claimedName: string): { name: string; capabilities: string[]; } | null; /** * v2.20.1 — store the explicit-caller path's bcrypt-verified verdict + lazily * self-heal `claimedRow`'s lookup digest (Q1: every agent makes explicit-path * calls, so this deterministically migrates the whole fleet to O(1) and closes * the NULL-digest observation). Call ONLY after authenticateAgent returned ok * (and not legacy). For an `active` row the current token matched (no extra * bcrypt); only a rotation_grace row needs a one-row re-check to tell the * current token from the grace-window previous token (for the correct TTL cap). */ export declare function explicitCallerCachePut(token: string, claimedRow: AgentRecord, capabilities: string[]): void; export declare function purgeOldRecords(db: CompatDatabase): void; /** * v2.1 Phase 4c.2: configurable audit-log retention. Returns the number of * rows removed. `retentionDays <= 0` is a no-op — explicit opt-out for * operators who want indefinite retention. */ export declare function purgeOldAuditLog(retentionDays: number): { purged: number; }; /** v2.1 Phase 4c.2: test-only reset for the piggyback counter. */ export declare function _resetAuditPurgeCounterForTests(): void; /** * v1.7: structured params_json alongside the legacy params_summary column. * New writes populate both: params_summary for back-compat readers, and * params_json as a JSON-stringified structured object. Old rows predate * params_json (NULL) — readers should fall back to params_summary. * * The params_json field is encrypted at rest when RELAY_ENCRYPTION_KEY is set * (it may contain sensitive parameters like message content fragments). */ export declare function logAudit(agentName: string | null, tool: string, paramsSummary: string | null, success: boolean, error: string | null, source?: string, structured?: Record): void; export interface AuditLogRecord { id: string; agent_name: string | null; tool: string; params_summary: string | null; /** v1.7: structured JSON (decrypted on read) of the tool-call params. */ params_json: Record | null; success: number; error: string | null; source: string; created_at: string; } export declare function getAuditLog(agentName?: string, tool?: string, limit?: number): AuditLogRecord[]; /** * Record a hit and return the current count in the current window. * Returns { count, limit, allowed } — callers check .allowed. */ export declare function checkAndRecordRateLimit(agentName: string, bucket: string, limitPerHour: number): { count: number; limit: number; allowed: boolean; }; /** * v2.1 Phase 7q — sanctioned teardown of an agent row. * * Owns the DELETE paths that are NOT session_id-scoped. Sole remaining * caller: `relay recover` (operator-initiated forensic wipe of a stuck * registration). The 30-day dead-agent purge that also called this was * CUT under the ADR-0005 final ruling (see purgeOldRecords) — it was the * last autonomous agent-row deletion, and "idle 30 days" is an * undecidable abandonment proxy. Runs a single transaction that removes * the `agents` row AND its `agent_capabilities` sidecar. * * NO CAS. DELETE is idempotent by design: a missing row returns * changes=0, which is not an error in any caller's contract. The * session_id-scoped CAS DELETE is deliberately NOT folded here — that * path (`unregisterAgent`) protects against stale-SIGINT races which * teardownAgent's caller by definition is not subject to (recover CLI * is operator-invoked). * * `reason` is telemetry-only in v2.1.0 — future cascade side-effects * (e.g. audit-log entry per teardown, fire `agent.torn_down` webhook) * can land inside this function keyed off `reason` without touching * every caller. */ export declare function teardownAgent(name: string, reason: "unregister" | "recover"): void; /** * v2.1 Phase 7q — sanctioned auth-state transition. * * Per-row CAS UPDATE: predicate pins `auth_state = expectedFromState` and * optionally `token_hash IS expectedTokenHash` (NULL-safe match). Returns * `{ changed: true }` when the UPDATE hit exactly one row, `{ changed: * false }` when the source state drifted between the caller's pre-read * and this write (concurrent rotate/revoke/reissue lost the race). * * `updates` covers the auth-control fields the state machine can mutate * around a transition: token_hash, previous_token_hash, * rotation_grace_expires_at, recovery_token_hash, revoked_at. Metadata * fields (last_seen, agent_status, etc.) belong to `updateAgentMetadata`, * and `auth_state` itself is controlled via `toState` — there is a single * source of truth for each field. * * In v2.1.0 the only caller is `sweepExpiredRotationGrace` (rotation_grace * → active, clearing previous_token_hash + rotation_grace_expires_at). * rotateAgentToken + revokeAgentToken retain their existing multi-branch * CAS shapes per spec §2.2 — folding them now has cost + risk with no * invariant gain. Forward-adoption is v2.2+ if ever worth it. */ export declare function applyAuthStateTransition(name: string, expectedFromState: AuthStateInput, toState: AuthStateInput, updates: { token_hash?: string | null; previous_token_hash?: string | null; token_lookup?: string | null; previous_token_lookup?: string | null; rotation_grace_expires_at?: string | null; recovery_token_hash?: string | null; revoked_at?: string | null; }, expectedTokenHash?: string | null): { changed: boolean; }; /** * v2.1 Phase 7q — sanctioned metadata-only update on an agent row. * * Handles last_seen touches, agent_status transitions (online/busy/away/ * offline), and busy_expires_at TTL writes. These are non-auth-state * fields — they do not participate in the auth state machine, so there * is no CAS predicate. An empty-fields call is a safe no-op (does not * throw, does not run a degenerate SQL statement). * * Callers: `touchAgent` (last_seen bump on successful auth), `setAgentStatus` * (busy/away/online/offline), and forward-looking heartbeat paths that * want a single-site metadata writer. * * Returns true if any row was actually updated, false if the agent name * did not exist (so callers can surface a not-found signal if they care). * Empty-fields calls return true without running SQL. */ export declare function updateAgentMetadata(name: string, fields: { last_seen?: string; agent_status?: string; busy_expires_at?: string | null; last_alive?: string; last_drain_at?: string; }): boolean; /** * v2.13.0 — record the agent's OWN process as its liveness anchor. Called by * the stdio MCP server at startup (it walks its ancestry to find the agent * CLI) and by register_agent self-report (managed/script agents). Sets * agent_pid + agent_pid_start, and fills host_id with the relay's own machine * GUID iff currently NULL (so the same-host probe can match; never overwrites * a host_id the handshake already set). Clears any stale negative-probe verdict * so a freshly-relaunched agent isn't briefly dead-cached. No-op (false) if the * row doesn't exist yet. Sanctioned single-site agents mutation (lives in db.ts). */ export declare function setAgentLivenessAnchor(name: string, pid: number, startedAt: string | null): boolean; /** * v2.1.3 — sanctioned offline transition for a stdio terminal that is * exiting (SIGINT / SIGTERM). * * Replaces the v2.0.1 Codex HIGH 1 DELETE-on-SIGINT path. Preserves the * agent row + token_hash + capabilities + description + auth_state so a * subsequent Claude Code terminal with the same RELAY_AGENT_NAME can * re-register through the existing active-state path with its existing * token — no operator ceremony, no lost identity, no webhook noise. * * CAS predicate: `name = ? AND session_id = ?`. A concurrent terminal * that rotated session_id between our SIGINT capture and this call wins * the race — CAS returns `{ changed: false }` and we no-op. This is the * exact concurrent-instance-wipe protection the v2.0.1 HIGH 1 fix * shipped; it remains intact because the new semantic is "clear MY * session identity, not someone else's." * * Mutations on CAS hit: * - session_id = NULL (bootstrap-ready for next terminal) * - agent_status = 'offline' (declared lifecycle state) * - busy_expires_at = NULL (offline overrides busy shield) * * Deliberately preserved: * - token_hash / auth_state / capabilities / agent_capabilities * - description / role / created_at / managed / visibility * - last_seen (carries the "when was the agent truly active" signal; * the offline marker lives on agent_status now) * * Callers: `performAutoUnregister` in src/transport/stdio.ts. No other * callers expected. Explicit `unregister_agent` MCP tool + `relay recover` * CLI continue to route through `unregisterAgent` / `teardownAgent` (they * are deliberate operator actions with delete semantics). */ export declare function markAgentOffline(name: string, expectedSessionId: string): { changed: boolean; }; /** * ADR-0012 (Fork B) — non-destructive BINDING RELEASE. * * The dead-anchor diagnostic (hooks/check-relay.sh) detects a STALE BINDING — a * fast resummon left a dead terminal's `host_shell_pids` + a dead `agent_pid` * anchor, so Tether can't wake the agent ("no bound terminal") — and REFUSES to * auto-recover (Fork B ships no automatic takeover). It names THIS as the exact * operator remedy. * * Clears EXACTLY the binding — `session_id` + the Tether terminal chain * (`host_shell_pids`) + the same-host liveness anchor (`agent_pid` / * `agent_pid_start` / `last_alive`) — and PRESERVES the IDENTITY: `token_hash`, * `auth_state`, `name`, `capabilities`, `host_id`, `description`, `last_seen`. So * the next SessionStart takes the register path (`session_id IS NULL` → the 120s * LIVE-gate reads STALE) and re-binds with a fresh chain — WITHOUT freeing the * name, invalidating the token, or resetting the immutable capabilities. Those * three hazards are exactly why `relay recover` (a destructive DELETE) was * REJECTED as the remedy; see ADR-0012 amended. * * CAS ON THE OBSERVED BINDING (codex #136 P1 — the TOCTOU this arc exists to * kill, at the recovery layer). The caller probed liveness on a row it READ; the * release must prove "the row I am writing is the row I looked at." Without it, * a legitimate fresh rebind landing between the probe and the write (a new * terminal wins `register(force, expected_session_id=)` → rotates * session_id + overwrites the anchor) gets its LIVE binding cleared and the * command reports SUCCESS — stranding a healthy terminal unwakeable, the exact * outcome the liveness gate was added to prevent. So the UPDATE is predicated on * the full observed binding identity: `session_id` AND `agent_pid` AND * `agent_pid_start`, all `IS`-compared (null-safe) against what the caller read. * changes=0 means the binding moved under us → the caller REFUSES (re-read, not * released) — the same shape as a `FORCE_PRECONDITION_FAILED` loser, deliberately. * * SAFETY BOUNDARY: this helper enforces the write-race guard but trusts its * caller for the LIVENESS decision. The gate that refuses to release a LIVE * binding (which would strand a healthy idle agent unwakeable — the * self-inflicted silent-mute this arc exists to kill) lives in the `relay * release-binding` CLI (src/cli/release-binding.ts), which calls this ONLY after * `anchorLivenessVerdict` reads OBSERVED-DEAD (or an explicit `--override`). Do * NOT wire a new caller to this helper without that gate. */ export declare function releaseAgentBinding(name: string, expected: { session_id: string | null; agent_pid: number | null; agent_pid_start: string | null; }): { changed: boolean; }; /** * v2.2.2 BUG2 — sanctioned closed-session transition for a stdio * terminal that is shutting down *intentionally* (SIGINT / SIGTERM). * * Supersedes the `markAgentOffline` call previously used by * `performAutoUnregister`. Same CAS predicate (`name = ? AND * session_id = ?`) — a concurrent terminal that rotated session_id * between our SIGINT capture and this call wins the race. Difference: * sets `agent_status = 'closed'` instead of `'offline'` so dashboards * can distinguish retired-by-intent terminals from * offline-but-might-return. Preserved fields are identical (token_hash, * auth_state, capabilities, last_seen, …). * * Auto-promotion to `abandoned` still fires at RELAY_AGENT_ABANDON_DAYS * via deriveAgentStatus (age-based), so closed terminals don't linger * visible forever — they follow the same retirement arc as offline. */ export declare function closeAgentSession(name: string, expectedSessionId: string, /** * v2.8 — optional signal kind that triggered this close. Mirrors * into the new `signal_received_at` + `signal_kind` columns the * v2.8 state machine reads. Stays NULL for non-signal close paths * (e.g. explicit unregister via MCP tool), which keeps the legacy * `agent_status='closed'` semantics intact while the v2.8 * `deriveDashboardState` derivation routes through * `signalReceivedAt` for signal-triggered closes specifically. */ signalKind?: "SIGHUP" | "SIGINT" | "SIGTERM" | null): { changed: boolean; }; /** * v2.15.2 — signal-only session teardown for the stdio signal handler * (`performAutoUnregister`). Supersedes `closeAgentSession`/`markAgentOffline` * on THAT path only (spawn's offline pre-register keeps `markAgentOffline`). * * WHY THIS IS DIFFERENT. A SIGHUP/SIGINT/SIGTERM to THIS MCP-server process is * NOT a reliable death signal for the AGENT: the agent (claude/codex, tracked * by `agent_pid` via the hook's ancestry-walk) can survive a terminal * reflow / VS Code reload and relaunch its MCP server. Storing a terminal * `agent_status` ('closed'/'offline') on such a signal PHANTOMS a * surviving/relaunched agent — and `deriveAgentStatus` R1 makes a stored * 'offline' win even over a confirmed-alive probe, so it never self-heals. * * So this teardown makes NO liveness claim and writes NO sticky terminal * status: * - CLEAR the liveness anchor (agent_pid / agent_pid_start / last_alive) — * the next register re-stamps it; until then liveness derives 'unknown'. * - agent_status = 'idle' — a non-terminal, derivable value: with the anchor * cleared, `deriveAgentStatus('idle', 'unknown')` returns 'unknown' (never * a phantom 'idle'), and if the agent comes back alive it reads active. * - STAMP signal_received_at / signal_kind for dashboard forensics ONLY. * `deriveDashboardState` now gates the signal-derived 'closed' on liveness * (a live probe suppresses it), and `registerAgent` clears these on the * next session, so the stamp is session-scoped and can't become stale * live input. * - release session_id + busy_expires_at (the session ended). * * Same CAS predicate as closeAgentSession (`name = ? AND session_id = ?`): a * concurrent terminal that rotated session_id between our signal capture and * this call wins the race (we no-op). Invalidates both probe caches on change. * * A genuine operator `set_status('offline')` is UNAFFECTED — that path * (`setAgentStatus`) is a deliberate declaration and R1 still makes it win. */ export declare function endAgentSessionOnSignal(name: string, expectedSessionId: string, signalKind?: "SIGHUP" | "SIGINT" | "SIGTERM" | null): { changed: boolean; }; /** * v2.1.4 (I11) — sanctioned additive cap expansion. * * Rules enforced here (belt + handler-layer suspenders): * 1. Agent row must exist. Missing → throws "NOT_FOUND" (handler maps). * 2. `newCapabilities` must be a SUPERSET of the current caps. Any cap in * the current set missing from the request is a reduction attempt → * throws "REDUCTION_NOT_ALLOWED". * 3. If the diff (new \ current) is empty → throws "NO_OP_EXPANSION". * 4. Otherwise: single transaction writes the union to agents.capabilities * (JSON column) AND inserts the missing caps into agent_capabilities. * * Why both columns: `agents.capabilities` is the legacy JSON surface read by * the registerAgent + discover paths. `agent_capabilities` is the v2.0-normalized * sidecar used by post_task_auto routing. Both must stay consistent — same * two-column discipline registerAgent uses at bootstrap. * * No CAS on the JSON column. Concurrent expand_capabilities on the same row * is vanishingly rare (the caller must hold the agent's own token) and the * worst-case outcome of a race is "one of the two concurrent expansions gets * re-applied"; the result is still a superset of the starting set. * * Throws typed string errors (matching ERROR_CODES) so the handler can map * them to structured responses. */ export declare function expandAgentCapabilities(name: string, newCapabilities: string[]): { added: string[]; current: string[]; }; /** * v2.6.0 — sanctioned operator-side mint of an agent token. * * Backs `relay mint-token `. Two paths: * * - First mint (no existing row): INSERT a fresh agent row with role, * capabilities, and an optional description. Defaults match the CLI: * `role` and `capabilities` are caller-supplied (CLI passes its own * defaults). agent_status='idle' (matches registerAgent first-mint). * * - Force rotate (existing row + options.force=true): rotate token only. * Caps are PRESERVED (caps are immutable after first registration); role is * also preserved so a force-rotate cannot quietly relabel an agent. * session_id is CLEARED and agent_status set to 'offline' so the next * time the agent process authenticates, the dashboard accurately * reflects the rotation: the prior session is invalid, and a fresh * env-token bootstrap must occur out-of-band before the agent can * reach the relay again. Auth-state side fields * (previous_token_hash / rotation_grace_expires_at / * recovery_token_hash / revoked_at) are zeroed because mint-token is * defined as a clean reset, not a graceful rotation: any in-flight * state on the auth machine is invalidated. * * - Existing row WITHOUT --force: throws so the caller can surface the * destructive nature of the operation. The CLI maps this to a clean * stderr error pointing at --force. * * Mirrors registerAgent's INSERT shape exactly (same 13 columns) so a * minted-but-never-registered row is indistinguishable from a registered * row at the auth layer. The agent process can then authenticate via * RELAY_AGENT_TOKEN env without ever calling register_agent — which is * the whole point: it sidesteps the LLM-client safety monitors that * pattern-match register-then-use sequences as credential handoff. */ export declare function mintAgentToken(name: string, role: string, capabilities: string[], options?: { description?: string | null; force?: boolean; }): { agent: AgentWithStatus; plaintext_token: string; created: boolean; }; /** * Register (or re-register) an agent. * * v1.7 behavior: * - First registration: generate a new token, store its hash, return the raw * token in `.plaintext_token` (shown ONCE to the caller, never again). * - Re-registration of an existing agent: role is updated, but capabilities * and the existing token_hash are PRESERVED. This lets the SessionStart * hook safely upsert on every terminal open without rotating tokens. * * v1.7.1 change: capabilities are IMMUTABLE after first registration. The * `capabilities` argument is IGNORED on re-register — the returned agent * reflects the preserved (existing) caps. To change caps, unregister then * re-register. Auth of the re-register caller is enforced at the dispatcher; * this function preserves caps as defense-in-depth regardless. * * - Legacy agents (registered pre-v1.7, token_hash = NULL): re-registration * generates a token for them (one-time migration path). Returns the token. */ export declare function registerAgent(name: string, role: string, capabilities: string[], options?: { description?: string; managed?: boolean; /** Which agent-CLI this row registered through (validated against the profile registry; unknown → NULL = UNKNOWN, never a default). Enables the server-side verdict-absence check. */ cli_profile?: string | null; /** ADR-0002: self-declared coordination class. Set on FIRST register only; a re-register silently preserves the original (immutable, like managed/capabilities). Validated against AGENT_CLASSES at the handler (Zod). */ class?: string; /** v2.2.0: window title for the dashboard click-to-focus driver. Mutable on re-register. */ terminal_title_ref?: string | null; /** * v2.2.1 B2: `force` flag IS exposed on the MCP surface (see * RegisterAgentSchema in src/types.ts). Collision enforcement policy * lives at the HANDLER layer (handleRegisterAgent), not the DB * layer — direct db.registerAgent callers (tests, relay recover * internals, migrations) bypass the collision check by design. * Default false → handler rejects active-row re-registers with * NAME_COLLISION_ACTIVE unless caller explicitly opts in. */ force?: boolean; /** * v2.1 Phase 7p HIGH #2: when set, pins the CAS predicate to exactly this * recovery_token_hash value (on the recovery_pending → active transition). * The dispatcher stores its verified hash here so a concurrent admin * reissue that lands between verify and UPDATE fails the CAS — the old * ticket cannot win the race. `undefined` means "not a recovery flow, * anchor on the fresh SELECT value" (preserves behavior for active / * legacy_bootstrap branches). */ expectedRecoveryHash?: string | null; /** Tether v0.3 PID-handshake: agent process-ancestry PID chain. On re-register, OVERWRITES the stored chain when provided; preserved when omitted. */ host_shell_pids?: number[]; /** Tether v0.3 PID-handshake: OS machine GUID. v2.11.0 GAP 1: session-refreshable on an authenticated re-register (provided→overwrite, omitted→preserve), mirroring host_shell_pids. Captured on first registration; the token-holder may refresh it on relaunch (e.g. to populate an empty host_id or after a machine move). */ host_id?: string; /** * ADR-0012 — CAS precondition for a force TAKEOVER. When provided (including * an explicit `null` = "expect an offline row"), the re-register UPDATE gains * `AND session_id IS ?`, so the write lands ONLY if the row's session_id * still equals this value. changes==0 with a session mismatch → the takeover * LOST the race → ForcePreconditionError (distinct from the auth-race * ConcurrentUpdateError). `undefined` = no session CAS (a normal re-register). * The handler (handleRegisterAgent) REQUIRES this whenever force=true — there * is no unconditional-force bypass left at the MCP surface. */ expected_session_id?: string | null; }): { agent: AgentWithStatus; plaintext_token: string | null; auto_assigned: QueuedAssignment[]; registration_recovery: string | null; }; /** Fetch an agent's auth record (includes token_hash) by name. */ /** * v2.1 Phase 4b.1 — rotate an agent's token under CAS. The caller-proven * current hash goes into the WHERE clause; two concurrent rotations AND a * rotate-racing-revoke scenario all collapse to one winner + one * ConcurrentUpdateError. Returns the fresh plaintext token (shown ONCE). */ /** * v2.1 Phase 4b.2 — outcome signal for rotate callers. `agentClass` tells * the handler how to shape its response: managed → grace window + push * message; unmanaged → new token + restart_required advice. */ export interface RotationOutcome { newPlaintextToken: string; newHash: string; agentClass: "managed" | "unmanaged"; graceExpiresAt: string | null; } export declare function rotateAgentToken(name: string, expectedOldHash: string, options?: { graceSeconds?: number; }): RotationOutcome; /** * v2.1 Phase 4b.2 — admin-initiated rotation across agents. Authorization * (rotate_others capability) is enforced upstream by the dispatcher; this * function trusts the rotator was cap-checked. Behavior mirrors * self-rotation but omits the `token_hash = ?` predicate (admin doesn't * know the target's old token). */ export declare function rotateAgentTokenAdmin(targetName: string, options?: { graceSeconds?: number; }): RotationOutcome; /** * v2.1 Phase 4b.2 — grace-window cleanup. Called via the shared piggyback * tick in server.ts. Transitions every expired `rotation_grace` row back to * `active`, clearing `previous_token_hash` + `rotation_grace_expires_at`. * Idempotent: rows already cleaned up match no WHERE clause. */ export declare function sweepExpiredRotationGrace(): number; /** * v2.1 Phase 4b.1 v2 — transition the target's auth_state to `revoked` or * `recovery_pending`. NEVER nulls token_hash (preserved for forensics + CAS * integrity). With `issueRecovery: true`, mints a one-time recovery_token * (bcrypt-hashed into recovery_token_hash) that the operator can present via * `register_agent(recovery_token=...)` to transition back to `active`. * * CAS: source state must be in {active, legacy_bootstrap, recovery_pending}. * Repeat calls against a `revoked` row return `{ revoked: false }` silently. * Re-calls against a `recovery_pending` row with issueRecovery=true rotate * the recovery_token_hash (operational support for "operator lost the first * ticket" — `wasReissue: true` lets the audit log distinguish "first revoke" * from "lost-ticket reissue"). */ export declare function revokeAgentToken(targetName: string, options?: { issueRecovery: boolean; }): { revoked: boolean; recoveryToken: string | null; wasReissue: boolean; }; export declare function getAgentAuthData(name: string): AgentRecord | null; /** * v2.0.1 (Codex HIGH 1): optional `expectedSessionId` scopes the delete so a * stale stdio process cannot wipe a fresh session of the same agent name. * When supplied and the stored session_id doesn't match, no row is deleted * and the function returns `false` silently — the old process exits cleanly * without touching the new session's registration. * * Manual `unregister_agent` MCP calls pass no session_id — they are explicit * operator actions and still wipe by name. The auto-unregister SIGINT handler * passes its captured session_id. */ /** * v2.8 dashboard-state-machine — roles that count as "dispatch-relevant" * recipients for the `last_dispatched_at` stamp. * * Per the v2.8 dashboard state-machine design: * "set by send_message when message has priority='high' OR recipient * is a builder role" * * The set is intentionally tight: roles that represent agents whose * "I was given work" signal is operationally meaningful for the * dashboard's `stale` derivation (i.e. they're expected to be doing * work in response to messages/tasks, not just observing). * * If you need to extend the set, update CHANGELOG.md to document the * new role + the rationale before merging. The state machine reads * `last_dispatched_at` to discriminate `stale` (was working, went * quiet, recently dispatched) from `waiting` (just idle), so adding * roles widens the `stale` surface. */ export declare const DISPATCH_RELEVANT_ROLES: ReadonlySet; /** * v2.8 dashboard-state-machine — stamp `agents.last_dispatched_at` on the * recipient row when the dispatch event qualifies. Called from * `sendMessage`, `postTask`, `postTaskAuto` after the message/task * commit so the row only gets stamped when the dispatch actually * landed. * * Stamping rule (mirrors brief at `:43`): * - priority === 'high' (or 'critical') → STAMP * - recipient role in DISPATCH_RELEVANT_ROLES → STAMP * - otherwise → NO-OP * * Best-effort: if the recipient row doesn't exist (rare race after * unregister) or the role lookup throws, the stamp is silently * skipped. The dispatch itself already committed; missing the * dashboard breadcrumb is non-fatal to the messaging contract. */ export declare function markRecipientDispatched(recipientName: string, priority: string | null): void; /** * v2.8 dashboard-state-machine — bulk-fetch the registered agents in * the shape the decay broadcaster's `BroadcasterAgentSnapshot[]` needs. * * Pre-filters pending message counts by `created_at < now - pendingWindowMs` * per the brief's contract — only messages older than the pending window * count toward the `pending` derivation (fresh mail isn't "pending, * operator should look"; it's "active, agent is processing"). * * `agent_state_machine.ts:207-212` explicitly says callers must do * this pre-filter BEFORE passing `pendingCount` to `deriveDashboardState`. * * Single SQL round-trip: agents JOIN messages (filtered by age). For * typical N < 20 registered agents this is fast. */ export declare function getDashboardAgentSnapshots(pendingWindowMs: number, nowMs?: number): Array<{ name: string; inputs: { lastSeen: string | null; signalReceivedAt: number | null; signalKind: string | null; unregisteredAt: number | null; pendingCount: number; lastDispatchedAt: number | null; liveness: LivenessVerdict; }; }>; export declare function unregisterAgent(name: string, expectedSessionId?: string): boolean; /** Look up the current session_id for an agent. Returns null if the agent doesn't exist. */ export declare function getAgentSessionId(name: string): string | null; /** * v2.0 final (#26) + v2.1.3 (I6): set the agent's operational status. * - idle (default): normal active state, eligible for health-monitor reassignment * - working: actively executing — health monitor skips reassignment * - blocked: cannot proceed (missing input / dependency) — health monitor skips * - waiting_user: paused pending operator input — health monitor skips * - offline: graceful shutdown signal * * `stale` is NOT a valid set_status value — it is a relay-computed observation * (deriveAgentStatus flips idle/working/blocked/waiting_user → stale after 5 min * of last_seen silence, → offline after 30 min). Callers may pass it directly * at the db layer for testing; production code should avoid it. * * Returns true if the agent existed and the status was updated. */ /** Accepted by setAgentStatus — new v2.1.3 enum plus legacy aliases. */ type SetStatusValue = "idle" | "working" | "blocked" | "waiting_user" | "stale" | "offline" | "online" | "busy" | "away"; export declare function setAgentStatus(name: string, status: SetStatusValue): boolean; export interface HealthSnapshot { status: "ok"; agent_count: number; /** v2.13.0 — agents confirmed alive RIGHT NOW (fresh same-host PID probe / * heartbeat). The trustworthy "how many are actually awake?" count, vs the * raw agent_count which includes idle/closed/abandoned rows. */ agent_count_alive: number; /** v2.15.0 — agents with NO liveness data (agent_pid absent / cross-host): * liveness==='unknown'. Surfaced DISTINCTLY from any dead/closed count so a * reader never treats "we don't know" as "gone". */ agent_count_unknown: number; message_count_pending: number; task_count_active: number; task_count_queued: number; channel_count: number; } /** * v2.0 final (#20): counts for the health_check tool. Cheap — one SELECT * COUNT each, runs on every caller's request. */ export declare function getHealthSnapshot(): HealthSnapshot; export declare function touchAgent(name: string): void; /** * v2.1.4 (I12): read-only query for `get_standup`. Returns all messages whose * `created_at >= sinceIso`, newest first, decrypted. Unlike `getMessages` this * does NOT touch the read_by_session column — standup is observation, not * consumption. */ export declare function getMessagesInWindow(sinceIso: string, limit?: number): MessageRecord[]; /** * v2.1.4 (I12): read-only query for `get_standup`. Returns all tasks whose * `updated_at >= sinceIso` OR whose status is still active (queued / posted / * accepted). The standup reports completed_in_window separately from * queued/blocked, so we pull a superset and let the caller partition. * Descriptions + results decrypted at read. */ export declare function getTasksInWindow(sinceIso: string, limit?: number): TaskRecord[]; /** Test-only: clear both probe caches + reset the probe counter. */ export declare function _resetLivenessProbeCacheForTests(): void; /** Test-only: read the probe counter. */ export declare function _getLivenessProbeCountForTests(): number; /** Test-only: seed the in-memory positive-probe cache, so `last_alive` * (positiveConfirmationISO) can be driven to CHANGE between reads without a * live process on a real ~5s probe cycle. Mirrors the reset/count hooks. */ export declare function _setPositiveProbeForTests(name: string, atMs: number): void; /** v2.15.0 — the three-way presence verdict. `unknown` = no positive AND no * negative signal (agent_pid absent, or cross-host so we can't probe) — it is * the honest "we don't know", NEVER treated as death. */ export type LivenessVerdict = "alive" | "dead" | "unknown"; /** * v2.15.0 — compute a row's liveness verdict PURELY (in-memory only; ZERO DB * writes) so it can run on every read path. Absence of a probe-able anchor * (no agent_pid) or a cross-host row → `unknown`, never `dead`. Only a positive * dead signal — agent_pid present, same host, and the process confirmed gone * (kill ESRCH) or its start-time mismatched (PID reuse) — yields `dead`. * `isAgentProcessAlive` already encodes the narrow-dead rule (kill ok + * start-time missing/unreadable ⇒ alive, not dead). Positive + negative caches * (both in-memory) suppress re-probes within LIVENESS_PROBE_CACHE_MS. */ export declare function computeLivenessVerdict(row: { name: string; host_id?: string | null; agent_pid?: number | null; agent_pid_start?: string | null; host_shell_pids?: string | null; }): LivenessVerdict; export declare function getAgents(role?: string): AgentWithStatus[]; /** * ADR-0002 — the live team "who's-who" grouped by coordination class, for * discover_agents view='topology'. TWO independent exclusions (per the gate): * 1. dead/terminal (LIVENESS): status === 'offline' (the dead verdict) is dropped. * 2. hidden CLASSES: `transient` (alive-but-ephemeral) + `unclassified` (no * declared posture) are dropped from the who's-who. * Everything else is grouped FLAT within its class {name, role, class, status}. * Groups are seeded in TOPOLOGY_VISIBLE_CLASSES order; excluded counts are * reported (no silent truncation). Read-only. */ export declare function buildAgentTopology(): { view: "topology"; topology: Record>; counts: Record; excluded: { offline: number; transient: number; unclassified: number; }; total_shown: number; }; /** * v2.4.1 — per-agent inbox rollup for the dashboard. * * Single GROUP BY over agents LEFT JOIN messages so every registered agent * appears in the result, including those with zero mail (pending_count=0, * unread_count=0, last_message_at=null). Used by snapshotApi to decorate * agents[] without a second round-trip per row. * * Semantics: * - pending_count — messages still in status='pending' (not yet drained * by a get_messages call that flipped them to 'read'). * - unread_count — the CANONICAL session-agnostic unread (#56): * read_by_session IS NULL AND resolved_at IS NULL (= pendingGlobalClause, * "not read by any session and not resolved"). Was `seq IS NULL` (mirroring * peek's v2.3 signal), but seq is stamped by ANY observation — including a * NON-consuming browse (get_messages peek=true / status='all') that never * drains — so the seq count silently DISAGREED with the drain and the other * SSOT surfaces. read_by_session moves with the drain, so this now agrees * with pending_count. * - last_message_at — ISO of MAX(created_at) across any status; NULL * when the agent has no inbox history. */ export declare function getInboxSummary(): Array<{ agent_name: string; pending_count: number; unread_count: number; last_message_at: string | null; }>; /** ADR-0011 — one row of the sender's outstanding-ask recap. */ export interface OutstandingMessage { id: string; to_agent: string; disposition: string; created_at: string; deadline: string | null; read_at: string | null; resolved_at: string | null; /** Sender-visible lifecycle state, derived at query time. */ state: "unread" | "read-unresolved" | "resolved"; /** REPORT-ONLY: past its bound and still unresolved. NEVER stored/mutated. */ overdue: boolean; content_preview: string; content_truncated: boolean; } /** * ADR-0011 — the SENDER's outstanding-ask recap, and the PULL source of truth * for overdue drift. Returns the ask/obligation messages the caller SENT, each * with its sender-visible lifecycle state (unread / read-unresolved / resolved) * and a REPORT-ONLY `overdue` flag computed AT QUERY TIME. It NEVER mutates a * message — report-first, never auto-resolve/auto-delete (ADR-0005). A fresh * orchestrator session reconstructs overdue state purely by calling this; the * optional read/resolved webhooks are push-on-top, never the source of truth. * * LOG messages are excluded entirely — LOG can never be overdue, so the * historical backlog can never wall this recap. `overdueBoundSeconds` (the * tunable RELAY_OVERDUE_SECONDS, resolved by the caller so this stays pure + * testable) applies to an ASK, and to an OBLIGATION that declared no explicit * deadline; an obligation WITH a deadline is overdue strictly past that deadline. * `includeResolved=false` (default) returns just the outstanding set (the recap); * true returns the full C-view including resolved rows. `nowIso` is injectable * for deterministic tests. */ export declare function getOutstanding(senderName: string, opts: { overdueBoundSeconds: number; includeResolved?: boolean; nowIso?: string; }): OutstandingMessage[]; export declare function sendMessage(from: string, to: string, content: string, priority: string, disposition?: string, deadline?: string | null): MessageRecord; /** * v2.3.0 Part C.1/C.2 — sanctioned mailbox helper. * * Idempotent upsert of the mailbox row for the given agent. Returns the * full mailbox record. Epoch is a fresh UUID at first creation; rotates * only on explicit `rotateMailboxEpoch` (called from backup/restore). * Transaction-safe — the INSERT OR IGNORE + follow-up SELECT is the * stable pattern across better-sqlite3 + WAL. */ export declare function getOrCreateMailbox(agentName: string): { mailbox_id: string; agent_name: string; epoch: string; next_seq: number; }; /** * #53 (mustang [DEFECT]) — the CANONICAL "pending" predicate: the single source * of truth every mailbox surface derives its pending/unread set from. * * Before this, four surfaces each had their OWN definition of "pending": * - peek_inbox_version wake signal: `seq IS NULL` * - get_messages(pending) drain: per-session `read_by_session` * - health_check count: global `read_by_session IS NULL` * - the SessionStart hook delivery: the binary `status` column * That predicate sprawl is exactly what let the wake signal and the drain queue * disagree — a wake reported N unread while `get_messages` pending returned 0, * because "unread" (seq) and "pending" (read_by_session) are different planes, * and a non-consuming browse (or a premature drain) silently zeroed one while * the other still held mail. One core, two forms: * * PENDING_NOT_RESOLVED_SQL — `resolved_at IS NULL`. A resolved (acked) message * is NEVER pending, on ANY surface. (Pre-#53 the peek + health counts both * omitted this — a resolved message still counted as unread backlog.) * * pendingForSessionClause(s) — NOT_RESOLVED and not read by session `s`. The * per-session action queue: re-pends a prior session's unfinished mail to a * fresh terminal (v2.0 #6 handover), hides what THIS session already read. * Used by get_messages(pending), the peek wake signal, and the hook. * pendingGlobalClause() — NOT_RESOLVED and read by NO session. The session- * agnostic backlog used by health_check's system-wide count. * * SCOPE (#53 / architect-gated Option 1): this unifies the DEFINITION of pending * so wake and drain cannot disagree. It deliberately does NOT change WHEN a * message is marked read (still on the get_messages fetch) — decoupling fetch * from consume is a behavioural contract change on a published package, deferred * to the architect. No tool schema changes here. */ export declare const PENDING_NOT_RESOLVED_SQL = "resolved_at IS NULL"; /** * The DELIVERED axis (SSOT). A message is DELIVERED once some session has DRAINED it, * which stamps `read_by_session`; `NEVER_DRAINED_SQL` = no session has — the canonical * "undelivered obligation" condition, shared by the purge exemption (pendingGlobalClause), * the wake detector's candidate set (pendingGlobalClause), AND the drain's since-escape * (pendingSinceClause below), so the three cannot disagree about what a peeked-but-undrained * message is. They DID disagree in 3.0.0: the drain keyed its escape on the OBSERVED axis * (`seq`, stamped on first observation INCLUDING a non-consuming peek), so a peek — what * every watcher does — flipped aged undelivered mail out of the recipient's own drain * while the purge protected it and the detector counted it pending. `seq` is the OBSERVED * axis; delivery is `read_by_session`. Undelivered means NOT DRAINED, not "not looked at". */ export declare const NEVER_DRAINED_SQL = "read_by_session IS NULL"; /** Canonical per-session pending predicate (SSOT). `session` may be "" (no * session) — then `read_by_session != ''` is only true for a genuinely-read * row, so the clause reduces to "unresolved and (unread-by-anyone or read-by- * some-other-session)", matching get_messages' historical `?? ""` behaviour. */ export declare function pendingForSessionClause(session: string): { sql: string; params: string[]; }; /** Canonical session-agnostic backlog predicate (SSOT): unresolved AND not read * by ANY session. Used where there is no single caller session (health_check). */ export declare function pendingGlobalClause(): { sql: string; params: string[]; }; /** * #198 + 3.0.1 — canonical WINDOW rule for a PENDING drain, beside the pending PREDICATE * helpers above. A pending drain must return UNDELIVERED mail regardless of `since`; * `since` bounds ONLY DELIVERED history. "Undelivered" is NOT DRAINED (NEVER_DRAINED_SQL — * the shared DELIVERED-axis SSOT), NOT "never observed". * * 3.0.1 FIX: #198 keyed this escape on `seq IS NULL` (the OBSERVED axis), which a * non-consuming PEEK stamps. So peeking an aged undelivered message set its `seq`, lapsed * the escape, and the window then HID it from the recipient's own drain — silent * non-delivery, the exact class #198 exists to kill, reached through the call our docs * called side-effect-free, and worse because peek is what every watcher (Sentinel, the * dashboard, `relay watch`) does. `seq` is the OBSERVED axis (set by peek OR drain); * delivery is `read_by_session` (set only by a drain). Routing this escape through * NEVER_DRAINED_SQL makes the drain agree with the purge exemption and the wake detector, * which already key on it — so the three can no longer disagree, and a peek stops removing * mail from reach. * * Every pending surface — the get_messages drain, getMessagesSummary's pending preview, and * the consistency probe's superset — routes its window through this ONE helper, so the age * rule is inherited by construction and no surface can drift. History reads * (all/read/resolved) keep a plain `created_at >= ?`. Empty when no bound is set * (since='all'/null): an unbounded pending drain returns everything unresolved-and-undrained. * Plan-verified: OR'd against the `to_agent` equality it does not change the index seek. */ export declare function pendingSinceClause(sinceIso: string | null): { sql: string; params: string[]; }; /** * v2.3.0 Part C — peek helper backing the `peek_inbox_version` MCP tool. * Pure observation — no mutation. Returns the current mailbox shape + * an observed count of messages addressed to the agent. */ export declare function peekMailboxVersion(agentName: string): { mailbox_id: string; epoch: string; last_seq: number; total_messages_count: number; total_unread_count: number; }; /** * v2.3.0 Part C — rotate the mailbox epoch for a specific agent. Called * from backup/restore so restored DBs get a fresh epoch even if the * underlying seq counter was reset by the archive. Clients whose cached * cursor epoch doesn't match on next peek reset their local last_seen * to 0 and drain from scratch. */ export declare function rotateMailboxEpoch(agentName: string): string; /** * v2.3.0 Part C — rotate EVERY mailbox epoch. Used by restoreFromBackup * to invalidate every client's cursor in one pass rather than waiting * for per-agent peek calls. */ export declare function rotateAllMailboxEpochs(): number; export declare function getMessages(agentName: string, status: string, limit: number, peek?: boolean, sinceIso?: string | null, lane?: "all" | "direct" | "capability", ack?: boolean): MessageRecord[]; /** * v2.12.0 — pending-vs-history. Permanently RESOLVE (ack) the named messages * for `agentName`, so they leave the cross-session pending queue for good. * Backs the `resolve_messages` tool and the partial-handling path ("I handled * these, not those") that `get_messages(ack=true)` (resolve-the-whole-drain) * does not cover. * * Recipient-scoped: only rows WHERE `to_agent = agentName` are touched, so a * caller can never resolve another agent's mail even if it passes foreign ids * (defense-in-depth — the dispatcher already binds the caller's token to * `agent_name`). Idempotent: the `resolved_at IS NULL` guard means re-resolving * an already-resolved id is a no-op, and unknown / non-owned ids are silently * skipped (reported via the count). Resolving does NOT mark a message read — * read is a separate per-session plane. * * Returns `{ resolved_ids, resolved_count, requested_count }` so the caller can * see exactly which of the requested ids it actually owned + flipped. */ export declare function resolveMessages(agentName: string, messageIds: string[]): { resolved_ids: string[]; resolved_count: number; requested_count: number; }; /** * v2.1.6 — inbox-summary helper. Mirrors the priority + status ordering of * getMessages but: * 1. does NOT mutate read_by_session (pure observation), * 2. accepts an optional `sinceIso` lower bound so SQL can pre-filter by * created_at — cheaper than the handler-layer filter when the caller * cares only about recent mail, * 3. decrypts content so the handler can slice a preview at the boundary. * * Intended for get_messages_summary. Keeps the handler thin + keeps the * read-path-purity contract that getMessagesInWindow established in v2.1.4. */ export declare function getMessagesSummary(agentName: string, status: string, limit: number, sinceIso: string | null): MessageRecord[]; /** * v2.1.6 — return the ISO timestamp at which the agent's current session * started (last register_agent call). NULL for unknown agents OR for rows * registered before v2.1.6 added the column — handler treats NULL as * "no anchor; skip the filter" so we never invent a bound. */ export declare function getAgentSessionStart(agentName: string): string | null; /** * v2.1.6 — operator-driven clean slate for reused agent names. Deletes every * message + task where the agent is sender OR recipient. Preserves the agent * row itself (use `relay recover` for that). Does NOT touch audit_log * entries (forensic record) or channel membership. * * Idempotent: running against an agent with no history returns zero counts. * Wrapped in a single transaction so partial failures don't leave half-purged * state. */ export declare function purgeAgentHistory(agentName: string): { messages_deleted: number; tasks_deleted: number; }; /** * v2.2.2 B3 — list agents whose last_seen is older than the given * ISO-timestamp cutoff. Used by `relay purge-agents` to gather * deletion candidates before asking for operator confirmation. */ export declare function listAgentsOlderThan(cutoffIso: string): AgentRecord[]; /** * v2.2.2 B3 — delete a single agent row if (and only if) its * last_seen is still older than the cutoff. Returns true when the * DELETE landed, false when the row moved (operator came back, race) * or was already gone. Sanctioned-helper home for raw `DELETE FROM * agents` so `relay purge-agents` passes the drift-grep guard. */ export declare function deleteAgentIfAbandoned(name: string, cutoffIso: string): boolean; export declare function broadcastMessage(from: string, content: string, role?: string): { sent_to: string[]; message_ids: string[]; }; /** * v2.10 — capability-routed messaging. Find every registered agent whose * declared capability set includes `capability` (exact-string match against * the agent_capabilities index — same matching contract as post_task_auto). * Optionally excludes the sender. Returns owner agent names (possibly empty). * * The FYI/coordination-lane analogue of post_task_auto's candidate lookup, * with two deliberate differences: (1) it matches a SINGLE capability tag * (membership), not an ALL-OF set; (2) it returns ALL owners for fan-out, not * the single least-loaded pick — an FYI should reach every agent that owns * the domain, not just one. */ export declare function findCapabilityOwners(capability: string, excludeSender?: string | null): string[]; /** * v2.10 — capability-routed messaging fan-out (principle #1: capability * routing over named routing). Routes one FYI/coordination message to the * CURRENT owner(s) of `capability` by inserting one `messages` row per owner, * stamped with `routed_capability` so the recipient + dashboards distinguish * the FYI lane from point-to-point completion reports (the action lane). Recipients * drain via the normal get_messages path — no new read surface. * * No-owner case (design ruling #2): if nobody currently owns the capability, * insert NOTHING and return routed_to:[] — FYI is fire-and-forget to current * owners, NOT queued-until-owner (that would be task semantics). * * Mirrors broadcastMessage's per-recipient transaction + outbox-event + * inbox-changed fan-out so MCP subscribers + the cross-process tail wake. */ export declare function postToCapability(from: string, capability: string, content: string, priority: string, excludeSelf?: boolean): { routed_to: string[]; message_ids: string[]; }; export type SchemaGatingMode = "enforce" | "warn" | "off"; /** * Read the global schema-gating kill-switch. Default 'warn' (shadow): validate * + log violations but ALLOW completion, so a brand-new gate can't wrongly * reject legit completions during rollout. 'enforce' rejects; 'off' skips. * An invalid value falls back to the safe 'warn'. */ export declare function getSchemaGatingMode(): SchemaGatingMode; export interface TaskSchemaRecord { id: string; json_schema: string; /** Parsed JSON Schema document. */ schemaDoc: Record; created_by: string; created_at: string; } /** Thrown when a candidate schema document fails meta-validation at register. */ export declare class SchemaDocumentInvalidError extends Error { errors: string[]; constructor(errors: string[]); } /** Thrown when registering an id that already exists (schemas are immutable). */ export declare class SchemaAlreadyExistsError extends Error { constructor(id: string); } /** Thrown when a schema-gated task is completed with a non-conforming result. */ export declare class ResultSchemaViolationError extends Error { schemaId: string; errors: string[]; constructor(schemaId: string, errors: string[]); } /** Auto-register the built-in schemas. Idempotent; never clobbers overrides. */ export declare function seedBuiltinTaskSchemas(db: CompatDatabase): void; /** * Register a reusable, IMMUTABLE task schema. The document is meta-validated * (validateSchemaDocument) BEFORE it is ever compiled by ajv — ajv code-gens * from the schema, so an unvetted document is an attack surface. Re-registering * an existing id is refused (immutability) — bump the version id instead. */ export declare function registerTaskSchema(id: string, schemaDoc: unknown, createdBy: string): TaskSchemaRecord; /** Fetch a registered task schema by id (parsed doc included), or null. */ export declare function getTaskSchema(id: string): TaskSchemaRecord | null; /** * Validate a task `result` against the task's registered schema. A gated result * MUST be a non-empty string that parses as JSON and conforms; a missing schema * row fails CLOSED (invalid). */ export declare function checkResultAgainstTaskSchema(schemaId: string, result: string | undefined): SchemaCheck; export declare function postTask(from: string, to: string, title: string, description: string, priority: string, schemaId?: string): TaskRecord; export declare function getTasks(agentName: string, role: string, status: string, limit: number): TaskRecord[]; export declare function getTask(taskId: string): TaskRecord | null; /** * Thrown when a CAS-protected mutation finds the row in an unexpected state. * The caller sees a specific message distinguishing this from not-found or * authz errors — under realistic concurrency, the right response is to * re-read the task and decide what to do. */ export declare class ConcurrentUpdateError extends Error { constructor(message: string); } /** * ADR-0012 — thrown by `registerAgent` when a force TAKEOVER loses its * compare-and-swap: the caller passed `expected_session_id` (the session_id it * READ from the row) but the row's session_id no longer equals it — another * relaunch won the takeover, or the row went live between the caller's read and * this write. There is exactly ONE winner by construction; every other racer * lands here. The handler maps this to FORCE_PRECONDITION_FAILED; the caller * MUST re-read (its LIVE gate then skips) and surface LOUDLY — never retry-force, * never come up mute (silence-as-failure). This is the concurrency guarantee * that replaces the old unconditional force bypass (codex-5-5's #131 TOCTOU). */ export declare class ForcePreconditionError extends Error { readonly expectedSessionId: string | null; readonly actualSessionId: string | null; constructor(name: string, expectedSessionId: string | null, actualSessionId: string | null); } /** * v2.1.3 — thrown by sendMessage (and any future sender-originating write) * when the named sender row does not exist at write time. Surfaces the * silent-UPDATE path the predecessor session hit during the post-recover * curl wedge: auth had previously passed, but the row was deleted between * dispatcher verify and handler INSERT — the old code path INSERTed the * message anyway and the sender's last_seen silently stayed frozen. * * Classified as `SENDER_NOT_REGISTERED` by handleSendMessage; caller should * re-register the sender name and retry. */ export declare class SenderNotRegisteredError extends Error { constructor(name: string); } /** * v2.2.1 B2: raised by `handleRegisterAgent` (NOT `registerAgent` — the * collision check lives at the handler layer, not the DB layer) when a * second session tries to claim a name that's still actively held by a * different online session. Pre-v2.2.1 this was a silent warn + session_id * rotation — whichever terminal polled `get_messages` first drained the * mailbox; the other got zero and no error. Caught in the wild * 2026-04-21 during v2.2.0 validation (scoped agent names * prevent duplicate-name shared-inbox drain races). * * The class is kept in db.ts for shared visibility + type-safe catch * blocks, but is thrown ONLY from the handler. Direct db.registerAgent * callers (tests, relay recover internals, migrations) never encounter * it because they bypass the collision check by design. * * The handler maps this to `NAME_COLLISION_ACTIVE` (same error code used * by the v2.1.3 I5 token-mismatch-on-active-row path — "this name is * actively held" is the same concept; only the trigger differs). * * Escape hatches (MCP surface): * - `force: true` field on register_agent — exposed in * RegisterAgentSchema, documented in src/types.ts as the * operator-opt-in override. * - `relay recover ` CLI — force-releases the row at the DB * layer so the next register is a clean bootstrap. */ export declare class NameCollisionActiveError extends Error { readonly existingSessionId: string; readonly lastSeen: string; constructor(name: string, existingSessionId: string, lastSeen: string); } export declare function updateTask(taskId: string, agentName: string, action: TaskAction, result?: string): TaskRecord; export interface AutoRoutingResult { task: TaskRecord; routed: boolean; assigned_to: string | null; candidate_count: number; } /** * Capability-based task routing (v2.0 beta). * * Inserts a task and picks the least-loaded agent whose capability set is a * superset of `requiredCapabilities`. Tie-break: freshest `last_seen`. * If no agent matches, the task is stored with `status='queued'` and * `to_agent=NULL`; it will be picked up on the next `register_agent` of an * agent whose caps match (see `tryAssignQueuedTasksTo`). * * Routing race is benign on insert — two concurrent calls each insert their * own distinct task row. CAS is only required on mutations of existing rows * (health requeue, queued→posted at register time). */ export declare function postTaskAuto(from: string, title: string, description: string, requiredCapabilities: string[], priority: string, options?: { allowSelfAssign?: boolean; }): AutoRoutingResult; export interface QueuedAssignment { task_id: string; from_agent: string; title: string; priority: string; required_capabilities: string[]; } /** * Attempt to assign queued tasks to a newly-registered (or re-registered) * agent. CAS-protected per row: concurrent registers cannot double-assign. * Returns the list of tasks successfully assigned so the caller can fire * webhooks. Bounded by RELAY_AUTO_ASSIGN_LIMIT (default 20) per call. */ export declare function tryAssignQueuedTasksTo(agentName: string, agentCapabilities: string[]): QueuedAssignment[]; export interface HealthReassignment { task_id: string; previous_agent: string; triggered_by: string; from_agent: string; required_capabilities: string[] | null; /** Why the task was requeued — 'lease-expired' (an accepted task whose * assignee stopped renewing its lease) or 'assignee-gone-before-accept' (a * task routed to an agent whose session dropped before it accepted — audit * HIGH #2). Lets callers/webhooks distinguish a normal lease timeout from a * route to a vanished agent. */ reason: "lease-expired" | "assignee-gone-before-accept"; } /** * Lazy health monitor (v2.0 beta). * * Scans for `accepted` tasks whose lease has expired beyond the grace period. * CAS-requeues them (to_agent=NULL, status=queued) and returns the list so * the caller can fire webhooks. Bounded by RELAY_HEALTH_SCAN_LIMIT (default 50). * * Disabled entirely if RELAY_HEALTH_DISABLED=1 (emergency off-switch). * * This function is designed to be called lazily from get_messages / get_tasks * / post_task_auto — the work per call is O(stale tasks) capped at the scan * limit, and the cheap count query short-circuits when nothing is stale. */ export declare function runHealthMonitorTick(triggeredBy: string): HealthReassignment[]; export declare function registerWebhook(url: string, event: string, filter?: string, secret?: string): WebhookRecord; export declare function listWebhooks(): WebhookRecord[]; export declare function deleteWebhook(webhookId: string): boolean; export declare function getWebhooksForEvent(event: string, fromAgent: string, toAgent: string): WebhookRecord[]; /** * v2.1 Phase 4e (F-3a.5): redact error messages before persisting them to * webhook_delivery_log. The dashboard never renders this column today, but * the column exists, a future feature could expose it, and operators + * backups read raw DB rows. Redaction order is most-specific → least: * 1. Full URLs → * 2. Absolute paths → * 3. IPv4 literals → * 4. IPv6 literals → * 5. bcrypt hash prefix → * 6. Long alnum/_=.- tokens (20–128 chars) → * Token pattern is broad; it runs LAST so earlier patterns got a chance * to replace their substrings first. log.warn (stderr) always keeps the * full original so operators can still diagnose. */ export declare function redactErrorMessage(raw: string | null): string | null; export declare function logWebhookDelivery(webhookId: string, event: string, payload: string, statusCode: number | null, error: string | null): void; /** * Returned to the webhook-firing caller so the retry attempt can POST the * URL without a schema lookup. terminal_status distinguishes "still pending * retries" (NULL) from "delivered" / "failed". */ export interface WebhookRetryJob { log_id: string; webhook_id: string; url: string; secret: string | null; event: string; payload: string; retry_count: number; } /** * Record a failed initial delivery so it will be retried later. Call this * from the webhook dispatcher when the first POST fails. */ export declare function scheduleWebhookRetry(webhookId: string, event: string, payload: string, initialError: string): void; /** * v2.1 Phase 4e: terminate a retry row immediately (skip backoff ladder). * Called on DNS-rebinding refusal — retry would just feed the attacker. */ export declare function terminateWebhookRetry(logId: string, reason: string): void; /** * Record a successful terminal delivery — short-circuits further retries * of the same attempt row. */ export declare function markWebhookDelivered(logId: string): void; /** * Pull and claim retry jobs whose `next_retry_at` has matured. CAS-protected * per row — two callers cannot claim the same job. * * v2.0.1 (Codex HIGH 3): crash-safe claim via a 60-second lease. Replaces * the previous "next_retry_at = NULL" claim marker which stranded rows * forever if the owning process crashed between claim and outcome. * * Claim eligibility: * - terminal_status IS NULL (not delivered or permanently failed) * - retry_count > 0 (there was an initial failure) * - next_retry_at <= now (time to attempt) * - AND (claimed_at IS NULL OR claim_expires_at < now) — unclaimed OR * previous claim expired (crashed owner) * * Bounded by RELAY_WEBHOOK_RETRY_BATCH_SIZE (default 10) per call. * Lease duration: RELAY_WEBHOOK_CLAIM_LEASE_SECONDS (default 60). */ export declare function claimDueWebhookRetries(): WebhookRetryJob[]; /** * Resolve the outcome of a retry attempt. Success → terminal delivered. * Failure with attempts remaining → schedule next backoff. Failure at max → * terminal failed. */ export declare function recordWebhookRetryOutcome(logId: string, succeeded: boolean, statusCode: number | null, error: string | null): void; export interface ChannelRecord { id: string; name: string; description: string | null; created_by: string; created_at: string; } export interface ChannelMessageRecord { id: string; channel_id: string; from_agent: string; content: string; priority: string; created_at: string; } export declare function createChannel(name: string, description: string | null, createdBy: string): ChannelRecord; export declare function joinChannel(channelName: string, agentName: string): { joined: boolean; channel_id: string; }; export declare function leaveChannel(channelName: string, agentName: string): { left: boolean; }; export declare function postToChannel(channelName: string, fromAgent: string, content: string, priority: string): ChannelMessageRecord; export declare function getChannelMessages(channelName: string, agentName: string, limit: number, since?: string): ChannelMessageRecord[]; export declare function listChannels(): ChannelRecord[]; export {}; //# sourceMappingURL=db.d.ts.map