/** * Worker pool — manages forking, killing, and lifecycle of worker processes. * Extracted from daemon.ts for modularity. */ import { type ChildProcess } from 'node:child_process'; import { type MojoConfig, type MojoLivePatch } from '../adapters/backend/mojo-types.js'; import { type CardUsageSnapshot } from '../im/lark/md-card.js'; import { killPersistentBackendTarget } from './persistent-backend.js'; import { type RestartObserver } from './restart-coordinator.js'; /** * Retiring worker processes for a session id — for liveness gates that outlive * the registry entry. A collision loser (or any process-only retirement) is * deleted from the registry while its process is still dying; a VC fence armed * in that window would otherwise find no ds, skip its producer-liveness gate, * and clear on pane probes alone (fifth-round review, A1 residual). */ export declare function retiringWorkersForSession(sessionId: string): ChildProcess[]; /** * Start a new worker generation's launcher-env ledger. * * The previous generation's keys are PARKED, never dropped — and deliberately * WITHOUT consulting whether that worker has exited. * * A worker exit does not prove the dangerous process is gone, and the dangerous * env acts on the mojo CLI *child*, not on the worker that parents it: * - `MojoBackend.kill()` sends a bare `SIGTERM`, nulls its handle and reports * `exitCb(0)` immediately — no SIGKILL escalation, no wait. A child that * traps/ignores TERM, or is slow, survives it. * - the worker's close path then runs `killCli()` and `process.exit(0)` without * awaiting the child, so worker exit can precede child death. * - a mojo child may also have detached descendants, so even a per-PID exit * proof would not cover the process tree. * `PATH`-substituted launchers and `LD_PRELOAD` hooks live in exactly that child, * which is what holds the activated device credential. * * So the ledger is monotonic for the life of this DaemonSession: it disappears * only when the session ends or the daemon restarts (it is in-memory), never on * an exit signal we cannot verify. A stricter alternative — escalating to SIGKILL * and waiting for process-GROUP quiescence before releasing — is the only way to * make release sound, and is deliberately out of scope here: it needs new * teardown machinery and would still have to cover descendants, whereas monotonic * retention is fail-closed by construction. * * Cost: a session that was ever handed a dangerous launcher env stays * unprovable until it ends. That is an availability trade, not a credential leak. * * Exported so the behavioural guards can assert this directly — deleting the * three call sites used to leave every test green. */ export declare function startNewGenerationEnvLedger(ds: DaemonSession, initEnv: Record | undefined): void; /** Symmetric lifecycle fence: relay must not start while another operation is * still restarting, spawning, suspending, or closing this worker generation. */ export declare function isSessionLifecycleInFlight(ds: DaemonSession): boolean; export declare function getDaemonBootId(): string; /** Read one frozen native-usage snapshot at the reply boundary. Card delivery * remains best-effort even when a CLI has no supported transcript or a usage * resolver fails. */ export declare function getDaemonSessionUsageSnapshot(ds: DaemonSession, effectiveCliId?: CliId, opts?: { fresh?: boolean; }): CardUsageSnapshot; /** Reply-card (final output / adopt preamble / local-turn) usage. Only the * `'footer'` display mode surfaces usage here; `'streaming'` and `'off'` yield a * concrete empty snapshot. Keeping the display decision out of the native usage * reader leaves accounting and dashboard consumers intact; the concrete empty * snapshot (rather than undefined) also freezes "hidden" over final-output * retries. */ export declare function getDaemonReplyCardUsageSnapshot(ds: DaemonSession, effectiveCliId?: CliId): CardUsageSnapshot; /** Streaming-card usage. Only the `'streaming'` display mode (the default) * surfaces usage in the live card body; `'footer'` and `'off'` yield empty. * Returns a concrete empty snapshot on any config failure so the streaming * renderer stays best-effort. `fresh` forces an exact read at meaningful * boundaries (turn end / idle); intra-turn ticks leave it false to ride the * reader's reparse throttle and stay off the disk. */ export declare function getDaemonStreamingCardUsageSnapshot(ds: DaemonSession, effectiveCliId?: CliId, opts?: { fresh?: boolean; }): CardUsageSnapshot; import type { CliId } from '../adapters/cli/types.js'; import type { CliTurnPayload, FrozenSessionReplyTarget, DaemonToWorker, TrustedCaller, WorkerToDaemon, Session, StreamStatus, QueuedActivationTailEntry } from '../types.js'; import { type DaemonSession } from './types.js'; import { type CliUsageLimitState } from '../utils/cli-usage-limit.js'; import { type VcMeetingListenerTopicKey } from '../services/vc-meeting-listener-topic-store.js'; type WorkerStartupState = { ready: boolean; failureNotified: boolean; /** Init turn attribution frozen at fork. A durable VC delivery is dispatched * (queued) into a not-yet-ready worker; if that worker dies before ready * (fork ENOENT, syntax/import crash, abrupt exit) the fork-level `error` and * pre-ready `exit` paths must route the failure through the same receipt/lease * gate as a structured error, not reply out-of-band. */ initTurnId?: string; initDispatchAttempt?: number; }; export declare const TRANSFER_DETACH_FENCE_PICKER_MS = 8000; export interface WorkerSessionReplyOptions { uuid?: string; quoteMessageId?: string; beforeQuoteFallback?: () => void | Promise; /** Do not fan meeting-derived content out through user-configured outbound * hooks. Dedicated VC replies have one audited external effect: Lark. */ suppressHook?: boolean; /** Exact daemon session that produced this output. Dedicated VC receivers * share a visible chat anchor with ordinary sessions, so the anchor alone * cannot identify the transcript/lifecycle owner. */ sourceSessionId?: string; /** Exact daemon-frozen destination for a durable turn. */ replyTarget?: FrozenSessionReplyTarget; /** Automatic VC delivery presentation. Explicit human IM replies omit this * and continue to follow their quote/thread context. */ placement?: 'auto' | 'chat' | 'topic'; meetingTopicKey?: VcMeetingListenerTopicKey; } export interface WorkerPoolCallbacks { sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string, opts?: WorkerSessionReplyOptions) => Promise; getSessionWorkingDir: (ds?: DaemonSession) => string; getActiveCount: () => number; /** Close a stale session (message withdrawn, etc.). `false` means the * authoritative close failed and the active owner must remain retryable. * `void` is retained for older embedders/tests that implement a synchronous * best-effort close; the production daemon always returns an exact boolean. */ closeSession: (ds: DaemonSession) => boolean | void | Promise; /** Re-check the per-bot resident-session cap after a process starts or an * over-cap busy session becomes idle. Optional for unit-test callers. */ enforceLiveSessionCap?: () => void; /** Durable consumers subscribe to transcript-backed turn completion here. * Optional so ordinary sessions and tests keep their existing behavior. */ onTurnTerminal?: (ds: DaemonSession, terminal: Extract, context: { workerGeneration: number; }) => void | Promise; /** A hidden fresh-topic schedule can be reclaimed once its exact turn is * settled. Transcript-backed CLIs report `terminal`; screen-only/remote * adapters use the existing debounced idle edge as a compatibility fallback. */ onDeferredScheduleTurnSettled?: (ds: DaemonSession, context: { turnId: string; source: 'terminal' | 'idle'; }) => void | Promise; /** A process exit makes every unresolved receipt dispatched to this exact * worker generation ambiguous; the receiver decides retry policy. */ onWorkerExit?: (ds: DaemonSession, context: { sessionId: string; workerGeneration: number; code: number | null; signal: NodeJS.Signals | null; }) => void | Promise; /** The managed CLI can crash and auto-restart inside a still-live Node * worker. Durable receipts dispatched to this generation become ambiguous * even though `onWorkerExit` will not fire. */ onCliExit?: (ds: DaemonSession, context: { sessionId: string; workerGeneration: number; code: number | null; signal: string | null; }) => void | Promise; /** Boot recovery worker confirms its old persistent CLI was fenced before * receiver delivery endpoints may accept a replay. */ onReceiverResetReady?: (ds: DaemonSession, context: { sessionId: string; turnId: string; dispatchAttempt: number; }) => void; /** Runtime lease-expiry worker confirms the exact attempt is no longer able * to execute before the receiver accepts its replay. */ onDurableExpiryReady?: (ds: DaemonSession, context: { sessionId: string; turnId: string; dispatchAttempt: number; workerGeneration: number; disposition: 'queued_removed' | 'cli_fenced'; }) => void; /** Called only after a durable ledger mutation was persisted and its worker * ACK was attempted. Runtime CLI-mismatch cleanup may now close the old * generation without abandoning a FIFO entry. */ onCodexAppLedgerDrained?: (ds: DaemonSession) => void | Promise; /** The exact queued opening crossed the adapter submission boundary. The * daemon may now release its runtime route reservation and flush follow-ups. */ /** Return false only when a buffered follow-up was not accepted and should * be retried. Once accepted, later presentation persistence is best-effort * and must not request a delivery retry. */ onQueuedActivationSubmitted?: (ds: DaemonSession, activationToken: string) => boolean | void | Promise; } /** * Initialise worker-pool callbacks. Must be called once before forkWorker(). */ export declare function initWorkerPool(cb: WorkerPoolCallbacks): void; export declare function setActiveSessionsRegistry(m: Map | undefined): void; export declare function listActiveSessions(): DaemonSession[]; /** Linear-scan lookup of the active-sessions Map by `Session.sessionId`. * The Map's actual key is `sessionKey(rootId, larkAppId)` (composite), so we * cannot use Map.get here. */ export declare function findActiveBySessionId(sessionId: string): DaemonSession | undefined; /** Direct access to the active-sessions Map. Reserved for callers that need * to mutate (e.g. resumeSession reactivating a closed record); read-only * callers should prefer listActiveSessions / findActiveBySessionId. */ export declare function getActiveSessionsRegistry(): Map | undefined; /** * True iff this DaemonSession represents a real CLI-backed conversation * that's safe to migrate via /relay. Returns false for daemon-command * scratch placeholders (the `worker:null + hasHistory:false` records that * daemon.ts creates for /help, an unfinished picker /relay, etc.) — those * have no CLI history, no tmux, and migrating them yields an empty shell * in the target chat with a fake "已就绪" M1. * * Why not just `!!ds.worker || ds.hasHistory`: * - `ds.worker` is runtime-only; null after daemon restart until * forkWorker re-attaches. * - `ds.hasHistory` is a runtime field too — restoreActiveSessions sets * it `true` UNCONDITIONALLY for any persisted non-adopt session * (session-manager.ts:618). A scratch that survived a restart comes * back with hasHistory:true, defeating the guard. * * Use persisted markers instead: `ds.session.cliId` and * `ds.session.lastCliInput` are written ONLY after a real worker started * the CLI (worker-pool's fork path stamps cliId; rememberLastCliInput * writes lastCliInput on every input). Daemon-command scratches never set * either, so the predicate survives restart and is robust across paths. * * Apply at every relay surface that consumes a candidate `ds`: * - relay-picker.ts collectRelayPickerEntries (don't list scratches) * - card-handler.ts relay_confirm preflight (don't M1 + transferSession a scratch) * - this file's transferSession depth defense (catch any caller that bypassed both upstream guards) * - command-handler.ts /relay --create leader guard */ export declare function isRelayableRealSession(ds: DaemonSession): boolean; /** A worker-less row that never represented a CLI and carries no deferred * user intent. Only this narrow class is safe to evict as command scaffolding. */ export declare function isDisposableCommandScratch(ds: DaemonSession): boolean; /** Worker lifecycle readiness is independent from Web Terminal availability. * Legacy/test sessions predate workerReady, so retain the old port inference * only while the explicit flag is absent. */ export declare function workerHasInitialized(ds: DaemonSession): boolean; /** Capability of the backend frozen onto this worker/session generation. */ export declare function sessionSupportsWebTerminal(ds: DaemonSession): boolean; /** Empty means this session intentionally has no Web Terminal surface. */ export declare function readableTerminalUrlFor(ds: DaemonSession): string; export declare function writableTerminalLinkFor(ds: DaemonSession): string | undefined; /** How often the live streaming card is re-PATCHed with fresh usage while a * turn executes. 12s stays off the prompt-cache-friendly path; the tick reads * with fresh:true so it bypasses (does not merely out-wait) the usage reader's * 15s reparse throttle — the transcript grows per tool step, so each refresh * folds only the newly appended bytes. */ export declare const USAGE_REFRESH_INTERVAL_MS = 12000; /** Single source of truth for "this session should be periodically re-rendering * its streaming card with fresh usage right now". Used by both the arm gate and * the interval tick so arm/clear is a state-boundary invariant, not tied to one * PATCH path. Requires: an actively-working turn, a live (non-sentinel, not a * new-turn handoff) streaming card, the worker initialized (NOT a Web-Terminal * port — ZMX reports ready with port=0), usageDisplay='streaming', and a CLI * that actually has a native-usage transcript (gemini/opencode/pi/… have none * → nothing to show). */ export declare function usageRefreshShouldRun(ds: DaemonSession): boolean; /** Re-render the current streaming card with the freshest usage snapshot. The * interval tick is self-correcting: if the session no longer qualifies (turn * settled, card gone, limit, etc.) it clears its own timer, bounding any missed * explicit clear to a single interval. Reads with fresh:true so the periodic * PATCH actually beats the cost reader's 15s reparse throttle. */ export declare function refreshStreamingCardUsage(ds: DaemonSession): void; /** Bring the periodic usage refresh in line with current state — arm when the * session qualifies, clear otherwise. Idempotent; safe to call after ANY * lastScreenStatus assignment or streaming-card lifecycle change. This is the * single choke point that makes the timer a state-boundary invariant. */ export declare function syncUsageRefreshTimer(ds: DaemonSession): void; /** * PATCH the live streaming card with the freshest riff sandbox URL. Mirrors * {@link scheduleLocalCliOpenReadinessPatch}: when the card POST is still * in-flight (streamCardId === sentinel) the refresh is parked on * `pendingRiffUrlCardRefresh` and flushed once the POST lands — the riff * accessUrl typically arrives inside exactly that window (task-execute returns * within ~1s of the initial card POST), and without the pending flag the * in-card writable link would stay stale until the next status-edge PATCH. */ export declare function scheduleRiffAccessUrlPatch(ds: DaemonSession): void; /** Attach the crash-safe timer owner for one eligible ordinary Claude/Lark * session. Session state is persisted; this runtime registry only owns timers. */ export declare function ensureOrdinaryTurnRecoveryAttached(ds: DaemonSession, botCfg?: import("../bot-registry.js").BotConfig): boolean; export { freezeMojoIdentityForSession } from './mojo-session-identity.js'; /** * Freeze the control-plane identity of every restored mojo session at daemon * startup, instead of waiting for each one's next worker fork. * * The lazy path left a window: after a restart but before a session was next * woken, an operator could change the bot's endpoint/workspace, and the session * would then adopt THAT as its "original" identity — pairing a remote lineage * created on tenant A with tenant B. Migrating during restore closes it, because * restore runs before the dispatcher can deliver a message. * * A legacy row that already holds a remote lineage cannot be migrated safely (no * record of its original control plane), so sessionMojoConfig drops that lineage; * this pass reuses the same helper so both entry points behave identically. */ export declare function migrateMojoSessionIdentities(activeSessions: Map): void; /** * Resolve the mojo config a session must run on, freezing its control-plane * identity the first time and honouring that snapshot forever after. * * Split by purpose: * - identity (cloud / localDaemon / baseUrl / ppeEnv / workspaceId / agentId) * comes from the FROZEN snapshot. A live edit must not move an existing * session between execution modes or tenants — a cold resume would continue, * and `/close` would cancel, against an endpoint that never created it. * - credentials (jwt / jwtEnv / env) and behaviour knobs (stream / * systemPrompt / idleTimeoutSec) stay LIVE, so a rotated token takes effect * and no plaintext JWT is persisted into session state. * * `freeze` is false on read-only paths (workerless cancel) so teardown never * mutates session state; a session that predates this field then simply runs on * live config, exactly as it did before. */ /** * Resolve the mojo config a session must run on, plus whether its remote lineage * may still be used. * * Returns an explicit state rather than a bare config, because "we have a config" * and "this lineage is safe to resume/cancel" are different questions and * conflating them is what let a cancel reach the wrong tenant: * - `usable` — identity is frozen (or freshly frozen); lineage is trusted * - `quarantined` — legacy row with a remote lineage but no frozen identity. * Nothing records which control plane holds it, so it must not * be resumed or cancelled automatically. * - `none` — legacy row with no lineage at all; nothing at risk. * * Identity (cloud / localDaemon / baseUrl / ppeEnv / workspaceId / agentId) comes * from the FROZEN snapshot; credentials (jwt / jwtEnv / env) and behaviour knobs * stay LIVE so a rotated token takes effect and no plaintext JWT is persisted. * * `freeze` is false on read-only paths (workerless cancel) so teardown never * mutates session state. */ export declare function sessionMojoConfig(ds: DaemonSession, botCfg: { mojo?: MojoConfig; }, opts: { freeze: boolean; }): { config: MojoConfig; lineage: 'usable' | 'quarantined' | 'none'; }; export declare function clearUsageLimitState(ds: DaemonSession): void; export declare function cardUsageLimit(ds: DaemonSession): CliUsageLimitState | undefined; export declare function restoreUsageLimitRuntimeState(ds: DaemonSession): void; export declare const CARD_POSTING_SENTINEL = "__posting__"; /** * Move the current streaming card into `frozenCards` without freezing it * cosmetically. The next successful card POST will sweep it via * `recallFrozenCards`. Used on paths that bypass the normal freeze step * (worker dead before a new turn, repo switch tearing down the session) so * we never delete the only visible card before its successor exists — if * fork / worker_ready / POST fails, the parked card stays in the thread. * * Lazy-loads `frozenCards` from disk if the in-memory Map is missing * (post daemon-restart, before any card-handler action has loaded it). * Without this, parking would synthesize an empty Map and the subsequent * `saveFrozenCards` would overwrite earlier turns' entries on disk — * stranding their cards in the thread with no way to recall them. * * No-op when there is no live card to park. */ export declare function parkStreamCard(ds: DaemonSession): void; /** * Delete previously-frozen streaming cards from the live card's visible Lark * destination and clear those entries from the cache. * Called whenever a new streaming card becomes the active one — old turns' * cards just add visual clutter when scrolling that destination's history. * A chat-scope session may answer in several Lark topics; cards from another * topic remain frozen until a successor is posted in that same topic. * * Lazy-loads `frozenCards` from disk if the in-memory Map is missing * (post daemon-restart). Best-effort delete; failures (already withdrawn, * expired) are non-fatal. * * Skips any entry whose messageId matches `ds.streamCardId` — guards the * daemon-restart window where a turn was frozen (entry persisted to disk) * but a new card was never POSTed before the crash. After restart the same * messageId is the live `streamCardId` again, and recalling it would delete * the only card the user can see. */ export declare function recallFrozenCards(ds: DaemonSession): void; /** The first visible state for a newly accepted turn. * * `starting` is a process/session lifecycle state. A live Grok worker that * accepts another turn is already past startup, so surface the turn as working * immediately. The worker's structured lifecycle gate will later publish the * authoritative terminal state from updates.jsonl. */ export declare function turnStartingCardStatus(ds: DaemonSession, effectiveCliId: CliId): 'limited' | 'working' | 'starting'; /** * Post the current turn's starting card as soon as the daemon accepts the * inbound message. Terminal redraw is deliberately not part of this trigger: * some CLIs consume a turn without emitting another screen_update, which used * to leave streamCardPending stuck and suppress cards for every later turn. * * Only one POST may be in flight per session. If another turn arrives during * the request, the generation check preserves that newer pending state and * immediately follows with its card after the first POST settles. */ export declare function postTurnStartingCard(ds: DaemonSession, sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string) => Promise, turnId: string): Promise; /** * Force-post a fresh streaming card for `ds`, bypassing the per-bot * `disableStreamingCard` opt-out. Backs the `/card` command: a user can * manually summon a live card in an otherwise-quiet session. Parks the current * card (if any) first so `recallFrozenCards` withdraws it once the fresh one * lands — the thread ends up with a single live card. Returns false when the * worker isn't initialized yet, so the caller can surface a friendly "not * ready" message. A ready backend without Web Terminal still gets the card. * * Note: this does NOT itself flip `ds.streamingCardForced` — the caller sets * that so the card keeps live-patching afterwards even when the bot opted out. */ export declare function postFreshStreamingCard(ds: DaemonSession, sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string) => Promise): Promise; /** * Audience for a private `/card`: the bot's `allowedUsers` (the canOperate set — * owner & co-owners), deduped, `ou_` only. Talk-only grants (`globalGrants` / * `chatGrants`) and a bare triggerer are intentionally NOT included: the private * card is owner-only. A grant-authorized user who runs `/card` therefore does * not receive a card (matches the "授权人不发" rule). Empty when the bot has no * `allowedUsers` (fully-open mode → no owner to send to). */ export declare function resolvePrivateCardAudience(ds: DaemonSession): string[]; /** * Private `/card`: build a one-shot snapshot of the current terminal and send it * as an ephemeral (visible-to-one) card to each open_id in `audience`, one API * call each (concurrency-capped). Never posts a group-visible card and never * patches — privacy is the whole point, so there is deliberately no fallback. * Returns per-recipient counts so the caller can report progress without leaking * the audience list into the chat. */ export declare function postPrivateSnapshotCard(ds: DaemonSession, audience: string[]): Promise<{ sent: number; total: number; notReady: boolean; }>; /** * Deliver the write-enabled session card (the "🔑 获取操作链接" card, which carries * a write-token terminal URL + manage buttons) privately to a single operator. * * Prefers an in-chat "visible-to-you" ephemeral card in a flat group so the * operator never has to leave the conversation. Thread-scope sessions and * chat-scope sessions currently folded into a thread go straight to DM: * Feishu may accept their ephemeral message without rendering it in the topic * panel. p2p chats also DM directly (the DM lands in that same 1:1 chat). * * Other group chats attempt ephemeral first and fall back to DM on ANY failure. * * Both channels are private, so the DM fallback never leaks the write token — * unlike the private /card snapshot (which fails closed), here we fail OVER. * * Returns the channel actually used, or 'failed' if both errored. */ export declare function deliverWriteLinkCard(ds: DaemonSession, operatorOpenId: string, cardJson: string): Promise<'ephemeral' | 'dm' | 'failed'>; export interface WriteLinkOwnerDelivery { ok: boolean; error?: 'terminal_unavailable' | 'terminal_unsupported' | 'no_owner' | 'delivery_failed'; delivered: number; total: number; channels: Array<'ephemeral' | 'dm' | 'failed'>; } /** * Build the write-enabled session card (writable terminal URL + manage buttons) * for `ds`, or null when the terminal isn't up yet (no worker port/token). * Shared by the owner-fanout ({@link deliverWriteLinkCardToOwners}, behind * `botmux term-link`) and the single-operator delivery * ({@link deliverWritableTerminalCardTo}, behind the `/term` slash command). */ export declare function buildWritableTerminalCard(ds: DaemonSession): string | null; /** * Build the write-enabled session card for `ds` and deliver it privately to the * bot's owner(s) — the payload behind the `botmux term-link` CLI command. * * Mirrors the in-chat "🔑 获取操作链接" button flow ({@link deliverWriteLinkCard}), * but fans out to the owner audience ({@link resolvePrivateCardAudience}) instead * of a single click-operator: a CLI caller has no Lark identity, so "deliver to * the owner(s)" is the closest equivalent of "deliver to the person who asked". * Each owner gets an in-chat visible-to-you ephemeral card, auto-falling back to * a private DM in topic / p2p chats. The write token therefore only ever rides * these private channels — it is never returned to the CLI caller / stdout. */ export declare function deliverWriteLinkCardToOwners(ds: DaemonSession): Promise; /** * Deliver the writable-terminal card privately to a single operator — the `/term` * slash command's payload (the owner who typed it; owner-gated in command-handler). * Same private ephemeral→DM channel as the "🔑 获取操作链接" card button. Returns * 'not_ready' when the terminal isn't up yet, else the channel actually used. */ export declare function deliverWritableTerminalCardTo(ds: DaemonSession, operatorOpenId: string): Promise<'ephemeral' | 'dm' | 'failed' | 'not_ready' | 'unsupported'>; export interface SubstituteControlCardDelivery { sent: number; total: number; } /** * DM a control card to the bot's owner(s) for a substitute-mode session. * ZMX receives a manage-only card because it has no Web Terminal surface. * Guards against duplicate sends via `session.substituteControlCardSent`. */ export declare function deliverSubstituteControlCard(ds: DaemonSession): Promise; /** * Deliver a status confirmation (restart / session-closed / resume) as a * "visible-to-the-operator-only" ephemeral message in a plain group; on failure * (topic groups reject with 18053) or in p2p, fall back to the normal visible * reply (`reply`). `content` is the card JSON when msgType==='interactive', * otherwise the plain text. Topic-group / p2p behavior is unchanged. * * IMPORTANT: ephemeral is only attempted for a flat **chat-scope** destination. The * ephemeral API (`ephemeral/v1/send`) takes a `chat_id` only — it has no * thread/root anchoring — so for a **thread-scope** session (a 话题 inside a * 普通群, or a 话题群 topic) an ephemeral card would escape the topic and land at * the group top-level. The same applies when a chat-scope session is invoked * from a folded thread: callers pass its frozen reply target, and any explicit * thread/quote destination takes the visible `reply()` path. */ export declare function deliverEphemeralOrReply(ds: DaemonSession, operatorOpenId: string | undefined, content: string, msgType: 'text' | 'interactive', reply: () => Promise, replyTarget?: FrozenSessionReplyTarget): Promise; /** * Queue a card PATCH. If no PATCH is in-flight, sends immediately. * Otherwise stores the card JSON on `ds.pendingCardJson` (overwriting * any previously queued value — only the latest state matters). */ export declare function scheduleCardPatch(ds: DaemonSession, cardJson: string, turnId?: string): void; export declare const restartCounts: Map; /** * Ensure built-in skills are installed for a given CLI. Synchronous and * idempotent. Two cadences: * - Skill FILES (whiteboard / workflow family / built-in skills) are * re-evaluated on EVERY spawn, before the `skillsInstalledCliIds` gate, so * runtime switches (whiteboard, workflow) and user customization (skill body * override / disable) take effect on the next session without a restart. * - Hook install + botmux-ask fallback do not vary at runtime, so they run * once per CLI per daemon lifecycle (behind the gate). */ export declare function ensureCliSkills(cliId: CliId, cliPathOverride?: string): void; /** * Ensure per-CLI environment is set up for this daemon lifecycle: install * built-in skills and the single stable Botmux MCP Gateway entry. * Both steps are idempotent and best-effort. */ export declare function ensureCliEnv(cliId: CliId, cliPathOverride?: string): void; /** Unconditionally sweep botmux-owned skills out of the user's global * `~/.claude/skills`. botmux owns the `botmux-` namespace there and injects its * skills per-session via `--plugin-dir`, so anything matching is a leak that * would otherwise surface (and mis-fire) in the user's standalone `claude`. * Idempotent & best-effort — safe to call repeatedly. */ export declare function sweepGlobalBotmuxSkills(): void; /** Pre-accept Claude Code's per-project folder-trust dialog for `workingDir`. * Claude keys trust off realpath(cwd) (its getcwd(3) is already realpath'd), * so seed that path. Merge-safe + best-effort: only ADDS the flag, never * clobbers other keys; any failure is swallowed so it can't block spawn. */ export declare function ensureClaudeFolderTrust(workingDir: string, stateJsonPath?: string): void; /** * Retire a session's worker (and, worker-less, its orphaned backing session). * * CONTRACT FOR REMOTE BACKENDS (riff / mojo): a LIVE remote worker is refused * unless the call carries the prepare/commit `remoteCloseCommitRequestId` — * the function RETURNS WITHOUT RETIRING and the worker keeps running. Callers * must not assume success. Today that refusal is absorbed differently per * call site: * - /cd (IM + dashboard) rejects remote sessions BEFORE repinning, so this * refusal is never reached from there; * - the explicit-close path always carries the commit requestId; * - crash-loop, collision-loser, VC restore/upgrade and device-isolation * sweeps still call this best-effort and, for a live remote worker, keep * the generation alive with its lineage intact (fail-safe: never a silent * remote cancel). A caller that needs the worker GONE must go through * prepare/commit or the shutdown-detach flow — not this function. */ export declare function killWorker(ds: DaemonSession, opts?: { remoteCloseCommitRequestId?: string; /** Set by the authoritative mojo close once its cancel is PROVEN, so the * best-effort orphan teardown below does not cancel the same session again. */ mojoCancelAlreadyProven?: boolean; }): void; /** * Retire the worker PROCESS only — no close semantics of any kind. * * No `close` IPC (a request-less remote close is refused by the worker, and a * local one would destroy the backing session), no remote cancel, lineage and * containment handles intact. SIGTERM is sent immediately (the worker's * handler runs killCli() — for mojo that is backend.kill(), which by contract * never cancels the remote session) and armWorkerKillBackstop guarantees * SIGKILL if it wedges. * * For callers that must make a worker generation DIE while its logical/remote * state lives on: the collision loser (its registry entry is deleted right * after, so a refused killWorker left a live credential-carrying worker * unreachable — the P0-new orphan shape) and the VC runtime-lease fence * (which must prove the local producer process dead before admitting replay). */ export declare function retireWorkerProcessOnly(ds: DaemonSession, reason: string): void; /** * Retire the old worker generation during a routing transfer without applying * ordinary `/close` semantics to its backend. * * The replacement worker reuses the same logical session after this completion * fence. Sending `close` here would call `destroySession()` in the old worker * and race that replacement's persistent-backend reattach. A worker-less * session is already detached, so unlike killWorker() this path must never * destroy its orphaned backing resource. */ export declare function detachWorkerForTransfer(ds: DaemonSession, opts?: { timeoutMs?: number; }): Promise; /** * Whether a worker-less restart must first destroy the session's persistent * backing pane. Adopt sessions are excluded — botmux never owned the user's * pane, so killing it would violate the bridge invariant. Pure so the * adopt-skip decision is unit-testable without spawning a worker. */ export declare function shouldDestroyPaneBeforeRestart(ds: Pick): boolean; /** * Live-worker /restart 携带的最新 per-bot env(bots.json `env`)。worker 收到 * restart 时在 respawn 前全量覆盖 lastInitConfig.env —— 否则 live-worker 重启 * 一直用 fork 时刻的旧快照,dashboard 改完 env(如切 provider 的 * ANTHROPIC_BASE_URL/TOKEN)后 /restart 并不会生效(只有 refork 路径生效)。 * 三分态返回:对象 = 最新 env;null = 明确清空(dashboard 已删); * undefined = 取不到(bot 已注销等异常),让 worker 保持快照不动(=旧行为)。 * 只热更 env / model 两个字段:sandbox/backendType 是刻意 freeze-once 的设计(见 * forkWorker init 注释),cliId 换 CLI 会踩 resume transcript 对齐,均不带。 */ export declare function latestPerBotEnvForRestart(ds: DaemonSession): Record | null | undefined; /** * Fold the unprovable keys of an env payload into the generation's ledger. * * Names only — the values can be credentials, and the proof only inspects names. * Exported for the restart-timeline tests, which must be able to assert the * ledger itself rather than infer it from a classifier result. */ export declare function rememberAppliedUnprovableEnvKeys(ds: DaemonSession, env: Record | undefined): void; /** * Live-worker /restart 携带的最新模型(与 env 同一条通道、同一套三分态)。 * * model 不进冻结集合、每次 spawn 按当前 bot 配置解析(见 resolveSessionLaunchModel), * 但 `/restart`、dashboard 重启、CLI 崩溃自动重启这三条路**不 refork**:worker 收到 * restart 后用旧 `lastInitConfig` 原地 respawn。不捎带的话,改完模型再重启起来的还是 * 旧模型——正是「改配置对存量会话生效」要修的那件事。 * 三分态:字符串 = 用它;null = 明确不传模型(bot 未配 / 已清空); * undefined = 取不到(bot 已注销等异常),让 worker 保持快照不动(=旧行为)。 */ export declare function latestModelForRespawn(ds: DaemonSession): string | null | undefined; /** Join or start one correlated physical restart for a session. */ export declare function requestSessionRestart(ds: DaemonSession, observer: RestartObserver): { attemptId: string; joined: boolean; } | undefined; export declare function __testOnly_resetRestartCoordinator(): void; type RemoteClosePreparation = { ok: true; taskId?: string; residual?: CloseResidual; /** Move this still-uncancellable lineage into the PARKED slot as part of the * durable close, so the residual decision survives and replays. */ parkMojoLineage?: string; } | { ok: false; error: 'riff_cancel_failed' | 'riff_config_missing' | 'riff_task_changed' | 'riff_worker_close_failed' | 'riff_row_inconsistent' | 'riff_durable_close_failed' | 'riff_close_reconciliation_required' | 'remote_shutdown_fence_in_progress' | 'mojo_cancel_failed' | 'mojo_durable_close_failed' | 'mojo_close_reconciliation_required' /** Bot deregistered — retryable once it is registered again. */ | 'mojo_config_missing' /** Durable lineage with no active owner to cancel through. */ | 'mojo_close_identity_missing' /** * The remote teardown already happened irreversibly, but the local close * did not finish. ONLY the local commit may be retried. */ | 'mojo_close_commit_required'; /** * May the caller simply try the SAME close again? * * This was hardcoded `true`, which contradicted the tri-state it was * reporting: an `uncertain` prepare needs explicit reconciliation (a blind * retry cannot cancel a session nobody can name), and an `irreversible` * one must never re-run the remote cancel. `false` means "do not loop on * this; a human/reconciler decides". */ retryable: boolean; /** * The exact worker verdict, when this refusal came from one. `retryable` * appears here too: a close whose WRITES stay fenced may still itself be * retryable, and flattening that into `uncertain` would demand manual * reconciliation for a failure the retry can clear. */ recovery?: 'retryable' | 'uncertain' | 'irreversible'; taskId?: string; }; /** * The residual a row carries because a lineage was PARKED as unverifiable. * * Read from the parked slot, never from the active one: restore-time quarantine * moves the id into `mojoQuarantinedLineage` and clears `riffParentTaskId`, so a * check against the active slot misses the production shape completely — the row * would close as an ordinary success while its remote session kept running. * * May hold more than one id (comma-joined) when two unverifiable lineages were * parked together; it is passed through verbatim so manual cleanup sees both. */ export declare function mojoCloseResidualForRow(session: Session | undefined): CloseResidual | undefined; export declare function suspendWorker(ds: DaemonSession, reason?: string): boolean; /** * Cash in a queued suspend. Called once the session leaves the producing states * it was queued for; a no-op during working/analyzing — that IS why it queued. * * Callers MUST defer this out of the status handler's synchronous body * (queueMicrotask) — suspendWorker clears `ds.worker` and `ds.lastScreenStatus`, * and the rest of that handler still reads both to record the usage delta, flip * the turn reaction ✋→✅, emit the state-transition hook, and render the final * card. Running it inline would skip exactly the turn-completion bookkeeping * this whole feature exists to protect. * * `ownsGeneration` is the calling handler's generation check (`ownsWorkerSession`), * and it is **defense-in-depth** — not a guard against a race anyone has shown to * be reachable today. Two earlier drafts of this comment each claimed a concrete * race; both were wrong, so the reasoning is spelled out here to stop a third: * * - It is NOT "a stale worker's late `idle` reaches `screenshot_uploaded`": * the message handler's fence (`if (ds.worker !== worker) return`) sits * BEFORE the switch and already drops every message from a replaced worker. * - It is NOT "two microtasks in one tick, the first suspends + re-forks and * the second meets the replacement": `suspendWorker` only nulls `ds.worker`, * it never re-forks (a re-fork is driven by external input, i.e. a later * MACROtask), and the microtask queue drains without letting one in. The * second microtask therefore sees `ds.worker === null` and this predicate * early-returns on that — a replacement is not what it meets. * * What it does buy: a queued callback can only ever act while its own generation * still owns the session. That keeps this deferral safe against future callers, * new synchronous side effects between enqueue and drain, and any path that * starts re-forking earlier than today. Consuming the claim is a destructive act * on a live worker, so it is worth gating even without a demonstrated race. * A checkpoint from a generation that no longer owns the session keeps the flag * pending; only the owning generation may consume it. * * Deliberately the generation check ALONE, not `ownsLifecycleMutation` (which * also folds in "not transferring"): a routing transfer is a temporary refusal, * not a lost claim, and it is suspendWorker's own guard to make. Screen updates * stop once a session sits quiet, so treating transfer as "not ours" would park * the flag with no later checkpoint to revive it — hence the explicit * transfer-settled retry below. */ declare function runPendingSuspendIfSettled(ds: DaemonSession, ownsGeneration?: () => boolean): void; export declare const __testOnly_runPendingSuspendIfSettled: typeof runPendingSuspendIfSettled; export declare function teardownAuthoritativePersistentBackingBeforeClose(target: DaemonSession | Session): void; /** Render the live streaming-card JSON for `ds` (🖥️ header + usage line + * 显示输出/终端/关闭会话 buttons). Factored so callers outside the normal * screen-update flow — notably the Lark 恢复会话 button — can restore the SAME * card the session had while running, instead of a stripped-down variant. * `status` defaults to the session's last known screen status. */ export declare function buildStreamingCardJson(ds: DaemonSession, status?: StreamStatus): string; /** * Idempotent close: kill worker if alive, mark Session status='closed' + closedAt, * publish session.exited (if a live worker was killed) and session.update * (if the persistence row transitioned to closed). * * Calling this on an unknown sessionId, an already-closed session, or a session * whose worker died asynchronously must still resolve with `{ ok: true }`. */ /** * Why a close left something behind even though the local row closed. * * Two causes qualify, and they are deliberately NOT merged, because they send the * operator to different places: * * - `mojo_lineage_quarantined` — REMOTE residual. Nothing records which control * plane holds the remote session, so cancelling could reach a different tenant. * Carries the surviving remote `taskId`; cleanup is remote. * - `local_subtree_*` — LOCAL residual. The remote lineage really was cancelled, * but the backend could not prove its own process subtree on THIS host was gone, * so it kept the containment handle and the device-isolation blocker with it. * No `taskId`: there is no surviving remote id to chase, and offering one would * point cleanup at the wrong system. * * In both cases refusing forever would strand the row (no retry can make an * unverifiable control plane verifiable, nor make an unenumerable host * enumerable), so the row closes — but the caller must SAY so rather than showing * the ordinary "closed" confirmation. */ export interface CloseResidual { reason: 'mojo_lineage_quarantined' | 'local_subtree_unprovable_on_platform' | 'local_subtree_boundary_unproven'; /** Present only for a REMOTE residual; a local one has no remote id to name. */ taskId?: string; } /** * `outcome` is a REQUIRED discriminant on success, not an optional warning field. * * An optional flag on an otherwise ordinary success is exactly what every call site * forgets to read, so making it mandatory does help — but ONLY across a typed call. * It is not a completeness guarantee: a consumer that reads just `.ok` compiles * fine, and every JSON boundary (the dashboard IPC route, the CLI's daemon POST, * the sessions card) erases the type entirely. Reviewing this change found exactly * such consumers silently flattening a residual into a plain success. * * So the invariant is held by the consumer TESTS, not by this type. Any new close * consumer needs one. */ export type CloseSessionResult = { ok: true; outcome: 'closed'; alreadyClosed: boolean; known: boolean; } | { ok: true; outcome: 'closed_with_residual'; residual: CloseResidual; alreadyClosed: boolean; known: boolean; } | ({ ok: false; alreadyClosed: false; } & Exclude); /** * Close from a BACKGROUND path — one with no user surface to report to * (trigger-session cleanup, deferred-schedule settlement, sweeps). * * These callers cannot show a card or a toast, so the only honest thing they can * do with a residual or a refusal is make it OBSERVABLE: an uncancelled remote * session is still burning cloud time and holding an injected credential, and * silently discarding the result is how that became invisible everywhere else. * * Returns the untouched result so a caller that DOES care can still branch. */ export declare function closeSessionForBackgroundCleanup(sessionId: string, context: string): Promise; export declare function closeSession(sessionId: string, opts?: { awaitWorkerExit?: boolean; }): Promise; /** * Close can arrive through daemon IPC before startup restore has registered the * persisted row. In that window there is no DaemonSession for killWorker(), but * a stamped persistent backing may still be running. Tear down only backends * whose ownership is explicit; never touch adopted user panes, queued rows, or * legacy rows whose backend is unknown. */ export declare function destroyUnregisteredPersistentBacking(session: Session, kill?: typeof killPersistentBackendTarget): boolean; /** * Compare-and-set an entry on an active-sessions Map. A different current * occupant always wins; callers must roll back the rejected incoming row. * Replaces bare `activeSessions.set(key, ds)` at sites where a silent overwrite * would leak the prior entry's worker + leave its store row stuck active. * * The Map is passed explicitly so callers operate on the same instance they * already hold (restoreActiveSessions takes the daemon's Map as a parameter; * transferSession reaches it through `activeSessionsRegistry`). In production * both refer to the same object — the daemon registers its Map at boot — but * decoupling avoids module-state assumptions in tests. * * Registration is compare-and-set: a different current occupant always wins. * The caller owns rollback of its rejected incoming row. This is deliberately * non-destructive because the occupant may be a fresh live session created * while an older async restore/create continuation was in flight. */ export type SetActiveSessionResult = { accepted: true; closedSessionId?: string; } | { accepted: false; reason: 'kept_pending_owner'; keptSessionId: string; closedIncomingSessionId: string; } | { accepted: false; reason: 'both_pending'; keptSessionId: string; preservedIncomingSessionId: string; } | { accepted: false; reason: 'inactive_incoming'; keptSessionId: string; preservedIncomingSessionId: string; } | { accepted: false; reason: 'quarantine_reserved'; keptSessionId: string; preservedIncomingSessionId: string; } | { accepted: false; reason: 'cleanup_failed'; keptSessionId: string; preservedIncomingSessionId: string; cleanupSessionId: string; error: string; }; /** Serialize asynchronous ownership decisions for one registry key. Ordinary * bot turn admissions are intentionally concurrent, so a read/await/set helper * is not a CAS unless every contender shares this lock. */ export declare function withActiveSessionKeyLock(map: Map, key: string, action: () => Promise | T): Promise; /** * Return an active persisted row that deliberately reserves this routing key * after an inconclusive exact-backend teardown. * * The store is the durable authority rather than a process-local Set: a daemon * restart must not forget the quarantine and create a second runtime beside a * possibly-live ZMX/other persistent target. Closed rows are ignored so an * operator's successful close immediately releases the route. */ export declare function findQuarantinedRoutingConflict(key: string, larkAppId: string, candidateSessionId?: string): Session | undefined; /** * Synchronous registration gate for creation paths that previously used a * bare Map.set(). A daemon close can update the shared Session object while * the creator is awaiting Lark/project metadata; never publish that now-closed * row back into the live routing map when the continuation resumes. */ export declare function setActiveSessionIfActive(map: Map, key: string, ds: DaemonSession): boolean; export declare function setActiveSessionSafe(map: Map, key: string, ds: DaemonSession): Promise; /** * Roll back a freshly-created row rejected by the registration CAS, then read * the routing winner *after* that asynchronous rollback finishes. * * Message handlers use this to hand an already-deduped inbound event to a * concurrent HTTP/dashboard/restore winner instead of silently dropping it. * The post-await lookup matters: close cleanup must never return a stale object * that another continuation replaced while the rollback was in flight. */ export declare function rollbackRejectedSessionAndGetWinner(map: Map, key: string, rejected: DaemonSession, rollback?: (sessionId: string) => Promise): Promise; type TransferBufferedInput = Extract; export declare function __testOnly_resetOrdinaryImDeliveries(): void; export declare function isSessionTransferring(ds: DaemonSession): boolean; /** Register follow-up work that must run only after the relay gate is fully * released. Returns false when no transfer is active, so callers can proceed * immediately without a check-then-register race. */ export declare function deferUntilSessionTransferSettled(ds: DaemonSession, callback: () => void): boolean; /** Route user/automation input through the transfer fence. Messages accepted * during detach are replayed byte-for-byte to the replacement generation. */ export declare function sendWorkerSessionInput(ds: DaemonSession, message: TransferBufferedInput): boolean; /** * Transfer an active session from its current chat to a new chat. The CLI * process keeps running inside its tmux session — only the routing fields * (chatId, rootMessageId, scope) and activeSessions key are rewritten. After * the rewrite, forkWorker spawns a new worker that re-attaches to the same * `bmx-` tmux, so the AI's transcript continues without break. * * Visible side effects: * - Lark messages in the *source* chat remain where they were — we have no * API to move them. Only the worker's *routing* moves; the AI's memory * follows via the CLI's persistent jsonl on disk. * - Cards posted by the prior worker stay in the source chat. We clear * streamCardId/Nonce/imageKey so the new worker posts fresh cards in the * target chat instead of trying to PATCH unreachable old ones. * * Pre-conditions (entry guards, all checked synchronously up-front — no * idle-wait loop; busy workers are refused immediately so the caller can * report a deterministic outcome and the user retries when the worker * quiets): * - Session must be currently active (live worker + activeSessions entry) * - Source must not be a pendingRepo placeholder (no CLI ever started) * - Source must not be an adopted external-tmux session * - Source worker must be in idle/limited (or already dead) — otherwise * refuse with `worker_busy` * - Target chat must not already host a real chat-scope session for the * same bot (`target_chat_has_session`). Scratch (worker:null) occupants * are NOT a conflict — they're command-time placeholders and we close * them in-line to free the slot before continuing. * * Idempotent for `same_chat`: returns error without side effects when the * source chat equals the target chat. */ export declare function transferSession(sessionId: string, targetChatId: string, targetRootMessageId: string, /** * Target chat type. * 'group' → topic groups are supported via `targetScope: 'thread'`; * `/relay --create` builds the target by createGroupWithBots so * it's a regular group by construction; the cross-daemon * migrate-to-chat IPC inherits the same target. * 'p2p' → the bot's DM. Flat DMs (p2pMode 'chat') land chat-scope on the * chatId anchor; thread-mode DMs land thread-scope on a DM 话题 * root. The session's chatType flips with the move so post-relay * inbound routing / picker labels / reply targeting treat it as * a DM. Carried from the picker card's `target_chat_type`. * The runtime check just below catches raw-string casting at module * boundaries (mocks, HTTP body parses, future bypasses). */ targetChatType: 'group' | 'p2p', /** * Target routing scope for the relayed session. * 'chat' → anchor = chatId (flat top-level; `/relay --create`, migrate * IPC, and普通群 flat-mode picker all use this — current behavior). * 'thread' → anchor = `targetRootMessageId` (a Lark 话题/thread); replies * go reply_in_thread. Picker computes this via * resolveRelayTargetRouting for 话题群 / new-topic / shared / * 线程内回复. */ targetScope: 'thread' | 'chat', opts?: { /** @internal Override for tests — the real implementation forks a child * process and tries to attach to tmux, neither of which is appropriate * in a unit test environment. Defaults to module-level forkWorker. */ forkWorkerImpl?: typeof forkWorker; /** @internal Override for tests — mirror of forkWorkerImpl for killWorker. */ killWorkerImpl?: typeof killWorker; /** @internal Recursive marker: the bot-wide transfer mutation is held. */ mutationHeld?: boolean; /** @internal Override for tests — mirror of forkWorkerImpl for the * transfer-only worker detach path. */ detachWorkerImpl?: (ds: DaemonSession) => boolean | void | Promise; /** Detach-fence budget (ms) for the observer teardown handshake. Defaults * to the tight TRANSFER_DETACH_FENCE_MS, which the cross-daemon peer path * needs to stay inside the leader's 5s HTTP abort. In-process callers with * no HTTP ceiling (the picker relay_confirm) pass a larger value * (TRANSFER_DETACH_FENCE_PICKER_MS) so a slightly-slow-but-clean teardown * is not misclassified as a failure. Ignored when detachWorkerImpl is set * (test doubles don't observe a real fence). */ detachTimeoutMs?: number; }): Promise<{ ok: true; } | { ok: false; error: string; }>; /** True when this session can be byte-level forked via a CLI-native primitive. * Refuses codex-app outright, and refuses a plain `codex` session that is * running in Hybrid RPC mode (its live thread lives in the app-server, not a * forkable local rollout). */ export declare function isForkCapableSession(ds: DaemonSession): boolean; /** * Fork a session: create a SECOND, independent botmux session that inherits the * source's full context at the current node, landing at a different anchor * (another group / topic). The source session is left completely untouched and * keeps running — this is the non-destructive sibling of {@link transferSession} * (relay MOVES one session shell; fork COPIES into a new shell). * * Context inheritance is delegated to the CLI's native fork primitive * (`--fork-session` / `codex fork`) via the child's one-shot * `pendingForkSession` marker: the child's first spawn resumes the SOURCE's * CLI-native transcript but writes forward into a fresh CLI-minted id. botmux * never copies transcript bytes itself. * * Shares transferSession's front guards (mid-turn / adopt / pendingRepo / * vc-receiver / target-anchor occupancy) but performs NONE of its destructive * steps (no source card freeze, no worker detach, no source registry delete, no * source routing rewrite). */ export declare function forkSession(sessionId: string, targetChatId: string, targetRootMessageId: string, targetChatType: 'group' | 'p2p', targetScope: 'thread' | 'chat', opts?: { forkWorkerImpl?: typeof forkWorker; childTitle?: string; forkTaskText?: string; larkThreadId?: string; buildInitialPrompt?: (childSessionId: string) => string | CliTurnPayload; turnId?: string; senderOpenId?: string; senderIsBot?: boolean; }): Promise<{ ok: true; childSessionId: string; } | { ok: false; error: string; }>; export declare function codexAppCleanInputAcceptedForSession(ds: DaemonSession): boolean; /** * Recovery-seam at-most-once fence for a keyed follow-up turn (turnIdempotencyKey, * PR #818). The turn-level idempotency lease terminalizes an interrupted keyed * turn to a durable `failed(dispatch_unknown)` (worker-exit convergence / boot * reconcile / same-key retry) and — unlike a fresh async-virtual session — LEAVES * the shared session open and un-quarantined (P1-3). So a later refork of that * session must NOT resurrect the interrupted turn: `noReplay` only lives on the * transient input queues (pendingMessages / inflight), NOT on the durable Codex * App dispatch ledger. Without this fence, the recovery path * (codexAppRecoveredDispatches → worker init → recoveredAcceptedInputs, keyed on * `state==='accepted'` alone) would re-issue `turn/start` for a turn the caller * was already told is `failed` at-most-once — the ledger is the third replay * channel the lease's noReplay does not reach (codex #818 recovery-seam finding). * * Fence: for each `accepted` (NEVER `prepared`) ledger entry whose OWNER-MATCHED * async terminal is already `failed(dispatch_unknown)`, durably retire the entry * via cancelCodexAppDispatch. The retirement is TRANSACTIONAL: if any candidate * cannot be exact-cancelled (a prepared successor pins the FIFO, or the entry * vanished), the whole batch is rolled back in-memory and the fence THROWS before * any persist — never a partial retire, never a fork past a still-live * terminalized `accepted`. Idempotent and re-run at EVERY recovery seam, so it * also covers the window where the durable failed was written but a crash hit * before the exit-time retirement (the durable async truth is authoritative, * re-checked here). A `prepared` entry is never cancelled without proof — the * runner may have crossed the write boundary; the fence only ever targets * `accepted`, and a prepared frame blocking an accepted retirement aborts the * fork (above). Owner-scoped: only THIS bot's failed evidence counts, so a * foreign/unstamped async record never retires our accepted entry. * * FAIL-CLOSED (codex #818 recovery-seam round-2). At-most-once forbids replaying * a turn the caller was already told is `failed`, so an ambiguous fence THROWS * rather than proceeding to fork: * 1. Read side uses `asyncTriggerStore.lookupStrict` — a present-but-unreadable * / corrupt terminal file must NOT fold into "no record" (soft `lookup`), * which would let the accepted entry re-enter the recovery snapshot and * replay. ENOENT / absent trigger is a genuine "no terminal" and is fine. * 2. Retire persist failure (updateSession EIO): the in-memory ledger is rolled * back to `priorLedger` and the error is RETHROWN, aborting this fork before * the recovery snapshot is taken. `staggeredRecoveryFork` (the boot eager * re-attach caller) already try/catches each fork, isolates the row, and * retains it for a later retry — so the durable ledger + async truth stay * intact and the next seam re-attempts the retirement. Degrading to "replay * once" (the pre-fix behavior) would itself be the P1 we are closing. * @throws when the authoritative async truth is unreadable, or the retirement * cannot be durably persisted — the caller (forkWorker) must abort this fork. */ declare function retireTerminalizedCodexAppLedgerEntriesForRecovery(ds: DaemonSession): void; /** The opening activation was ACKed, but one of the turns held behind its * runtime reservation was not accepted before that worker exited. Promote the * exact FIFO head into a new durable queued activation so the next inbound or * Dashboard activation reforks it before every remaining tail item. */ declare function reparkQueuedActivationFollowUpTail(ds: DaemonSession, reason: string): boolean; export declare const __testOnly_reparkQueuedActivationFollowUpTail: typeof reparkQueuedActivationFollowUpTail; /** Send one normal (non-raw) worker turn while applying the per-bot Codex App * clean-input gate at message acceptance time. This freezes the sidecar onto * the IPC item, so later config flips do not mutate an already queued turn. */ export declare function sendWorkerInput(ds: DaemonSession, payload: string | CliTurnPayload, turnId?: string, opts?: { dispatchAttempt?: number; /** Explicit positive steer authorization (plain-human-interactive only). * Persisted on the accepted ledger entry and forwarded to the worker so the * serial runner may steer this turn into an active one. */ codexAppSteerable?: true; /** At-most-once (idempotency lease): forward to the worker so a keyed * follow-up delivered to a LIVE worker is tagged noReplay and never replays * onto an auto-restarted CLI after a crash+terminalize (turn-level PR #71). * The dormant-fork path rides `atMostOnce` on the fork init instead. */ atMostOnce?: true; trustedCaller?: TrustedCaller; }): boolean; /** * Complete live-credential snapshot to ride along with a turn, so a live mojo * session picks up a rotated / cleared JWT without a refork. * * Sent on EVERY turn rather than only when it differs from the init snapshot. The * daemon cannot know which patches a live worker has already applied, so diffing * against init was wrong in both directions: * - a deleted `mojo.jwt` produced no patch, leaving the old token in place; * - `init A → patch B → config rolled back to A` compared A against A and sent * nothing, leaving the backend on B. * The backend de-duplicates against its own current value, which is the only * authoritative source. * * The control plane is NOT included — it stays frozen for the session's lifetime. */ export declare function mojoLivePatchForSession(ds: DaemonSession): { mojoLivePatch: MojoLivePatch; } | undefined; /** * Auxiliary-UI suppression policy for the **mojo quarantine notice** path. * * NOTE (Plan B merge, 2026-08): setupWorkerHandlers' `managedAuxUiSuppressed` * no longer delegates here — it keeps its own inline copy that gates on * `isMeetingDrivenTurn` instead of this function's pre-Plan-B blanket * `ds.session.vcMeetingReceiver` check. That blanket check would re-suppress a * plain user turn on a meeting-agent chat session (the "手动@不回复" regression), * which is exactly why the streaming/aux path had to diverge. This function is * therefore currently the mojo-quarantine caller's policy only; migrating it to * `isMeetingDrivenTurn` (so the two converge again without the regression) is a * tracked follow-up. ordinaryTurnRecoveryEligible (below) carries the same * pre-Plan-B blanket and is on the same follow-up. * * Previously setupWorkerHandlers held this as a closure and the quarantine notice * COPIED three of its four checks — dropping `ordinaryManagedSuppression`, so a * durable-suppressed turn still posted to Lark. Copying a policy is how the two * diverge; this is the shared implementation both now call. * * Returns true when auxiliary UI must be suppressed. */ export declare function auxUiSuppressedFor(ds: DaemonSession, turnId?: string, dispatchAttempt?: number): boolean; /** Promote the oldest durable activation successor into a fresh tokened * journal and (for Codex App) its single accepted-ledger owner in one store * update, then hand it to the current worker. The tail is never shifted merely * because ChildProcess.send returned: the promoted journal survives until the * adapter ACK carrying this token arrives. */ export declare function promoteQueuedActivationTail(ds: DaemonSession, opts?: { send?: boolean; }): boolean; export type QueuedActivationTailReservation = Pick & { /** Clean-input decision captured synchronously with FIFO order, before any * caller-specific async prompt construction. This field is runtime-only. */ codexAppInputAccepted?: boolean; }; /** Reserve arrival order synchronously, before any caller-specific prompt * rendering may await. Gaps are harmless; reusing an order after a failed * durable admission would not be. */ export declare function reserveQueuedActivationTailAdmission(ds: DaemonSession): QueuedActivationTailReservation; /** Admit one exact successor behind a queued activation. The response boundary * is the session-store write: callers must not report acceptance or use live * worker IPC unless this returns. `reservation` lets routes reserve FIFO order * before asynchronous prompt construction without duplicating persistence * logic. */ export declare function admitQueuedActivationTail(ds: DaemonSession, entry: Omit, reservation?: QueuedActivationTailReservation, opts?: { codexAppInputGateFrozen?: boolean; }): QueuedActivationTailEntry; /** True while a live worker's opening activation still owns submission order. * Every ingress that sees this state must use admitQueuedActivationTail rather * than ordinary worker IPC. */ export declare function hasQueuedActivationAdmissionGate(ds: DaemonSession): boolean; export type ForkResumeOrTurnId = boolean | string | { resume?: boolean; turnId?: string; dispatchAttempt?: number; /** The payload is an exact retained activation journal. Do not re-read the * live clean-input feature flag on retry/replay. */ codexAppInputGateFrozen?: boolean; /** Correlates a worker restart across the detach/refork boundary so late * lifecycle events from the retired worker are not misattributed. */ restartAttemptId?: string; /** At-most-once turn (idempotency lease): the worker must NEVER replay this * input after a CLI exit — not via inflight carry-over, not from the still- * queued pendingMessages. Once the daemon terminalizes the turn, re-executing * it on an auto-restarted CLI would violate at-most-once (codex #776 round-7 * finding #1). */ atMostOnce?: boolean; trustedCaller?: TrustedCaller; }; /** Central quarantine decision for one fork boundary — the SINGLE authority that * keeps a restore-time "tail-only quarantine" from being forked incorrectly. * * A quarantined owner (see restoreActiveSessions) has an old activation-tail head * that failed to promote transiently: the head sits un-promoted in the tail with * the admission gate held (worker:null). The invariant across EVERY fork boundary * (restore reattach, IM inbound refork, web-terminal lazy wake, and any future * one) is enforced here so callers cannot each miss it: * * - Not quarantined → pass the caller's args through unchanged. * - Quarantined + NON-empty prompt → REFUSE (`fork:false`). forkWorker cannot * verify the caller durably staged this turn behind the old head, so letting * it through could overtake the head. The inbound path must durable-admit the * turn into the tail first, then blank-recover (empty prompt) through here. * - Quarantined + empty prompt → retry the old head's promotion. On failure keep * the flag/gate/worker:null and REFUSE (a blank fork would leave a live worker * beside an unpromoted tail and permanently wedge the FIFO gate). On success, * clear the flag and rewrite the fork to recover the PROMOTED OLD HEAD (never * the caller's turn): Codex App recovers through its ledger with an empty * prompt; a non-Codex CLI resubmits the exact `queuedActivationInput` with the * persisted resume/turn/attempt — mirroring the daemon activation-recovery fork. * * Extracted (and exported) so the orchestration can be unit-tested without a real * tmux/worker: mock `promoteQueuedActivationTail` and assert refuse vs. recover * args, then that forkWorker applies them. */ export declare function resolveQuarantinedForkPlan(ds: DaemonSession, promptInput: string | CliTurnPayload, resumeOrTurnId: ForkResumeOrTurnId): { fork: boolean; promptInput: string | CliTurnPayload; resumeOrTurnId: ForkResumeOrTurnId; }; /** * Fork (or re-attach) a worker for `ds`. * * Returns `false` ONLY when a quarantined tail-only owner's promotion could not * be recovered at this fork boundary (see resolveQuarantinedForkPlan): the caller * MUST treat the session as still worker-less (no live worker was started) and * leave the admission gate held. Every other outcome — forked, re-attached, * staged behind an ACK, routed through a live owner, or spawn-deferred during * device isolation — returns `true`. */ export declare function forkWorker(ds: DaemonSession, promptInput: string | CliTurnPayload, resumeOrTurnId?: ForkResumeOrTurnId): boolean; /** * Clear the stuck-warning authority markers WITHOUT patching the card. Used by * ACK paths (tui_keys_delivered / stuck_warning_expired) where the caller has * already resolved the card to its final state — we only need to drop the * nonce/cardId/turnId so a late duplicate click cannot re-inject keys. */ export declare function clearStuckWarningAuthority(ds: DaemonSession): void; /** * Resolve any active stuck-warning card (PATCH it to "done") AND clear its * markers. Called from every path where the CLI can recover or be replaced: * prompt_ready, claude_exit, worker exit/kill/suspend/refork, and the worker's * own stale-card notification. Centralising here avoids missing an exit path * and leaving a clickable card that can inject keys into a different CLI. * * ACK paths (tui_keys_delivered / stuck_warning_expired) do NOT use this — * they patch the card themselves with a context-specific message and then call * clearStuckWarningAuthority() to drop the markers. */ export declare function invalidateStuckWarning(ds: DaemonSession, reason: string): void; declare function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess, startupState?: WorkerStartupState, reservedWorkerGeneration?: number): void; /** * Shutdown-only view of the in-flight Codex App final-settlement promises. Each * entry is a `deliverFinalOutput` awaited inside the IPC message handler that * has NOT yet reached its `cb.onTurnTerminal` call; a settlement resolving * enqueues the turn terminal. During graceful shutdown, after all worker IPC * channels have disconnected (so no NEW settlement can be created), the daemon * bounded-waits these to settle BEFORE closing the turn-terminal queue * admission — otherwise a settlement that resolves post-close would have its * terminal refused and lost. Returns a snapshot array (safe to Promise.all). */ export declare function snapshotCodexAppFinalSettlements(): Promise[]; /** Current in-flight Codex App final-settlement count. After all worker IPC has * disconnected the map cannot grow, so a re-read of 0 after awaiting the * snapshot confirms settlement quiescence. */ export declare function codexAppFinalSettlementCount(): number; declare function finalOutputDedupeKey(ds: DaemonSession, msg: Extract): string; /** * Turn-end half of the two-phase turn reactions (auto-on for card-off sessions, * i.e. streaming card disabled). The 冲! "received" reactions are added per-message at the daemon * acceptance point (`noteTurnReceived`); the screen_update handler calls this * only on working|analyzing → idle|limited (not cold-start starting→idle), to * flip every pending ✋ on this session to ✅ DONE and clear the list. When * silentTurnReactions is enabled after a ✋ has already landed, we only remove * that received reaction and do not add DONE. Binding the start to the message * (not a status edge) means type-ahead / busy-batched messages each get their * own reaction and all settle together here. * * Every Feishu call is best-effort — a failure only means a missing emoji, so it * must never throw into the status pipeline (callers invoke as `void`). */ declare function finishTurnReactions(ds: DaemonSession): Promise; declare function deliverFinalOutput(ds: DaemonSession, msg: Extract, t: string, attempt: number, onComplete?: (owned: boolean) => void, isStillOwned?: () => boolean, frozenReplyTarget?: FrozenSessionReplyTarget, frozenUsage?: CardUsageSnapshot): void; /** Test-only alias so the retry pipeline can be exercised without a real * fork. Intentionally underscored to discourage non-test callers. */ export declare const __testOnly_deliverFinalOutput: typeof deliverFinalOutput; export declare const __testOnly_setupWorkerHandlers: typeof setupWorkerHandlers; export declare const __testOnly_reserveWorkerGeneration: typeof reserveWorkerGeneration; export declare const __testOnly_finishTurnReactions: typeof finishTurnReactions; export declare const __testOnly_finalOutputDedupeKey: typeof finalOutputDedupeKey; export declare const __testOnly_retireTerminalizedCodexAppLedgerEntriesForRecovery: typeof retireTerminalizedCodexAppLedgerEntriesForRecovery; declare function reserveWorkerGeneration(ds: DaemonSession): number; /** * Is the file sandbox active such that /adopt must be refused? A sandbox can * only be established at spawn time (bwrap wrap / Seatbelt profile); adopt * attaches to an ALREADY-running host process, so it could only ever run * UNsandboxed. From-strict UNION of every source that can require isolation: * - live per-bot `sandbox` / legacy `readIsolation`, * - the global `BOTMUX_SANDBOX=1` (sandboxEnabled()), * - the session's FROZEN sandbox decision (`session.sandbox`, recorded at * creation). forkWorker treats the frozen decision as authoritative, so a * session created sandboxed must stay un-adoptable even if the admin later * toggles the bot's global flag OFF — otherwise its `/adopt` would attach to * an unsandboxed live process. The reverse (bot=true / session=false) is * also caught by the union, which is the safe direction. * Callers reject at the ENTRY point (before persisting `adoptedFrom` / replying * "adopted") — see command-handler's `/adopt`, the adopt_select card, and the * session-manager restore branch. Resume/cold-start is unaffected: those spawn * a fresh CLI, which the sandbox wraps normally. */ export declare function adoptSandboxBlocked(botCfg: { sandbox?: boolean; readIsolation?: boolean; apiOnly?: boolean; }, session?: { sandbox?: boolean; chatId?: string; }): boolean; export declare function forkAdoptWorker(ds: DaemonSession, opts?: { restoredFromMetadata?: boolean; prompt?: string; turnId?: string; }): void; /** A live process, reduced to what orphan detection needs. */ export interface ProcSnapshot { pid: number; ppid: number; /** Full command line (argv joined by spaces). */ cmd: string; } /** * Enumerate live processes as {pid, ppid, cmd}. Linux reads `/proc` directly * (the rest of the worker code already relies on /proc); other POSIX shells out * to `ps`. Returns `[]` on Windows or on any failure — callers then reap * nothing, so "can't tell" can never escalate into a wrong kill. */ export declare function listProcesses(): ProcSnapshot[]; /** * Reap worker processes orphaned by a previous daemon that died WITHOUT running * its graceful shutdown — SIGKILL, OOM, or an uncaught crash. The shutdown() * path in daemon.ts already SIGKILLs stragglers on SIGTERM, but a hard kill * skips it entirely: the workers get re-parented to init (ppid==1), and because * a fresh worker's pid overwrites `session.pid`, killStalePids can never reach * them again. Each leaks ~0.5 GB and they pile up across restarts (observed: * 22 orphans / 3.3 GB on a dev box; daemon.ts records a prior 841-orphan / * ~65 GB incident). * * Identification is deliberately conservative — a process is reaped only if it * BOTH: * 1. has ppid==1 — its forking daemon is gone. A live daemon's workers are * parented to that daemon, so this never touches a running worker, even * under the one-daemon-per-bot layout or when several daemons start at * once; and * 2. references THIS install's worker script in its command line — so we * never touch another botmux install or an unrelated `worker.js`. * * Process listing and the kill syscall are injectable for tests. Returns the * number of orphans actually reaped. */ export declare function reapOrphanWorkers(opts?: { procs?: ProcSnapshot[]; kill?: (pid: number, signal: NodeJS.Signals) => void; workerPath?: string; }): number; export declare function killStalePids(activeSessions_: Session[], runtimeSessions?: ReadonlyMap): void; /** * Sweep dead CLI-pid marker files out of `.botmux-cli-pids/`. Each marker is * named for the PID that wrote it; when that PID is gone the file is a landmine — * the kernel eventually recycles the number onto an unrelated process, and a * `botmux send` climbing its ancestry can then read a since-exited session's * marker and route the message into the WRONG bot's session. (Fix A already * rejects such a marker at read time by verifying procStart / the env session * id; this GC removes the file so the collision can't even be attempted, and * keeps the directory from growing without bound — graceful worker exit unlinks * its own marker, but SIGKILL / crash / force-kill do not.) * * Cross-daemon safe: with many daemons sharing one data dir, a DEAD pid cannot * belong to any live daemon's session, so unlinking its marker never races a * peer. A live pid's marker is always left untouched. The tiny window where a * PID is recycled between the liveness probe and unlink is benign — the new * owner rewrites its marker on the next turn, and Fix A makes a briefly-missing * marker fall back to the correct env id, never to a wrong session. * * `isPidAlive` is injectable so tests are deterministic regardless of what PIDs * the host has actually allocated (picking a "surely-dead" literal is unsafe — * it can be below pid_max and collide with a live process on a busy runner). * Production uses `process.kill(pid, 0)`: a signal-0 probe that never kills. * Conservative on every ambiguity — only a definitively-dead PID is swept. */ export declare function defaultPidLiveness(pid: number): boolean; export declare function sweepDeadPidMarkers(dataDir?: string, isPidAlive?: (pid: number) => boolean): number; export declare function setCurrentCliVersion(v: string, runtimeKey?: string): void; export declare function getCurrentCliVersion(runtimeKey?: string): string; //# sourceMappingURL=worker-pool.d.ts.map