import type { SessionBackend, SessionDestroyResult, SessionShutdownDetachResult, SpawnOpts } from './types.js'; /** * Mandatory setup commands run in the riff sandbox to ensure `botmux` is * available. These are ALWAYS sent to the riff API via `config.setupCommands` * (not via prompt injection) so the install is reliable and not dependent on * the agent parsing a prompt. The riff sandbox has Node.js (it runs codex), * so npm install works. Any user-configured setupCommands are appended AFTER * these mandatory commands. */ /** riff(codex bridge)接受的思考等级档位——与服务端 shared/reasoningEffort 对齐。 */ export declare const RIFF_REASONING_EFFORTS: readonly ["low", "medium", "high", "xhigh"]; export declare const RIFF_SANDBOX_CLUSTERS: readonly ["boe", "cn"]; export type RiffSandboxCluster = typeof RIFF_SANDBOX_CLUSTERS[number]; export interface RiffBackendConfig { baseUrl: string; templateId?: string; /** @deprecated riff 服务端已收敛仅支持 codex(其它值 400);本字段不再被读取, * 任务一律以 agent=codex 创建。保留仅为兼容存量 bots.json。 */ agent?: string; model?: string; /** codex 思考等级(low/medium/high/xhigh),写入沙箱 config.toml 的 * model_reasoning_effort;留空走 riff 默认 medium。非法值静默丢弃。 */ reasoningEffort?: string; /** Direct JWT token (takes precedence over jwtEnv). */ jwt?: string; /** Name of env var containing the JWT token (default: RIFF_JWT). */ jwtEnv?: string; /** * Command to refresh the ByteCloud JWT when the keychain holds no live token. * When neither `config.jwt` nor the env token is set and `readBytecloudKeychainJwt` * returns null (every candidate expired / within the safety window / absent), * riff task creation would fail with a 401 that aborts the whole turn. Before * giving up, we run this command ONCE (debounced) to let the owning CLI * (bytedcli / kaboo-cli) refresh credentials and rewrite the keychain, then * re-read. bytedcli is the ByteCloud JWT owner: `bytedcli auth * get-bytecloud-jwt-token --force-refresh` refreshes via its Auth SDK and * writes the token to `~/.local/share/bytedcli/data/bytecloud-auth/…`, which is * already a `bytecloudKeychainCandidates` path. * * Shape: [binary, ...args]. When unset, resolves in order: * 1. env `BOTMUX_RIFF_JWT_REFRESH_CMD` (space-split, e.g. `bytedcli auth get-bytecloud-jwt-token --force-refresh`) * 2. a `bytedcli` binary found on PATH → `bytedcli auth get-bytecloud-jwt-token --force-refresh` * 3. otherwise no auto-refresh (fail-closed to the old behaviour — a 401). * We intentionally do NOT default to `npx @bytedance-dev/bytedcli@latest`: an * uncached/`@latest` npx resolve can block ~30s per call, far too slow for a * synchronous pre-request refresh. Deployments wanting npx must set the env * explicitly (and ideally pin the version). */ jwtRefreshCmd?: string[]; /** Sandbox resource pool selected for newly-created tasks. Riff defaults to * BOE when omitted; follow-ups inherit the parent task's sandbox. */ sandboxCluster?: RiffSandboxCluster; /** * Repos to clone into the riff sandbox, in the API's native shape * ({ repoName: 'group/repo', repoBranch? }). Takes precedence over * defaultRepo/defaultBranch. Typically derived by the worker from the * session's local workingDir (复用本地仓库+分支) — see * deriveRiffRepoFromWorkingDir. */ repos?: RiffRepoRef[]; /** Parent task id persisted by the daemon (see worker riff_task_id IPC) — * restores the follow-up lineage after a daemon restart. */ resumeParentTaskId?: string; /** Human-readable notes about the derived repo state (dirty tree, unpushed * commits). Printed as status lines on task creation so the user knows the * sandbox may not see their latest local changes. */ repoWarnings?: string[]; injectStatusLines?: boolean; logLevel?: string; /** * Environment variables injected into the riff sandbox execution environment. * Merged from: botmux session context vars (BOTMUX_SESSION_ID, …) → per-bot * env (bots.json `env`) → explicit config.env (which takes precedence). * The sandbox installs botmux via setupCommands, so BOTMUX_* vars are needed * for the agent to use `botmux send`. Sent as `config.env` to the riff API. */ env?: Record; /** * System prompt injected into the riff task. Prepended to the userPrompt * (riff API has no separate system-prompt field) so the agent knows it is * running inside a botmux-bridged session. When unset, the built-in * DEFAULT_RIFF_SYSTEM_PROMPT is used as a fallback. */ systemPrompt?: string; /** * ADDITIONAL shell commands run in the riff sandbox before the agent starts * working. botmux is ALWAYS installed via MANDATORY_SETUP_COMMANDS (not * user-editable, sent to the riff API as config.setupCommands); these are * extra commands the user wants to run after that (e.g. installing other * dependencies). Sent to the riff API as `config.setupCommands` appended * after the mandatory botmux install commands. */ setupCommands?: string[]; } /** Valid riff service base URL: non-empty http(s). Shared by the worker's * spawn fail-fast and the dashboard PUT endpoint so every config entry point * (dashboard / /config / setup / hand-edited bots.json) hits the same gate. */ export declare function isValidRiffBaseUrl(v: unknown): v is string; export declare function isValidRiffSandboxCluster(v: unknown): v is RiffSandboxCluster; export interface RiffRepoRef { /** Internal repo name, e.g. 'webinfra/agent-monorepo' (internal git host). */ repoName: string; /** Branch to pin. Omitted → the repo's default branch. (The riff API * ignores unknown fields like `branch`; `repoBranch` is the real one — * verified empirically: it normalizes to gitRef/gitRefType/gitCommitId.) */ repoBranch?: string; } /** * Normalize a git origin URL / repo spec to riff's internal repoName. * Accepts SSH (`git@:group/repo.git`) and HTTPS * (`https:///group/repo(.git)`) forms from any host, plus bare * `group/repo`. The host is not inspected here — the riff API validates * repoName against its internal registry and cannot clone external repos, so * an out-of-registry spec is rejected downstream rather than by hostname here. */ export declare function parseRiffRepoName(spec: string): string | null; /** * Derive the riff repo ref from a local checkout so a riff task executes * against the same repo + branch the botmux session works in (复用本地仓库). * All git calls are local (no network). Returns null when the workingDir is * not a git repo or its origin cannot be parsed into a `group/repo` name. * `warnings` surface states the sandbox cannot see (dirty tree, unpushed * commits, never-pushed branch) — callers inject them as status lines. */ export declare function deriveRiffRepoFromWorkingDir(workingDir: string, runGit?: (args: string[]) => string | null): { repo: RiffRepoRef; warnings: string[]; } | null; /** * Multi-repo derivation over an EXPLICIT, ordered dir list — the repo-select * card's 多仓库 flow stamps the user's chosen worktree dirs (in selection * order) onto the session, and ONLY that stamp triggers multi-repo here. The * first dir becomes riff's `primary` (sandbox cwd). Never scans children of an * arbitrary non-git workingDir: a home dir / repo-collection dir would attach * random unrelated repos to the task. */ export declare function deriveRiffReposFromDirs(dirs: string[], deriveOne?: typeof deriveRiffRepoFromWorkingDir): { repos: RiffRepoRef[]; warnings: string[]; } | null; /** * Daemon-side orphan cancel: /close on a worker-less riff session must still * cancel the persisted remote task (the sandbox agent otherwise keeps running * with injected Lark credentials after the topic is closed). Bounded + one * retry; failures are logged, never thrown. */ export declare function cancelRiffTaskById(cfg: { baseUrl: string; jwt?: string; jwtEnv?: string; }, taskId: string): Promise; /** Irreversible short hash of a sandbox URL for log correlation — the unique * subdomain IS the write capability, so neither URL nor host may be logged. */ export declare function hashUrlForLog(u: string): string; /** * The keychain candidates for a ByteCloud tool's `bytecloud-auth/` store, across * the CLIs botmux users log into (kaboo-cli / aiden-cli / cjadk / bytedcli). * * ⚠️ This is NOT a "cast a wide net" list. The selector in * `readBytecloudKeychainJwt` picks the globally-freshest token by `exp` * REGARDLESS of order, so an extra candidate is not free: a stale/foreign token * at a location the tool never actually writes could WIN and shadow the real * one. Every entry must be a location the tool genuinely uses on THIS host: * - Config-style CLIs (kaboo-cli / aiden-cli / cjadk) resolve their base via * Go's os.UserConfigDir (verified against kaboo 1.3.77): macOS → * `~/Library/Application Support`, Windows → `%AppData%` (Go errors, does * NOT default to `~/AppData/Roaming`, when it is unset — so we emit no * config candidate then), otherwise → `$XDG_CONFIG_HOME` (else `~/.config`). * We list ONLY the current platform's root, never several — a * foreign-platform root is never live here and would only invite shadowing. * - cjadk also uses a home dot-dir `~/.cjadk`; aipaas uses `~/.aipaas`. * - bytedcli stores under `~/.local/share/bytedcli/data` on Linux, macOS AND * Windows: its `bytedcliBaseDir()` (bytedcli-core.js, 0.125.0) has no * platform branch and ignores `$XDG_DATA_HOME`. Inside an AIME workspace it * swaps the home base for `$AIME_WORKSPACE_PATH/` * — see the fail-closed early return below. * Order is otherwise NOT significant (selection is by `exp`, not position). * Non-existent candidates simply fail the read and are skipped. */ export declare function bytecloudKeychainCandidates(home?: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string[]; export declare function decodeJwtExp(jwt: string): number | null; /** * Read the ByteCloud JWT from the keychain candidates, preferring a live token. * Pure + injectable (home/env/now) so it is unit-testable without touching the * real HOME. Never throws — unreadable/malformed candidates are skipped. * * Selection (fixes the stale-token-shadows-valid-token hazard: an expired token * from an earlier-listed tool must not mask a valid token from a later one): * 1. Collect every candidate's non-empty `bytecloud_jwt`, in candidate order. * 2. Drop tokens whose decoded `exp` is already past `now`. * 3. Among the survivors, pick the one with the greatest `exp` (freshest); * candidates whose `exp` we cannot parse (opaque values) rank BELOW any * parseable live token and are used only as a last-resort fallback when no * parseable-live token exists — so a broken/opaque old value can never * shadow a clearly-valid newer token. * Returns null when nothing yields a usable token. */ /** * Treat a token that expires within this many seconds as already expired. riff * task creation reads the JWT once and does a single fetch; a 401 there throws * and fails the whole turn (SSE reconnect only covers an ALREADY-created task), * and a fresh sandbox cold-boot costs minutes — so a token about to expire * mid-request is worse than skipping to a longer-lived candidate. Also absorbs * small client/server clock skew. */ export declare const JWT_EXPIRY_SAFETY_WINDOW_SEC = 30; export declare function readBytecloudKeychainJwt(home?: string, env?: NodeJS.ProcessEnv, nowMs?: number, platform?: NodeJS.Platform): string | null; /** * Locate a `bytedcli` binary on PATH (used to build the default JWT-refresh * command). Returns the bare name `bytedcli` when found so execFileSync resolves * it via PATH, or null when absent. Injectable env/platform for testing. * * We look for a real installed binary rather than defaulting to * `npx @bytedance-dev/bytedcli@latest`: an uncached / `@latest` npx resolve can * block ~30s, which is unacceptable on the synchronous pre-request path. If * bytedcli is not installed we simply do not auto-refresh (fail-closed to the * prior behaviour — the request may 401, exactly as before this change). */ export declare function findBytedcliBinary(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string | null; /** * Resolve the JWT-refresh command: explicit config → env * `BOTMUX_RIFF_JWT_REFRESH_CMD` (space-split) → a PATH-resident bytedcli → * null (no auto-refresh). See RiffBackendConfig.jwtRefreshCmd for the rationale. */ export declare function resolveJwtRefreshCmd(configured: string[] | undefined, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string[] | null; /** Minimum gap between speculative JWT refresh attempts. When the keychain * holds no live token, a reconnect/follow-up loop would otherwise trigger a * refresh on EVERY getJwt() call; we cap proactive refreshes at one per window * and let the shared keychain re-read serve the rest. Also caps the cost of a * refresh command that keeps failing (e.g. bytedcli not logged in). A refresh * driven by an actual server 401 bypasses this window (see `force`). */ export declare const JWT_REFRESH_DEBOUNCE_MS = 60000; /** How long a single refresh command may run before we give up. Once the * refresh is asynchronous (below) this no longer blocks the event loop — it * only bounds one awaited child process — so we keep it generous: a cold * bytedcli token fetch can take a few seconds, and a session that cannot get a * JWT has nothing useful to do anyway. */ export declare const JWT_REFRESH_TIMEOUT_MS = 30000; export declare function __resetJwtRefreshDebounceForTest(): void; export interface RefreshBytecloudJwtOpts { /** Injectable async runner (defaults to a real execFile). */ runner?: (bin: string, args: string[]) => Promise; /** Injectable clock for the debounce window (defaults to Date.now()). */ nowMs?: number; /** Bypass the debounce window. Set only when an actual server 401/403 proved * the current token is bad — a rejection is authoritative evidence worth one * more attempt even inside the window. Never set for speculative refreshes. */ force?: boolean; } /** * Run the JWT-refresh command once, ASYNCHRONOUSLY. Never throws: a missing * command, a non-zero exit, or a timeout all resolve to `false` (the caller then * falls back to whatever the keychain holds — i.e. the pre-change behaviour). * Resolves true only when a command actually ran to completion. * * Async + injectable (runner / now / force) so it never blocks the event loop * (critical on the daemon-side orphan-cancel path) and the debounce / coalesce / * fail-closed paths are unit-testable without spawning a real process. * * COALESCE: if a refresh is already in flight, ride it instead of spawning a * second bytedcli — concurrent getJwt() callers share one refresh. */ export declare function refreshBytecloudJwt(cmd: string[] | null, opts?: RefreshBytecloudJwtOpts): Promise; /** * RiffBackend — bridges botmux's SessionBackend interface to riff's HTTP API. * * Lifecycle: * spawn() → initializes riff client (no actual task created yet) * write(text) → creates a task (first write) or follow-up (subsequent writes) * SSE output events flow through onData callback * kill() → cancels current task via task-cancel * onExit → fires on /close (kill) or unrecoverable error, NOT on task done * * SSE events use standard SSE format: event type in `event:` line, JSON in `data:` lines. * Events: output (text chunks), status (state changes), init (full state + accessUrl), * session_info (sandbox access info), done (task completion), log (verbose logs). */ export declare class RiffBackend implements SessionBackend { private config; private sessionId; private dataCb; private exitCb; private accessUrlCb; private taskDoneCb; private taskIdCb; private outputBuffer; private currentTaskId; private currentAccessUrl; /** True when currentAccessUrl is the sandbox directAccessUrl (never downgrade it). */ private accessUrlIsDirect; private abortController; private killed; /** /close teardown in progress — new writes are rejected and an in-flight * create/follow-up must cancel its late task instead of streaming it. */ private closing; private taskDone; /** Tasks whose done event already fired the turn boundary — a duplicate * done (observed live) or a stale stream must never re-fire it. Bounded: * cleared past 64 entries (a session rarely exceeds a few dozen turns). */ private completedTaskIds; private reconnectAttempts; private maxReconnectAttempts; /** Wall-clock ms when the CURRENT SSE connection was established, or `null` * when none is open / the last fetch never connected. Typed `number | null` * (not a 0 sentinel) on purpose: 0 is both "not connected" AND a valid number * you could subtract, so a future edit dropping the guard would compute a * bogus multi-decade lifetime from `Date.now() - 0` and refund forever. `null` * makes "never connected" un-subtractable and forces the guard at the type * level. The reconnect budget is refunded only when a broken connection had * LIVED long enough to be a healthy long connection merely severed by the * upstream proxy's fixed ~183s lifetime cap — NOT merely because a connection * opened. Keying on "connection lived ≥ reconnectHealthyConnMs" (not on * receiving init, and not on any data event — both were falsified/ * insufficient) is what separates the two cases that look identical from the * client: * • healthy cap: connection lives ~183s, EOFs → refund → task streams on * • dead/hot-loop: connection opens then EOFs within ~1s, repeatedly → * NO refund → budget exhausts and bails. Covers BOTH a fetch that never * connects (stays null) AND a "connect→init→instant-EOF" loop against a * stale-running orphan (lives void): void; /** Called when the current riff task completes or fails (turn boundary). */ onTaskDone(cb: () => void): void; /** Called whenever a new task id becomes current (create/follow-up). The * worker forwards it to the daemon so the follow-up lineage survives a * daemon restart (currentTaskId otherwise lives only in this process). */ onTaskId(cb: (taskId: string | null) => void): void; /** Resolve JWT dynamically — re-reads env/keychain each call so auto-refresh * works. Async because a keychain miss may trigger a (non-blocking) CLI * refresh. `opts.allowRefresh=false` skips the refresh entirely (daemon-side * orphan-cancel: a best-effort teardown must never freeze the daemon on a * host-identity refresh). `opts.forceRefresh=true` bypasses the debounce * window (an actual server 401 proved the token bad). */ private getJwt; private resolveJwt; private readJwtFromBytecloudKeychain; spawn(_bin: string, _args: string[], _opts: SpawnOpts): void; write(data: string): boolean; resize(_cols: number, _rows: number): void; onData(cb: (data: string) => void): void; onExit(cb: (code: number | null, signal: string | null) => void): void; kill(): void; destroySession(): Promise; abortDestroySession(): Promise; commitDestroySession(): void; prepareShutdownDetach(): Promise; abortShutdownDetach(): Promise; commitShutdownDetach(): void; getChildPid(): number | null; captureCurrentScreen(): string; captureViewport(): string; getPaneSize(): { cols: number; rows: number; } | null; /** * Emit a styled status line into the terminal stream. The worker renders * this through a headless xterm — bare `\n` (no carriage return) makes * lines stair-step to the right, which is the main reason the raw log view * was hard to read. Always emit `\r\n` and reset ANSI styling per line. */ private emitLine; /** Normalize newlines for xterm rendering (bare \n → \r\n, keep existing \r\n). */ private emitText; /** * Emit ONE timeline row for a route-B display projection. Unlike {@link emitLine} * (which brackets every call with a leading + trailing CRLF → blank lines between * consecutive rows, and leaves internal `\n` un-normalized → xterm stair-stepping), * this normalizes ALL internal newlines to CRLF and appends exactly ONE trailing * CRLF, with NO leading CRLF. Consecutive rows therefore sit on adjacent lines and * multi-line bodies render flush-left. */ private emitTimelineRow; /** * Render a riff route-B `display` projection (a codex app-server event distilled * to {kind,title,text,command,exitCode,status}) as one human-readable timeline * row: `[思路] …` / `[命令] (exit N)` / `[回答] …`. The Chinese label comes * from riff's already-localized `title` when present (i18n follows riff); we only * fall back to a kind→label table when it is absent. Colour follows the kind * (failed command / error → red, completed command → green, reasoning/usage dim). */ private emitDisplay; private extractAttachments; private basename; private createTask; private followUp; /** * Prepend the configured system prompt to the user prompt. * The riff API has no separate system-prompt field (only userPrompt), so we * fold the system prompt into the prompt text. config.systemPrompt takes * precedence over the built-in DEFAULT_RIFF_SYSTEM_PROMPT. The result is * wrapped in a block so the agent can distinguish it from the user * message. NOTE: setup commands (botmux install) are NOT injected here — * they are sent to the riff API via config.setupCommands for reliability. */ private injectSystemPrompt; /** * Build the env object for the riff sandbox. Precedence (highest wins): * 1. config.env (explicit per-bot riff config) * 2. per-bot env from bots.json `env` (merged by the worker into config.env) * Returns a clean Record with empty values dropped. */ private buildEnv; private uploadAndCreate; private readFileAsBlob; /** * Post-await adoption gate for a freshly created/followed-up task id. * - closing(/close 竞态窗口):这个 late task 已经没有会话可服务——立即取消 * (有界+一次重试),绝不 stream/登记,防远端 orphan; * - killed / shutdownDetaching(detach):登记 id 让 daemon 持久化血缘, * 但不 stream(任务合法续跑,重启后 follow-up 接上); * - 正常:登记 + 由调用方启动 stream。 */ private adoptLateTask; /** Repos come exclusively from config.repos (worker-derived from the session * workingDir). The old defaultRepo/defaultBranch bot config was removed — * a stale bots.json value would silently shadow the workingDir derivation * with no UI left to clear it. */ private buildRepos; private cancelTask; private cancelTaskWithRetry; private streamTask; /** * Fire a task's completion exactly once — the turn boundary + final-output * fetch. Called from BOTH the `done` SSE event AND an `init` replay carrying a * terminal status (the task finished while a prior connection was dead and its * `done` was lost with the closed stream). Idempotency & staleness — per TASK, * not per backend: streams can deliver done more than once (observed ~500ms * apart live), and by the time a duplicate (or a reconnect's init replay) * arrives, a queued follow-up may already be running as the NEXT task (write() * reset the global taskDone). A plain boolean guard would re-fire the boundary * mid-way through that next task and falsely mark it done, so gate on: * 1) the completion must belong to the CURRENT task (stale streams no-op) * 2) each task fires the boundary at most once (completedTaskIds) */ private completeTask; private handleSseEvent; /** * Track the best sandbox URL for the "Web 终端" button. * Preference: directAccessUrl (the AIO sandbox terminal, directly openable) * over accessUrl (riff frontend page — hardcoded to the production domain * even on BOE deployments, so its origin is rewritten to the configured * baseUrl). A direct URL is never downgraded back to a frontend URL within * the same task. Returns true when the current URL changed. */ private updateAccessUrl; /** Rewrite a riff frontend URL onto the configured baseUrl origin (BOE vs prod). */ private rewriteToBaseOrigin; /** One-shot task-detail fetch to pick up directAccessUrl (not present in SSE events). */ private fetchDirectAccessUrl; private emitError; private fetchAndEmitOutput; } //# sourceMappingURL=riff-backend.d.ts.map