/**
 * 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 { CliId } from '../adapters/cli/types.js';
import type { WorkerToDaemon, Session } from '../types.js';
import { type DaemonSession } from './types.js';
import { type CliUsageLimitState } from '../utils/cli-usage-limit.js';
export interface WorkerPoolCallbacks {
    sessionReply: (rootId: string, content: string, msgType?: string, larkAppId?: string, turnId?: string) => Promise<string>;
    getSessionWorkingDir: (ds?: DaemonSession) => string;
    getActiveCount: () => number;
    /** Close a stale session (message withdrawn, etc.) */
    closeSession: (ds: DaemonSession) => void;
}
/**
 * Initialise worker-pool callbacks. Must be called once before forkWorker().
 */
export declare function initWorkerPool(cb: WorkerPoolCallbacks): void;
export declare function setActiveSessionsRegistry(m: Map<string, DaemonSession>): 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<string, DaemonSession> | 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;
export declare function writableTerminalLinkFor(ds: DaemonSession): string | undefined;
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 Lark and clear the cache.
 * Called whenever a new streaming card becomes the active one — old turns'
 * cards just add visual clutter when scrolling thread history.
 *
 * 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;
/**
 * 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 terminal isn't ready yet (no port), so the caller can surface a
 * friendly "not ready" message.
 *
 * 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<string>): Promise<boolean>;
/**
 * 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 so the operator never has
 * to leave the conversation. Feishu's ephemeral API only works in plain `group`
 * chats — topic / thread groups reject with {@link LARK_CODE_EPHEMERAL_NOT_GROUP}
 * (18053) and p2p chats are unsupported — and chatType can't distinguish a topic
 * group from a regular one (both record 'group'), so we attempt ephemeral for any
 * non-p2p chat and fall back to a private DM on ANY failure. p2p chats skip the
 * doomed ephemeral attempt and DM directly (the DM lands in that same 1:1 chat).
 *
 * 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' | 'no_owner' | 'delivery_failed';
    delivered: number;
    total: number;
    channels: Array<'ephemeral' | 'dm' | 'failed'>;
}
/**
 * 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<WriteLinkOwnerDelivery>;
/**
 * 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'>;
/**
 * 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 flat **chat-scope** sessions. 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. 话题群 happened to reject ephemeral with 18053 and fall
 * back to the in-thread reply, but a 话题 inside a 普通群 succeeds and leaks the
 * card out of the thread. So thread-scope sessions always take the visible
 * `reply()` path, which routes back into the thread (`reply_in_thread`).
 */
export declare function deliverEphemeralOrReply(ds: DaemonSession, operatorOpenId: string | undefined, content: string, msgType: 'text' | 'interactive', reply: () => Promise<unknown>): Promise<void>;
/**
 * 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): void;
export declare const restartCounts: Map<string, {
    count: number;
    lastAt: number;
}>;
/**
 * Ensure built-in skills are installed for a given CLI.
 * Synchronous and idempotent — runs once per CLI per daemon lifecycle.
 */
export declare function ensureCliSkills(cliId: CliId, cliPathOverride?: string): void;
/**
 * Remove legacy `botmux` MCP server registration from the given CLI's config.
 * Idempotent — runs once per CLI per daemon lifecycle.  Best-effort: any
 * failure is swallowed; we never want to block worker startup.
 */
export declare function cleanupLegacyMcpConfig(cliId: CliId): void;
/**
 * Ensure per-CLI environment is set up for this daemon lifecycle: install
 * built-in skills and clean up any legacy MCP server registration.
 * Both steps are idempotent and best-effort.
 */
export declare function ensureCliEnv(cliId: CliId, cliPathOverride?: string): 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;
export declare function killWorker(ds: DaemonSession): void;
export declare function suspendWorker(ds: DaemonSession, reason?: string): boolean;
/**
 * 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 }`.
 */
export declare function closeSession(sessionId: string): Promise<{
    ok: true;
    alreadyClosed: boolean;
}>;
/**
 * Set an entry on an active-sessions Map, but if the key is already occupied
 * by a DIFFERENT DaemonSession, close that occupant first. Replaces bare
 * `activeSessions.set(key, ds)` at sites where a silent overwrite would leak
 * the prior entry's worker + leave its store row stuck in `status='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.
 *
 * Canonical collision case: restoreActiveSessions at daemon boot iterating
 * two on-disk active sessions that resolve to the same chat-scope key (e.g.
 * a /relay command's scratch session + the real session that was transferred
 * into the same chat by a prior daemon run). Without this helper the later
 * iterated entry silently wins, the earlier one becomes a ghost-active.
 *
 * Setting the same `ds` at its own key is a no-op (no close).
 */
export declare function setActiveSessionSafe(map: Map<string, DaemonSession>, key: string, ds: DaemonSession): Promise<void>;
/**
 * 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-<sessionId>` 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;
}): Promise<{
    ok: true;
} | {
    ok: false;
    error: string;
}>;
export declare function forkWorker(ds: DaemonSession, prompt: string, resume?: boolean): void;
declare function setupWorkerHandlers(ds: DaemonSession, worker: ChildProcess): void;
/** Deliver a bridge `final_output` to Lark. The worker emits each turn
 *  exactly once (it pops the turn off its queue at emit time), so the
 *  daemon owns retries on transient failures. After 3 attempts we log
 *  and give up — the user's answer is lost; better than leaking memory
 *  via an unbounded retry loop. */
declare function deliverFinalOutput(ds: DaemonSession, msg: Extract<WorkerToDaemon, {
    type: 'final_output';
}>, t: string, attempt: number, lockedPendingCardId?: string, lockedQuoteTargetId?: string): 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 function forkAdoptWorker(ds: DaemonSession, opts?: {
    restoredFromMetadata?: boolean;
}): 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[]): void;
export declare function setCurrentCliVersion(v: string): void;
export declare function getCurrentCliVersion(): string;
export {};
//# sourceMappingURL=worker-pool.d.ts.map