/** * Shared RTDB `/control/{conversationId}/{agentId}` channel poller. * * Four hosts historically implemented this loop independently (agent-sdk, * claude-code-plugin, codex-plugin, openclaw-plugin) with duplicated * constants and drifting semantics. This class covers the union of their * behaviors via configuration so each host can migrate onto one engine: * * - Keys: `session` (claude/codex), `signal` (all), `primitive` (sdk/openclaw), * always read in session → signal → primitive order. * - Cadence: fixed interval (agent-sdk) or active/idle delays + jitter * (claude/codex/openclaw), with the activity probe sampled either at cycle * start (claude/codex) or after the poll completes (openclaw). * - Dedupe: signals/session controls gate on `updatedAt > lastSeen`, primed * either eagerly via {@link ControlChannelPoller.baseline} (sdk/claude/codex) * or lazily against the poller start time (openclaw). * - Consume: nodes are cleared by writing `null`; whether a node is consumed * after a handler error is configurable per key, and primitive dedupe * entries may be released only on successful consume (agent-sdk). * * Normalizations shared by every profile (pinned by the characterization * tests as the new contract): * - Single-flight polling via setTimeout chaining — no overlapping cycles, * even where the legacy host used a flat setInterval. * - stop()/start() during an in-flight poll can never double-schedule. * - A null agent id skips the cycle but keeps the cadence alive. * - No added backoff on errors: the next cycle runs at the normal cadence. * * The poller only ever talks to the scoped RTDB handle it is constructed * with — it must never touch the module-global default client from * `rtdb-rest.ts`, so per-runtime isolation survives the host migrations. */ export type ControlSignalType = 'interrupt' | 'stop_and_drop' | 'new_session'; export type ControlChannelKey = 'session' | 'signal' | 'primitive'; /** Minimal scoped RTDB surface; `RTDBClientHandle` satisfies it. */ export interface ControlChannelRTDB { read(path: string): Promise; write(path: string, data: unknown): Promise; } export interface ControlSignalEvent { conversationId: string; type: ControlSignalType; updatedAt: number; raw: Record; } export interface ControlSessionEvent { conversationId: string; updatedAt: number; control: Record; } export interface ControlPrimitiveEvent { conversationId: string; requestId: string; updatedAt: number; value: Record; } /** * Handlers may return `{ consume: false }` to leave the RTDB node in place * while still marking it seen — it will not be re-dispatched for this * revision (e.g. claude host skips consuming session controls when no live * session exists). Returning `{ defer: true }` means "not mine yet": the node * is left in place AND stays unseen, so it is re-dispatched on the next cycle * — this is how a runtime that registers a handler after polling has started * still receives signals observed during the gap. Returning nothing consumes * the node after a successful handle. A throw never defers: the node is * marked seen (and consumed per `consumeOnError`) so a crashing handler * cannot spin on the same request. */ export type ControlHandlerResult = { consume?: boolean; defer?: boolean; } | void; export interface ControlSignalHandlerConfig { handle(event: ControlSignalEvent): Promise | ControlHandlerResult; /** * Consume the signal node even when the handler throws. * agent-sdk/openclaw: true. claude/codex hosts: false (default). */ consumeOnError?: boolean; } export interface ControlSessionHandlerConfig { handle(event: ControlSessionEvent): Promise | ControlHandlerResult; /** claude/codex hosts leave the node in place on handler errors (default false). */ consumeOnError?: boolean; } export interface ControlPrimitiveHandlerConfig { handle(event: ControlPrimitiveEvent): Promise | ControlHandlerResult; /** Both primitive hosts consume requests even when the handler throws (default true). */ consumeOnError?: boolean; /** * `sequential` handles object entries oldest-first by `updatedAt` (agent-sdk); * `parallel` handles all entries concurrently and unsorted (openclaw). * Default: `sequential`. */ ordering?: 'sequential' | 'parallel'; /** Dedupe entry TTL; omitted means entries are remembered forever (openclaw). */ dedupeTtlMs?: number; /** Oldest dedupe entries are evicted beyond this cap (agent-sdk: 1000). */ dedupeMaxEntries?: number; /** * Release the dedupe entry once the request node is successfully consumed, * so a lingering node (failed consume) stays suppressed until TTL expiry * while cleared requests free their slot immediately (agent-sdk). Default false. */ releaseDedupeOnConsume?: boolean; /** * Consume entries whose value is not a plain object instead of leaving them * untouched. openclaw: true (normalize-fail → consume); agent-sdk: false. */ consumeInvalidEntries?: boolean; /** * Skip (but still consume) requests stamped before the poller started * (openclaw replay guard). Default false. */ skipStaleBeforeStart?: boolean; } export type ControlPollerCadence = { /** Flat interval between cycle completions (agent-sdk: 2000ms). */ kind: 'fixed'; intervalMs: number; } | { /** Active/idle delays + jitter (claude/codex/openclaw: 2000/10000 + [0,1000)). */ kind: 'adaptive'; activeMs: number; idleMs: number; jitterMs: number; hasActiveWork: () => boolean; /** * When the activity probe is sampled for the post-cycle delay: * claude/codex sample before the poll runs (`cycle-start`, default); * openclaw samples after the poll completes (`cycle-end`). */ activitySample?: 'cycle-start' | 'cycle-end'; }; export interface ControlPollerError { scope: 'read' | 'consume' | 'handler' | 'poll'; key?: ControlChannelKey; conversationId?: string; requestId?: string; error: unknown; } export interface ControlChannelPollerOptions { /** Scoped RTDB handle — the poller never touches the module-global default client. */ rtdb: ControlChannelRTDB; /** Static agent id, or a getter for hosts whose id can become unavailable. */ agentId: string | (() => string | null); /** Live conversation scope, snapshotted at the start of every cycle. */ conversationIds: () => Iterable; cadence: ControlPollerCadence; /** * Run the first cycle immediately on start (claude/codex/openclaw). * `false` waits one cadence delay first (agent-sdk setInterval semantics). * Default true. */ pollOnStart?: boolean; /** * `sequential` polls conversations one at a time (claude/codex); * `parallel` polls them concurrently (agent-sdk/openclaw). Default `sequential`. */ conversationConcurrency?: 'sequential' | 'parallel'; /** * Lazily prime signal/session dedupe against the poller start time on first * sighting instead of requiring an eager {@link ControlChannelPoller.baseline} * pass (openclaw). Default false. */ lazyBaselineFromStart?: boolean; handlers: { session?: ControlSessionHandlerConfig; signal?: ControlSignalHandlerConfig; primitive?: ControlPrimitiveHandlerConfig; }; onError?: (error: ControlPollerError) => void; /** Injectable jitter source (default Math.random). */ random?: () => number; /** Injectable clock (default Date.now). */ now?: () => number; } export declare class ControlChannelPoller { private readonly options; private timer; private running; private startedAt; private readonly lastSeenSignal; private readonly lastSeenSession; private readonly primitiveDedupe; constructor(options: ControlChannelPollerOptions); start(): void; stop(): void; isRunning(): boolean; /** * Eagerly prime signal/session dedupe from the current RTDB nodes without * handling them (agent-sdk at connect; claude/codex at session creation). */ baseline(conversationIds: Iterable): Promise; /** Run one full poll cycle across the current conversation scope. */ pollOnce(): Promise; private schedule; private runCycle; private sampleActivity; private nextDelayMs; private pollConversation; private pollSessionKey; private pollSignalKey; private pollPrimitiveKey; private processPrimitiveEntry; private dispatch; private readKey; private consumeKey; private baselineKey; private maybeLazyBaseline; private prunePrimitiveDedupe; private releasePrimitiveDedupe; private controlBasePath; private resolveAgentId; private reportError; private random; private now; }