import type { NodeMeta, NodeIdentity, NodeRow, NodeStatus, ExitIntent, SubscriptionRef, Mode, Lifecycle } from './types.js'; /** Create a node: scaffold its dirs, persist identity to meta.json, and seed the * row (identity + runtime from the incoming meta). Returns the hydrated view. */ export declare function createNode(meta: NodeMeta): NodeMeta; /** The canonical node record: durable identity (meta.json) ∪ authoritative * runtime (the row). Null if unknown. */ export declare function getNode(nodeId: string): NodeMeta | null; /** The indexed row (from the db) — cheap for queries that don't need full meta. */ export declare function getRow(nodeId: string): NodeRow | null; /** The node row whose durable LOCATION pane is `pane`, or null. Lets placement * resolve "who sits in this pane" by the first-class `%pane_id` handle (e.g. * to adopt a caller's pane as a focus). pane is not UNIQUE in the schema, but a * live pane backs at most one node, so this returns the single match. */ export declare function getRowByPane(pane: string): NodeRow | null; /** Merge an IDENTITY patch into a node's meta.json and re-index its identity * columns. Identity has a single writer per node, so this read-modify-write is * safe (the contended runtime fields were moved out — see the atomic setters * below). Returns the hydrated view (runtime included). */ export declare function updateNode(nodeId: string, patch: Partial): NodeMeta; /** Set a node's status. Atomic single-column write. */ export declare function setStatus(nodeId: string, status: NodeStatus): void; /** Set a node's exit intent. Atomic single-column write. */ export declare function setIntent(nodeId: string, intent: ExitIntent): void; /** Set a node's tmux presence in one atomic write: the durable LOCATION anchor * `pane` (the `%pane_id`) plus its derived cache (`tmux_session` + `window`). * All three move together — `pane` joins the others inside the single UPDATE so * a move never half-writes the location. `pane` is optional: a caller that does * not yet track it (every caller, until the placement layer lands) writes null, * which is harmless because nothing reads `pane` yet. */ export declare function setPresence(nodeId: string, presence: { tmux_session?: string | null; window?: string | null; pane?: string | null; }): void; /** Record the live pi pid (daemon liveness signal) PLUS its launch-time * identity fingerprint, in one atomic write (crouter#98 review finding 1): * `capturePidIdentities([pid])` is a signal-0-adjacent `ps -p ` probe * taken RIGHT NOW, while `pid` is the process we just spawned/bound and * therefore definitely still exists — this is the LAUNCH-time baseline * `headlessBrokerHost.teardown()` later compares against before signaling * anything. A failed probe (rare — `ps` itself misbehaving) or a probe that * finds no row for `pid` (raced away in the instant between spawn and this * call) both write `NULL`: no baseline recorded, so the teardown guard fails * open exactly like a node booted before this column existed — never a false * identity that could itself cause a false mismatch later. */ export declare function recordPid(nodeId: string, pid: number): void; /** Clear the pi pid (window-backed relaunch, before the fresh pi re-records it) * AND its recorded launch-time identity — the two are written together by * `recordPid` and must be cleared together, or a subsequent relaunch that * reuses the pid namespace could compare its own fresh pid against a STALE * identity left over from the pid this row held before. */ export declare function clearPid(nodeId: string): void; /** ONE-TIME completion of the boot-scoped-identity format migration, run once * at daemon startup (the natural boot-reconciliation chokepoint, right after * `reconcileBootLiveness`). It closes the gap the `identitiesMatch` legacy * lane only TOLERATES: a broker that survives an in-place crouter upgrade * keeps its old `lstart#ticks` row indefinitely (`recordPid` only fires on * launch/revive/session_start), so the cross-boot ticks-collision exposure the * boot-scoped base was added to close never provably shuts for those rows. * * For every row whose RECORDED identity is legacy-format while this platform * now composes new-format (a readable kernel boot_id): * - if the recorded pid's CURRENT live identity matches the recorded one via * the migration lane (same live process, `identitiesMatch` true) AND that * current identity is itself boot-scoped — re-record it with the new * composer, completing the migration in place; * - otherwise (pid gone, probe failed, reused — no match, or no readable * boot_id for it) — leave the row untouched for the normal liveness * handling that already runs each tick. * * After this runs once, the legacy compare lane only ever engages for * genuinely no-boot_id platforms. NOT a per-caller reconciliation layer — a * single startup sweep. `captureIdentity` is injectable for unit tests. * * GATED on proven same-boot provenance by its caller (daemon startup runs it * only when `reconcileBootLiveness` returned `sameBootProven` — see * `bootProvenSame`). This is what makes a bare ticks match safe to bless as a * current-boot identity: without that proof an equal-ticks collision with an * unrelated process from a DIFFERENT boot could be rewritten as * `#`, permanently baptizing a stranger. * * Not transactional by design: it never HOLDS the write lock across the (slow, * per-row) `ps` probe. Each re-record is a compare-and-swap single-row UPDATE * constrained by ALL three snapshotted values (`node_id`, `pi_pid`, * `pi_pid_identity`), counted only when a row actually changed. So if the old * broker exits and a concurrent revive atomically records a fresh * `(pi_pid, pi_pid_identity)` between this sweep's probe and its update, the * CAS matches zero rows and the migration is skipped rather than clobbering * the newer identity with the stale one. */ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: number) => string | undefined): { migrated: string[]; }; /** All rows, optionally filtered by status. Unclaimed warm spares are excluded. */ export declare function listNodes(filter?: { status?: NodeStatus | NodeStatus[]; }): NodeRow[]; /** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it * from every listing until it is claimed. */ export declare function registerWarmSpare(nodeId: string, recipeKey: string): void; /** Take a parked spare OUT of the pool without claiming it — the reaper's * first move. Returns false when the marker was already gone (a concurrent * claim won it), which is the reaper's signal to leave that node alone: it is * somebody's live conversation now, not a spare. Deleting the marker under the * write lock is exactly what `claimWarmSpare` does, so the two can never both * succeed on the same node. */ export declare function unregisterWarmSpare(nodeId: string): boolean; /** One parked spare, joined to the node row a claim/reap needs to judge it. */ export interface WarmSpareRow { node_id: string; recipe_key: string; /** Mint time — the claim order and the TTL clock. */ created: string; status: NodeStatus; pi_pid: number | null; pi_pid_identity: string | null; } /** Every parked spare, oldest first — what the reaper (`runtime/warm-pool.ts` * `reapStaleSpares`) sweeps. Deliberately NOT filtered by liveness: judging a * spare is the caller's job. */ export declare function listWarmSpares(): WarmSpareRow[]; /** True while `nodeId` is an UNCLAIMED spare — nobody owns this conversation * yet. `registerWarmSpare` runs the instant the row exists and BEFORE the * broker launch, and a claim deletes the marker atomically, so this is a * reliable read from inside the booting engine (the context-intro extension * asks it at `session_start` to withhold bearings that would otherwise assert * the spare's placeholder persona instead of its eventual claimant's). */ export declare function isWarmSpare(nodeId: string): boolean; /** Hand out the oldest CLAIMABLE spare matching `recipeKey`, or null when the * pool has none. The select+delete run inside ONE canvas write-lock * transaction, so two concurrent creates can never be handed the same spare. * A spare whose broker is gone is skipped here and reaped by * `reapStaleSpares` — nothing ever resumes a spare (the recovery sweep reads * `listNodes`, which hides them), so a dead spare is garbage, not a parked one. * * The claim also restamps the row's `created` to `created` — birth time is a * spare's MINT time, and consumers sort conversations on it, so an hour-old * spare must not sort as an hour-old conversation. This is the one sanctioned * write of `created` after birth (`upsertRow` deliberately never touches it); * the caller mirrors the same stamp into meta.json. */ export declare function claimWarmSpare(recipeKey: string, created: string): string | null; /** How many CLAIMABLE spares are parked for `recipeKey` (the depth check a * refill reads before minting). Uses the same liveness test as the claim, so a * spare the claim would skip can never hold the pool at depth 1 and starve * every later create of a warm start. */ export declare function warmSpareCount(recipeKey: string): number; /** Direct children of `id`, oldest first — an indexed point query * (`idx_nodes_parent`) instead of a full-table `listNodes()` scan. */ export declare function childrenOf(id: string): string[]; /** One lean roster row: only the `nodes` columns already native to the row * (no meta.json read). Backs `GET /v1/canvas/roster` — the recurring attach / * browser topology poll, distinct from the enriched per-row * `dashboardRowsAll`+`enrichRows` snapshot (`GET /v1/canvas/snapshot`). */ export interface RosterRow { node_id: string; name: string; kind: string; mode: Mode; lifecycle: Lifecycle; status: NodeStatus; cwd: string; host_kind: 'tmux' | 'broker' | null; parent: string | null; created: string; } /** Every node's lean roster row, oldest first — a single scan indexed by * `idx_nodes_created`. No per-row `getNode`/meta.json read, no N+1. */ export declare function rosterNodes(): RosterRow[]; /** One `subscribes_to` edge, both endpoints — the roster's topology half. */ export interface RosterEdge { from_id: string; to_id: string; active: boolean; created: string; } /** Every `subscribes_to` edge (passive + multiple preserved, never collapsed) — * a single scan indexed by `idx_edges_from`/`idx_edges_to` (composite * `(type, from_id|to_id)`, so a bare `type` predicate still uses one of * them). Paired with `rosterNodes()` as the roster's exactly-two-query * contract. */ export declare function rosterEdges(): RosterEdge[]; /** Resumable ROOT conversations pinned to `cwd`, most-recently-created first. * A "root" is the top of a spine (`parent IS NULL`) that is a real * conversation (not a `kind:'human'` ask) and still resumable (`active`/`idle`, * never a `done`/`dead`/`canceled` node). This backs the front-door * `crtr -c`/`-r` resume: cwd is the key (the Claude-Code mental model — "my * last session HERE"), so it never teleports across projects. */ export declare function listResumableRoots(cwd: string): NodeRow[]; /** Record `A subscribes_to B` — A receives B's output. active=true wakes A on * emit; passive accumulates pointers without a wake. Mutable; callable by anyone. */ export declare function subscribe(subscriber: string, publisher: string, active?: boolean): void; /** Drop a subscription edge. */ export declare function unsubscribe(subscriber: string, publisher: string): void; /** Flip an existing subscription's wake behavior. */ export declare function setSubscriptionActive(subscriber: string, publisher: string, active: boolean): void; /** Record the audit-only `child spawned_by parent` edge. */ export declare function recordSpawn(child: string, parent: string): void; /** Who subscribes to `publisher` — the targets a push fans out to. */ export declare function subscribersOf(publisher: string): SubscriptionRef[]; /** Who `subscriber` subscribes to — its reports / the nodes feeding it. */ export declare function subscriptionsOf(subscriber: string): SubscriptionRef[]; /** A "view": every node whose output cascades up to `root` via subscriptions — * the subscription sub-DAG reachable downward (root → its reports → theirs …). * Returns ids excluding root, in BFS order. Cycle-safe. */ export declare function view(root: string): string[]; /** Stop-guard primitive: does this node hold an *active* subscription to a node * that's still live (active|idle) — i.e. something that can actually wake it? * If so, stopping is a legitimate await; if not, it must finish or escalate. */ export declare function hasActiveLiveSubscription(nodeId: string): boolean; /** Whether a node explicitly awaits a controller that can still deliver. A * parent relationship never implies this wait; the node must declare it. */ export declare function hasLiveMessageWait(nodeId: string): boolean; /** Settle an explicit message wait before appending the qualifying delivery. * This ordering makes the delivery's one wake observe cleared durable state. */ export declare function setMessageWait(nodeId: string, controller: string): void; /** Append a qualifying delivery and settle its matching wait while holding the * canvas write lock. A concurrent `node wait controller` runs before or after this whole * boundary, never between the durable inbox append and its compare-and-clear. */ export declare function deliverAndSettleMessageWait(nodeId: string, from: string | null, deliver: () => T): T; /** Write one controller-death notice and settle only the still-current dead * wait. The check, notice append, and clear share one canvas write boundary. */ export declare function settleDeadMessageWait(nodeId: string, controller: string, deliver: () => T): T | null; /** The outcome of {@link withFreshTerminalGuard} — exactly one of three * tagged states, never conflated: * - `'ran'`: the fresh read found a live, non-terminal row and `body` (the * guarded write) executed; its result is carried back. * - `'terminal'`: the row exists but is already terminal by `isTerminal`'s * predicate — `body` never ran. The snapshot (`status`, `final_report`) is * reported for the caller's error message. * - `'missing'`: no row for `nodeId` at all (deleted between the caller's * own selection/validation and this guard's fresh read) — `body` never ran. * A missing target is NOT a terminal target: it carries no `status`/ * `final_report` to report, and every caller must fail loud with its own * not-found contract rather than treat it as either a successful run or a * terminal rejection. */ export type TerminalGuardResult = { kind: 'ran'; result: T; } | { kind: 'terminal'; status: NodeStatus; final_report: string | null; } | { kind: 'missing'; }; /** #343: close the TOCTOU between checking a target has a natural cycle ahead * of it and the write that depends on that (for example, a deferred inbox * append). Fresh-reads `nodeId`'s * (status, final_report) and either runs `body` (a write) or short-circuits * with a `'terminal'`/`'missing'` outcome — the read and `body`'s write share * ONE canvas write-lock boundary (`withCanvasWrite`'s `BEGIN IMMEDIATE`), so * a concurrent finish/cancel/finalization/deletion either commits entirely * before this reads (observed here, `body` never runs) or entirely after * (`body` completes uncontested) — never in the gap a separate check-then- * write left open. Pass the shared `hasNoNaturalCycle` predicate rather than * a bespoke one so the two never drift. * * #343 review (Major): a dangling `nodeId` — the row deleted after the * caller's own selection but before this guard's fresh read — MUST NEVER run * `body`. Reporting `'missing'` distinctly from `'terminal'` (rather than * folding it into either the terminal branch or a fabricated "safe" empty * snapshot) keeps the two failure shapes from being conflated: a terminal * target has a real `status`/`final_report` to report and its own * `deferred_no_natural_cycle`-style rejection, while a missing target has * nothing to report and belongs to the caller's own not-found contract. */ export declare function withFreshTerminalGuard(nodeId: string, isTerminal: (row: { status: NodeStatus; final_report: string | null; }) => boolean, body: () => T): TerminalGuardResult; /** Rebuild node rows from on-disk metas (the db node table is a derived index). * Only the IDENTITY columns are rebuilt — they are a projection of meta. The * runtime columns (status/intent/pi_pid/window/tmux_session/pane) are NOT in meta * and NOT re-derivable from it: they describe live process/presence state, so * an existing row keeps them and a freshly re-created row takes the schema's * quiescent defaults (status='active', the rest null). The daemon reconciles * liveness from tmux reality, not from a stale file. * Edges are left intact — subscribes_to is db-authoritative; spawned_by is * re-derived from each meta's `spawned_by` (fallback: `parent` for legacy metas). */ export declare function rebuildIndex(): void; /** A node selected for pruning (or that would be, under --dry-run). */ export interface PrunedNode { node_id: string; status: NodeStatus; created: string; } export interface PruneResult { /** The nodes pruned (or, under dryRun, the nodes that WOULD be pruned). */ pruned: PrunedNode[]; dryRun: boolean; } /** {@link deleteNode}'s result: whether it actually deleted the node. */ export interface DeleteNodeResult { deleted: boolean; } /** Hard-delete ONE node: drop its row (the edges→nodes FK, ON DELETE CASCADE, * GCs its edges) and remove its on-disk `nodes//` dir. The single-node * analogue of {@link pruneNodes}, for IMMEDIATE reaping — e.g. an empty node the * user closed or detached from. An open managed worktree is never deleted: its * node retains the ownership needed for `node worktree close`. Nor is a node * with a LIVE (`armed`/`pending`) follow-up human-work receipt — its report payload * is still needed for delivery (integrated-review Major-2). Pure persisted- * state removal: the caller MUST have already torn down the broker engine + * viewer (this does not signal any process). The node row and its FK-coupled * state are removed in one canvas write boundary. Best-effort dir removal * follows the commit. */ export declare function deleteNode(nodeId: string): DeleteNodeResult; /** Retention sweep: remove TERMINAL nodes (status dead | done | canceled) whose * `created` is older than `ttlDays`, bounding the otherwise-unbounded growth of * node rows + dirs. Open managed-worktree nodes are retained so their checkout * remains owned until `node worktree close`. The edges→nodes FK (`ON DELETE * CASCADE`, migration v4) GCs each pruned node's edges automatically; the * on-disk `nodes//` dir is removed too. * * With `includeStale`, ALSO prunes nominally-live (active | idle) nodes past the * TTL whose process is provably gone — `pi_pid` is NULL or no longer alive. This * reaps stale roots (a bare `crtr` whose pi died without the row transitioning), * which the daemon's supervision never reconciled. A genuinely-running node keeps * a live `pi_pid`, so it is protected, as is the CALLER ($CRTR_NODE_ID). Without * the flag, active | idle are NEVER touched (the daemon's domain). * * Row deletes run in ONE transaction, so the sweep is all-or-nothing. Dir * removals follow after COMMIT — the fs isn't transactional, and by then * the rows are gone, so a re-run never re-finds a half-deleted node. `dryRun` * reports the candidate set and deletes NOTHING. */ export declare function pruneNodes(opts: { ttlDays: number; dryRun?: boolean; includeStale?: boolean; }): PruneResult; /** Count-based retention sweep: retain the `maxNodes` MOST-RECENTLY-ACTIVE * nodes plus every protected node, so the real retained floor is * `max(maxNodes, protectedCount)`. Bounds the otherwise-unbounded growth the * TTL sweep can't (a canvas that never crosses the TTL still grows without * limit). * * Protected (never a prune candidate, regardless of recency rank): any * non-terminal node — status active|idle — REGARDLESS of `pi_pid` liveness, * a node with an open managed worktree, and a node with a LIVE * (`armed`/`pending`) follow-up human-work receipt. A crashed-but-non-terminal row * (dead pid, status still active/idle) must survive for daemon crash-recovery * to resume, an open worktree must retain its node ownership until * `node worktree close`, and a live consult receipt's report payload is still * needed for delivery (integrated-review Major-2); none of the three is * eligible for count-based pruning. The caller ($CRTR_NODE_ID) is also always * protected. * * Algorithm: order ALL nodes by recency DESC (telemetry.json mtime, falling back * to `created`); `target = max(maxNodes, protectedCount)`; delete every node * ranked beyond `target` (0-indexed rank >= target) that is NOT protected and * NOT the caller. Retained = all protected + the newest terminal nodes up to * `target`. * * Mirrors {@link pruneNodes} exactly for the delete (shared `deletePrunedNodes`: * transactioned row sweep + FK edge cascade + best-effort dir removal). `dryRun` * reports the candidate set and deletes NOTHING. */ export declare function pruneToLimit(opts: { maxNodes: number; dryRun?: boolean; }): PruneResult;