import { expandHome } from './working-dir.js'; import type { BotConfig } from '../bot-registry.js'; import type { CliId } from '../adapters/cli/types.js'; import { type CreateSessionColumn, type SpawnRole, type Coworker } from './session-create.js'; import type { BackendType } from '../adapters/backend/types.js'; import type { ChatContext, CliTurnPayload, LarkAttachment, LarkMention, ScheduledTask, SubstituteTrigger } from '../types.js'; import type { MessageResource } from '../im/lark/message-parser.js'; import type { ResolvedSender } from '../im/lark/identity-cache.js'; import type { DaemonSession } from './types.js'; import { type Locale } from '../i18n/index.js'; export { getAttachmentsDir } from './attachment-path.js'; type RefreshCliVersion = (botConfig: Pick) => boolean; /** * Runtime counterpart of the restore-time CLI-mismatch guard(#346 只堵了重启 * 路径):bot 的启动选择(cliId / wrapperCli)在 daemon 运行中被热切后,存量会话 * 仍冻结着旧 CLI,下一条消息(或 terminal 唤醒)会把旧 CLI lazy resume 回来。 * 热切端点在改完配置后调用本函数,把该 bot 名下失配的活跃会话连同 backing pane * 一起关掉。 * * 豁免口径与 restoreActiveSessions 一致:queued(待办池)会话从没起过 CLI; * adopt 会话接管的是用户自己的外部 CLI,其 cliId 与 bot 配置不同是合法状态。 */ /** * 返回值区分三种结果,而不是一个 closed 计数:远端 CLI(mojo / riff)的会话活在 * 本机之外,本地行关掉了而远端仍存活是真实可能的结果。 * * - `closed`:本地行已关闭,且没有需要人工处理的残留。 * - `residual`:本地行已关闭(因此不会再被路由到),但远端会话还在,其 taskId 需要 * 上报给运维,否则调用方只看到「closed N」而拿不到清理线索。 * - `failed`:关闭被拒绝(例如远端取消无法证明),该行仍然 active。 */ export declare function closeCliMismatchedSessionsForBot(larkAppId: string): Promise<{ closed: number; residual: number; failed: number; }>; /** * Suspend (kill the CLI/pane, keep the session active) every non-queued, * non-adopt active session of a bot, so the NEXT message cold-restarts them. * Used by the read-isolation toggle: read isolation is applied only at cold * spawn (via provisionIsolatedBotHome + the Seatbelt wrapper), so flipping the * flag must force a cold restart — otherwise a user who close+resumes keeps * running the old (un-provisioned) state and the toggle silently no-ops. * Exemptions mirror closeCliMismatchedSessionsForBot (queued never started a * CLI; adopt sessions own a user's external CLI). Returns the count suspended. */ export declare function suspendActiveSessionsForBot(larkAppId: string): Promise; export { expandHome }; export declare function getSessionWorkingDir(ds?: DaemonSession): string; export declare function getProjectScanDir(ds?: DaemonSession): string; /** * Return all directories to scan for projects (supports multi-dir WORKING_DIR). * Each configured workingDir is used as the scan root AS-IS — scanProjects * recurses downward from it. See getProjectScanDir for why we no longer climb * to the parent directory. */ export declare function getProjectScanDirsForBot(larkAppId: string, workingDir?: string): string[]; /** Session-shaped compatibility wrapper for callers that already own a DS. */ export declare function getProjectScanDirs(ds?: DaemonSession): string[]; export declare function downloadResources(larkAppId: string, messageId: string, resources: MessageResource[]): Promise<{ attachments: LarkAttachment[]; needLogin: boolean; }>; /** Get bots actually present in the chat (excludes current bot). * Calls Lark OpenAPI to list chat members, then cross-references with * registered bots to enrich with cliId. Falls back to empty on API error. */ export declare function getAvailableBots(currentAppId: string, chatId: string): Promise>; /** * Render a `` tag for prompt injection. Caller resolves the sender * (open_id + type + optional name/email) via `resolveSender(...)` in identity-cache. * Returns empty string when no sender data is available so the prompt stays * clean for synthetic flows (scheduled tasks, no-op spawns). */ export declare function renderSenderTag(sender?: ResolvedSender): string; /** * cursor-agent's model tends to copy the inlined `` verbatim into its reply — it reads `open_id:name` as the * `--mention ` form and leaks `ou_xxx:高鹏` into the `botmux * send` body / opening line. Other CLIs haven't shown this, so the guard is * scoped to cursor only (claude-code et al. that set injectsSessionContext * never see this inline tag anyway). Returns '' for every other CLI and when * there is no sender tag to misread. */ export declare function renderCursorSenderNote(cliId: CliId | undefined, hasSender: boolean, locale?: Locale): string; /** * Render a buffered follow-up's sender attribution for daemon's pending-repo * branch (handleThreadReply), where a cross-user follow-up's `` tag is * prepended OUTSIDE the builder and later folds into the opening * ``. Pair the tag with the cursor anti-echo note so a folded-in * foreign sender gets the same protection the builder gives its own top-level * ``; otherwise an inline `ou_xxx:name` reaches cursor with no adjacent * note (the builder's note only covers `ds.pendingSender`'s top-level tag, and * may be absent entirely when pendingSender is undefined). Returns '' when * there is no sender to attribute. */ export declare function renderBufferedSenderBlock(sender: ResolvedSender | undefined, cliId: CliId | undefined, locale?: Locale): string; export declare function formatAttachmentsHint(attachments?: LarkAttachment[], locale?: Locale): string; export declare function ensureSessionWhiteboard(ds: DaemonSession): void; export declare function buildNewTopicPrompt(userMessage: string, sessionId: string, cliId: CliId, cliPathOverride?: string, attachments?: LarkAttachment[], mentions?: LarkMention[], availableBots?: Array<{ name: string; displayName: string; openId: string; }>, followUps?: string[], botIdentity?: { name?: string; openId?: string; }, locale?: Locale, sender?: ResolvedSender, opts?: { larkAppId?: string; chatId?: string; whiteboardId?: string; substituteTrigger?: SubstituteTrigger; chatContext?: ChatContext; }): string; /** Build the legacy opening prompt plus a Codex App structured sidecar. The * sibling string API above stays unchanged for every existing caller. Pending- * repo follow-ups currently arrive as already-enriched strings (and may contain * sender tags), so that rare merged path deliberately falls back to legacy * rather than guessing which bytes are user text. */ export declare function buildNewTopicCliInput(userMessage: string, sessionId: string, cliId: CliId, cliPathOverride?: string, attachments?: LarkAttachment[], mentions?: LarkMention[], availableBots?: Array<{ name: string; displayName: string; openId: string; }>, followUps?: string[], botIdentity?: { name?: string; openId?: string; }, locale?: Locale, sender?: ResolvedSender, opts?: { larkAppId?: string; chatId?: string; whiteboardId?: string; substituteTrigger?: SubstituteTrigger; codexAppText?: string; codexAppApplicationContext?: string; codexAppMessageContext?: string; codexAppFollowUps?: string[]; codexAppFollowUpContexts?: string[]; chatContext?: ChatContext; }): CliTurnPayload; /** * 按既有顺序构造 follow-up 的各个块。inline 模式直接 join;hook 模式 * (#794)把 reminder/whiteboard 挪进 sidecar,其余块照常 join 进 PTY 文本。 */ /** follow-up 构建选项。sessionBackendType 取会话冻结的后端类型(非当前 bot 配置, * 那些是 next-session 生效),用于判断该会话是否有本地 Claude hook 进程。 */ type FollowUpOpts = { attachments?: LarkAttachment[]; mentions?: LarkMention[]; isAdoptMode?: boolean; cliId?: CliId; cliPathOverride?: string; locale?: Locale; sender?: ResolvedSender; larkAppId?: string; chatId?: string; whiteboardId?: string; substituteTrigger?: SubstituteTrigger; codexAppText?: string; codexAppApplicationContext?: string; codexAppMessageContext?: string; /** 会话冻结的后端类型(ds.session.backendType)。riff 等远端后端没有本地 * Claude hook 进程,强制 inline 模式。 */ sessionBackendType?: BackendType; /** 本轮的权威 turnId(= 发给 worker 的 turnId,最终成为 managedTurnOrigin.turnId)。 * hook 模式下 sidecar 按 (turnId, fingerprint) 绑定,claim 时按权威 turnId 精确取。 * 缺失时无法做 turn 绑定,回退 inline(避免 reminder 被剥离却无 sidecar 可领)。 */ turnId?: string; }; export declare function buildFollowUpContent(content: string, sessionId: string, opts?: FollowUpOpts): string; /** Follow-up counterpart of buildNewTopicCliInput. */ export declare function buildFollowUpCliInput(content: string, sessionId: string, opts?: FollowUpOpts): CliTurnPayload; /** * Build raw input content for adopt-bridge mode. * * Bridge mode injects the user's text into the existing CLI exactly as the * local user would type it: NO ``, NO ``, NO * Skills hint. The model is intentionally unaware of botmux — the daemon * harvests final output via the transcript watcher and forwards it to Lark * out-of-band. * * Attachments and @mentions are surfaced as plain prose so the user's intent * carries over, but the format avoids any wording that would prompt the * model to call `botmux send` / route through botmux tooling. */ export declare function buildBridgeInputContent(content: string, opts?: { attachments?: LarkAttachment[]; mentions?: LarkMention[]; selfMention?: { name?: string | null; openId?: string | null; }; locale?: Locale; }): string; /** * Build the prompt that gets piped into a freshly-spawned CLI when an existing * (non-bridge) session re-forks its worker. Hits the `worker=null` re-fork * branch in handleThreadReply: resume after /close, daemon-restart + new * message, and any other path that lands a new turn without a live worker. * * Without wrapping, the worker would queue the user's raw text as the initial * prompt — the CLI sees no `` / `` envelope * and answers in its own terminal instead of calling `botmux send`. This * helper centralises the wrap so both daemon.ts and tests agree on the shape. * * Adopt-bridge sessions go through `buildBridgeInputContent` instead — see * the buildBridgeInputContent docstring for why bridge prompts intentionally * skip botmux routing tags. */ export declare function buildReforkPrompt(ds: DaemonSession, content: string, opts?: { attachments?: LarkAttachment[]; mentions?: LarkMention[]; cliId?: CliId; cliPathOverride?: string; selfMention?: { name?: string | null; openId?: string | null; }; locale?: Locale; sender?: ResolvedSender; }): string; /** Structured refork variant. Adopted external CLIs intentionally remain on * their existing raw bridge path and never receive a Codex App sidecar. */ export declare function buildReforkCliInput(ds: DaemonSession, content: string, opts?: { attachments?: LarkAttachment[]; mentions?: LarkMention[]; cliId?: CliId; cliPathOverride?: string; selfMention?: { name?: string | null; openId?: string | null; }; locale?: Locale; sender?: ResolvedSender; substituteTrigger?: SubstituteTrigger; codexAppText?: string; codexAppApplicationContext?: string; codexAppMessageContext?: string; turnId?: string; }): CliTurnPayload; /** * Copy current streaming-card fields from `ds` into the persisted Session and save. * Lets the existing card be PATCHed on next screen_update after a daemon restart, * instead of a fresh card being POSTed. */ export declare function persistStreamCardState(ds: DaemonSession): void; export declare function rememberLastCliInput(ds: DaemonSession, userPrompt: string, cliInput: string | CliTurnPayload, opts?: { codexAppInputAccepted?: boolean; }): void; /** * Whether daemon restore should eagerly re-fork a worker to re-attach a * surviving backing pane. True for every persistent backend (tmux/herdr/zellij/zmx); * the pty backend has nothing to re-attach to, so it stays lazy. * * Eager re-attach is what makes a session actually come back after a restart — * otherwise a killed worker leaves the session dead until its next message, and * a pane whose CLI died in the meantime never gets healed, so the transcript * fallback can't fire. The old `BOTMUX_QUIET_RESTART` gate that suppressed this * (to avoid re-pushing cards on dev restarts) is gone: restored sessions now * carry `suppressRecoveryCard`, so the recovery re-fork stays silent in the * Lark thread without having to skip recovery altogether. */ export declare function shouldAutoForkOnRestore(backendType: BackendType): boolean; /** * Re-fork the given restored sessions to re-attach their surviving panes, but * staggered to avoid a thundering-herd CPU/IO spike when many sessions survive a * restart: spawn `batchSize` workers, wait `delayMs`, repeat. * * Sessions whose worker is already live are skipped. Startup admissions are * held behind restore, but lifecycle callbacks or a future recovery path can * still close/replace/wake an entry during one of the batch delays; re-forking * that stale object would kill the current worker via the double-fork guard. */ export declare function staggeredRecoveryFork(sessions: readonly DaemonSession[], fork: (ds: DaemonSession) => void, batchSize?: number, delayMs?: number, stillOwned?: (ds: DaemonSession) => boolean): Promise; export declare function restoreActiveSessions(activeSessions: Map, quarantinedSessionIds?: ReadonlySet): Promise; /** * Resolve a session's live web-terminal worker port, WAKING the worker on demand * if needed. * * A session can be active with no live worker — a pty session that resumes * lazily, or a persistent-backend session whose staggered restart re-fork * hasn't reached it yet (or whose worker died since). The terminal * reverse-proxy, however, needs the worker's HTTP port to serve `/s/{id}`, so a * surviving-but-worker-less session would otherwise 502 ("session not running") * even though its tmux/zellij pane is alive. This bridges that gap: if the * session is active and its persistent backing pane still exists, re-fork the * worker to re-attach (empty prompt = no new turn, same as restart reattach) and * wait for it to report its port. * * Returns the port, or undefined when there's nothing serveable (no live worker * possible: not active, non-persistent backend, or the pane is gone). The * `forkWorker` double-fork guard plus its synchronous `ds.worker` assignment make * concurrent calls (the terminal's HTML GET + WS upgrade arrive together) safe — * only the first forks; the rest just await the same `ds.workerPort`. */ export declare function ensureTerminalWorkerPort(ds: DaemonSession): Promise; /** * Reactivate a single closed session — used by the "▶️ 恢复会话" card button * and the `botmux resume ` CLI command. Mirrors the per-session branch * of `restoreActiveSessions` but operates on one record by id and without * killing stale pids (the `/close` flow that produced this closed record * already killed them). * * Returns `{ ok: true, ds }` on success; structured error otherwise so callers * (HTTP IPC, card handler) can surface a precise message. * * - 'not_found' — sessionId doesn't exist in any session file * - 'not_closed' — session is still active or in some other state * - 'anchor_occupied' — another active session already owns this anchor * (e.g. user kept typing after /close, auto-creating * a fresh thread session); refuse rather than clobber * - 'adopt_unsupported' — adopt sessions are torn down by /close and have * no resume semantics * - 'deferred_unmaterialized' — a silent fresh-topic run finished without * publishing, so it has no conversation to resume * - 'resume_cancelled' — a concurrent close won while resume was committing */ export declare function resumeSession(sessionId: string, activeSessions: Map): Promise<{ ok: true; ds: DaemonSession; } | { ok: false; error: 'not_found' | 'not_closed' | 'anchor_occupied' | 'adopt_unsupported' | 'deferred_unmaterialized' | 'resume_cancelled'; activeSessionId?: string; }>; /** * Prompt preamble for silent scheduled fires. The regular first-turn wrapper * instructs the model to post progress updates via `botmux send`; a silent * monitoring task needs the opposite default — say nothing unless the alert * condition in the task prompt is met. Exported for tests. */ export declare function buildSilentScheduleHint(taskName: string, locale?: Locale): string; /** * Resolve the durable execution position of a scheduled task. * * Schedule execution position is task-level state: top-level starts from the * group top level, topic continues under a retained root, and new-topic posts a * fresh seed on every run. Legacy `deliver:new-topic` rows resolve to that third * state until the store normalizes them. * * A malformed/legacy `scope:'thread'` task without a root cannot reply in a * thread. Treat it as chat-scope so silent runs remain genuinely silent rather * than posting a banner merely to manufacture an anchor. */ export declare function resolveScheduledTaskScope(task: Pick): 'thread' | 'chat'; export declare function resolveScheduledTaskExecutionPosition(task: Pick): 'top-level' | 'topic' | 'new-topic'; export declare function executeScheduledTask(task: ScheduledTask, activeSessions: Map, refreshCliVersion: RefreshCliVersion): Promise; export interface SpawnDashboardSessionArgs { larkAppId: string; /** 新建的飞书群(chat-scope 锚点)。 */ chatId: string; /** 用户在弹框里写的原始任务内容。 */ content: string; /** in_progress=立即开跑;backlog=入待办池(parked,不起 CLI)。 */ column: CreateSessionColumn; /** 本 bot 在群里的角色,决定首轮 prompt 怎么包(lead 编排 / collab 并列 / solo)。 */ role: SpawnRole; /** 群里其它可协作的 bot(lead 用来列 sub bot、collab 用来提示同伴)。 */ coworkers?: Coworker[]; /** Images pasted into the Dashboard content box, already validated and * materialized inside this daemon's per-app attachment bucket. */ attachments?: LarkAttachment[]; /** 会话标题,缺省取内容首行。 */ title?: string; /** 是否在群里发一条可见的任务横幅(只由 creator/lead 那一次 spawn 发,避免 N 个 bot 重复刷屏)。 */ postBanner?: boolean; /** 会话归属人 open_id(本 bot 作用域);缺省回退本 bot 首个 allowedUser。 */ ownerOpenId?: string; ownerUnionId?: string; } /** 在新建的飞书群里为某个 bot 拉起一条 chat-scope 会话(dashboard「创建会话」用)。 * column='in_progress' → 立即 forkWorker 把内容当首轮发给 CLI; * column='backlog' → 入待办池(parked:worker:null + session.queued + queuedPrompt), * 等被激活(拖到进行中 / 点开始 / 群里来消息)再起 CLI。 * 与调度器 new-topic spawn 同构,差别只在「可暂存不起」与角色包装。 */ export declare function spawnDashboardSession(activeSessions: Map, refreshCliVersion: RefreshCliVersion | undefined, args: SpawnDashboardSessionArgs): Promise<{ ok: true; sessionId: string; } | { ok: false; error: string; }>; /** 激活一条 parked(待办池)会话:把暂存的 queuedPrompt 当首轮发给 CLI,清掉 queued * 标记。供「拖到进行中」「点开始」「群里来第一条消息」三个入口复用。已起过的会话 * (worker 在或 hasHistory)直接返回 already_active,幂等。 */ export declare function activateQueuedSession(ds: DaemonSession): Promise<{ ok: boolean; error?: string; }>; //# sourceMappingURL=session-manager.d.ts.map