/** * L2 tmux watchdog implementation of SessionSpawner. * * Spawns an independent tmux session running the configured CLI runtime * (default: `opencode`), polls for task evidence (evidence/summary.md), and * supports cancellation via tmux session kill. * * @see ../session-spawner.ts for the SessionSpawner interface * @see packages/spec/docs/session-per-task-handoff.md §3.3.3 */ import type { TaskHandoff, SessionHandle, SessionResult, SessionSpawner, SandboxMode } from "./session-spawner.js"; /** * Default sandbox container image. Hosted on the private registry * docker.aipper.de (reachable from CN networks), replacing Docker Hub * whose pulls hang/time out under GFW. */ export declare const DEFAULT_SANDBOX_IMAGE = "docker.aipper.de/node:24-bookworm-slim"; /** * Env vars forwarded into the sandbox container. Only keys actually present * in the host environment are passed, one `-e KEY=value` flag at a time. * Never use `--env-file` (full host env leak) and never `--privileged`. */ export declare const SANDBOX_ENV_WHITELIST: readonly ["ZENTAO_BASE_URL", "ZENTAO_ACCOUNT", "ZENTAO_PASSWORD", "PATH", "HF_TOKEN", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"]; /** * Validate a `--sandbox` CLI value. Returns "none" (the default) when the * flag was not supplied. "docker" wraps the spawn in a docker container; * "none" runs bare. "auto" is deprecated and rejected. */ export declare function parseSandboxMode(raw: string | undefined): SandboxMode; /** * Result of probing docker + sandbox image availability. */ export interface ProbeResult { /** True when the daemon is reachable AND the image is usable. */ available: boolean; /** Machine-readable reason when unavailable (e.g. "image pull failed"). */ reason?: string; } /** * Probe the docker sandbox in three stages (GFW-aware): * 1. daemon reachability (`docker version` against the server); * 2. local image presence (`docker image inspect `); * 3. pull attempt when missing (`docker pull `, 30s timeout). * * Any failure degrades gracefully: the function returns * `{ available: false, reason }` instead of throwing, so `--sandbox docker` * can surface a precise reason before the spawn is aborted. */ export declare function probeSandbox(image?: string): ProbeResult; /** * Compatibility wrapper kept for existing consumers/tests: only checks the * daemon + the default image. * @deprecated Use probeSandbox(image) which returns a structured result. */ export declare function isDockerAvailable(): boolean; /** * Result of the sandbox decision. */ export interface SandboxDecision { /** True when spawn() should wrap the CLI command in `docker run`. */ useDocker: boolean; } /** * Decide whether to docker-wrap based on the sandbox mode and the * docker/image probe result: * - none: always bare run (explicit opt-out / default). * - docker: wrap when usable, else throw UserError pointing at --sandbox none * or --sandbox-image (no silent fallback). */ export declare function decideSandbox(mode: SandboxMode, probe: ProbeResult): SandboxDecision; /** * Options for dockerWrapCommand. */ export interface DockerWrapCommandOptions { /** Absolute git repo root, mounted read-write at /workspace. */ gitRoot: string; /** CLI command executed inside the container (e.g. "opencode"). */ cliCommand: string; /** Container image (default: DEFAULT_SANDBOX_IMAGE). */ image: string; /** Container network mode; "none" adds `--network none`. */ network?: "default" | "none"; /** Host env to whitelist from (default: process.env). */ env?: NodeJS.ProcessEnv; /** * Optional absolute path to the CLI binary on the host. When set, the binary * is mounted into the container (e.g. `-v /usr/local/bin/opencode:/usr/local/bin/opencode`) * so the CLI command resolves inside the container. */ cliBinaryPath?: string; } /** * Filter a host PATH value for the container: drop entries under the host * user's home directory (/Users/...) that have no meaning on the server, * keeping standard locations (/bin, /usr/bin, /usr/local/bin, * /opt/homebrew/bin, ...). */ export declare function filterHostPath(pathValue: string): string; /** * Build the full spawn command that runs the CLI inside a docker container * while keeping the L2 tmux watchdog semantics (docker run is a foreground * command inside the tmux pane): * * cd && docker run --rm -i \ * -v :/workspace -w /workspace \ * -e KEY=VAL ... (whitelisted host env, one --env flag per var) \ * [--network none] \ * bash -lc '; exit' * * All dynamic values are single-quote escaped (shq) so the result is safe * inside the double-quoted `tmux send-keys ""` context AND inside the * pane shell; the inner bash -lc gets the cliCommand as one literal word. */ export declare function dockerWrapCommand(opts: DockerWrapCommandOptions): string; /** * Kill stale `aiws-task-*` tmux sessions left over from interrupted * supervisors (e.g. the main process was killed before waitOne() could run * cancel()). A session is considered stale when it is detached AND its pane * is sitting at a shell prompt (the CLI process already exited). Live task * sessions run a CLI process (opencode/node) in the pane, so they are never * touched. * * Returns the names of the killed sessions. */ export declare function cleanupStaleTaskSessions(prefix?: string): string[]; /** * Standalone task-scoped completion guard. It deliberately accepts only a * safe pane id and uses execFileSync argument arrays, so a done marker can * never turn into a host-session or shell injection kill. The script is * passed to a detached Node process because the parent orchestrator may exit * as soon as it has dispatched the task. */ export declare const DONE_SIGNAL_WATCHER_SCRIPT: string; export declare function startDoneSignalWatcher(signalPath: string, paneId: string, cwd: string): void; /** * Extract the executable from a CLI command string. Leading env-assignment * prefixes (`VAR=value`) are skipped so `which` checks the actual command. * Commands with arguments (e.g. `node /opt/wrapper.mjs`) are allowed: * availability detection checks only the binary, while the full command * string (prefixes included) is run inside the session. */ export declare function cliBinary(cmd: string): string; /** * Resolve the absolute path to a CLI binary on the host. Used by the docker * sandbox to mount the binary into the container (H1). Returns `undefined` * when the binary cannot be resolved (custom image may pre-install it). */ export declare function resolveCliBinaryPath(cmd: string): string | undefined; /** * Default CLI runtime for spawned tmux task sessions. * * Pi-first: when the `pi` binary is on PATH, spawned sub-agent sessions run * Pi (Pi 启动 Pi). Falls back to `opencode` for legacy OpenCode setups; * callers may still override explicitly via cliCommand/opencodeCmd. */ export declare function detectDefaultCliCommand(): string; /** * Provider 失败指纹(tmux-subagent-runtime change,task-2)。 * * waitOne() 用它将 pane 文本归类为明确的 provider 失败。真实观测(change * dsh-routing-effectiveness-eval evidence/task-13-deepseek-dispatch-result): * - `Connection error`(provider 连接失败,启动约 6s / 8min 后各出现一次) * - `Stream ended without finish_reason`(stream 无 finish reason 终止) * 外加常见 quota/rate/网络/5xx/429 文本。命中后需同一指纹连续达到 * errorThresholdRounds 轮才判 failed(见 waitOne),避免 pane 历史残留文本误报。 */ export declare const PROVIDER_ERROR_RE: RegExp; /** * Harness 失败指纹:CLI 进程提前退出/崩溃/未被启动的稳定痕迹。 * * 目标 pane/session 消失(`; exit` 后 tmux 自动销毁会话)由 target-gone * 检测兜底;此处只识别进程仍在 pane 中但 harness 已死的文本证据(split-pane * 模式 pane 不销毁、或 CLI 崩溃后包裹 shell 仍存活)。 */ export declare const HARNESS_EXIT_RE: RegExp; /** 一次 worker pane 错误判定的稳定信号。 */ export interface WorkerErrorSignal { /** 稳定分类:provider(上游)或 harness(CLI/外壳提前退出)。 */ kind: "provider" | "harness"; /** 稳定指纹:同一错误重复出现时返回相同值,用于连续轮数计数。 */ fingerprint: string; /** 命中的原始文本片段(写入 SessionResult.error 供审计)。 */ matched: string; } /** * 把 pane 文本归类为 worker 失败信号。纯函数、可测: * - 命中 provider 指纹 → `{ kind: "provider", ... }`(优先) * - 否则命中 harness 指纹 → `{ kind: "harness", ... }` * - 无命中 → undefined(worker 仍在工作 / 正常文本) * * fingerprint 是稳定字符串(分类 + 归一化匹配片段),相同错误重复出现时保持 * 不变——waitOne() 据此实现"同一指纹连续 N 轮"的快速收敛判定(设计决策 3)。 */ export declare function classifyWorkerError(text: string): WorkerErrorSignal | undefined; /** * Glyphs that indicate the CLI TUI is live in the captured tmux pane, so the * first handoff message lands on the CLI input prompt instead of the shell * prompt (see {@link TmuxSessionSpawner#waitForCliReady} / readyMode="tui"). * * - Classic box-drawing set (`─│╭╮╰╯┌┐└┘├┤┬┴┼`): matched by pi 0.84.1 and * other traditional TUI runtimes. * - OpenCode TUI set (observed in real remote end-to-end sessions): * `┃` (U+2503 heavy vertical), `╹` (U+2579 heavy up), `▀` (U+2580 upper * half block), `▄` (U+2584 lower half block), `⬝` (U+2B1D black very * small square). Without these, readiness was never detected inside the * 15s window for opencode and the call degraded to the 2s grace wait. */ export declare const TUI_READY_GLYPHS: RegExp; export interface TmuxSessionSpawnerOptions { /** * CLI command (name, path, or name + args via a wrapper) run inside the * spawned tmux session. Default: "opencode". * * Runtime-neutral: any CLI runtime that reads handoff.md and writes * evidence/summary.md + done.signal can be used here. */ cliCommand?: string; /** * Backwards-compatible alias for cliCommand (legacy OpenCode entry point). * `cliCommand` takes precedence when both are provided. */ opencodeCmd?: string; /** * cancel() 优雅关闭等待窗(pi-tmux-waitone-reliability L1-b):发送 C-c 后 * 等待该时长(ms)让 CLI 走正常 exit 序列,再 kill 兜底。默认 250。 */ cancelGraceMs?: number; /** * How to detect that the CLI runtime is ready before typing the first * handoff message: * - "tui": poll the tmux pane for TUI box-drawing glyphs (OpenCode / * interactive TUI runtimes). Default. * - "none": skip TUI detection and use a fixed grace wait (non-TUI * runtimes such as a plain Pi wrapper). */ readyMode?: "tui" | "none"; /** * Docker sandbox mode (D1.1). Default "none": local bare run without a * docker wrapper. "docker" wraps the CLI command in `docker run` when the * daemon AND the container image are usable (image probed via * probeSandbox, pulled when missing) and throws UserError otherwise. */ sandbox?: SandboxMode; /** * Container image for the docker sandbox (default: * docker.aipper.de/node:24-bookworm-slim — private registry reachable from * CN networks, replacing Docker Hub). */ sandboxImage?: string; /** * Container network mode (default "default"). "none" adds `--network * none` to the docker run command. */ sandboxNetwork?: "default" | "none"; /** * Split-pane mode (default true): when the spawner process runs inside a * tmux session (TMUX env present), create task panes in the CURRENT tmux * session instead of detached `aiws-task-` sessions. The first task is * split to the right (`split-window -h` by default); later tasks split the * AIWS-owned right pane vertically to form a stack. Every created pane is * identified by its tmux pane_id (`%N`) and stored on the SessionHandle. * * Set `split: false` for the explicit compatibility fallback to detached * task sessions. When the spawner is NOT inside tmux (cron / nohup / ssh * without tmux), split mode safely falls back to that detached path. */ split?: boolean; /** * Orientation for the first split in split mode: "h" = left/right panes * (default), "v" = top/bottom panes. Once a right stack exists, every * subsequent task uses "v" so subagents remain vertically stacked. */ splitLayout?: "h" | "v"; /** * Worker 级 guardian 恢复预算(design 决策 4,task-3):spawn() 为 subagent * 目标拉起 `aiws pi watch --detect-stall` watchdog 时,作为 `--max-recoveries` * 传入(默认 3)。0 表示不自动恢复(异常快速落盘)。 * 仅作用于 spawner 自己拉起的 guardian;显式 watchdog 参数/配置(`pi watch` * 的 autoRecover.maxRecoveries 或 --max-recoveries 覆盖)不受影响。 */ guardianMaxRecoveries?: number; } /** spawner 拉起的 guardian watchdog 默认 worker 级恢复预算(design 决策 4:快速失败收敛)。 */ export declare const DEFAULT_GUARDIAN_MAX_RECOVERIES = 3; /** * 解析 guardian 恢复预算:合法非负整数采用(截断取整),非法/缺省回退默认 3。 * 纯函数、可测;风格对齐 resolveAutoRecoverConfig 的宽松解析。 */ export declare function resolveGuardianMaxRecoveries(raw: number | undefined): number; /** * L2 tmux watchdog implementation. * * Creates a detached tmux session running the configured CLI runtime * (default `opencode`) in the task's git root directory. The caller then * polls for evidence/summary.md to appear with non-placeholder content. */ export declare class TmuxSessionSpawner implements SessionSpawner { private cliCommand; private readyMode; private sandbox; private sandboxImage; private sandboxNetwork; private split; private splitLayout; /** Whether split was explicitly requested (keeps default fallback quiet). */ private splitOptionProvided; /** tmux session that owns the current window and its right stack. */ private hostSessionName?; /** Current window id (for window-local options and metadata matching). */ private hostWindowId?; /** Pane ids created by this spawner; only these panes may be cleaned up. */ private ownedPaneIds; private paneHostSessions; /** Worker 级 guardian 恢复预算(spawner 拉起 pi watch 时的 --max-recoveries)。 */ private guardianMaxRecoveries; /** cancel() C-c 后等待窗(pi-tmux-waitone-reliability L1-b)。 */ private cancelGraceMs; constructor(options?: TmuxSessionSpawnerOptions); /** tmux targets accepted by shell commands in this module. */ private isSafeSessionName; private isSafePaneId; /** Forget a pane owned by this spawner without touching tmux. */ private releaseOwnedPane; private isSafeWindowId; /** * Resolve the current tmux session/window. Both values are required: a * session name alone is not enough to prove that a pane belongs to this * user's current window. */ private resolveHostContext; /** * Discover the newest AIWS right-stack pane from tmux itself. This is * intentionally not backed by instance memory: a new spawner process must * be able to continue an existing workspace/window stack safely. */ private findRightStackPane; /** Record a pane only after tmux returned a validated pane id. */ private claimPane; /** Write all pane metadata before exposing the pane as an AIWS stack member. */ private configureSplitPane; /** Tmux target for operations: pane_id (split mode) or session name. */ private tmuxTarget; /** * Validate that required CLI tools are available. * Throws UserError if tmux or the configured CLI binary is not found. */ private validateTools; /** * Spawn a new tmux session for the given task. * * Steps: * 1. Validate that tmux and the CLI command are available. * 2. Create a new detached tmux session named `aiws-task-`. * 3. Send the command `cd && ` to the session. * * If the tmux session already exists, it will be killed and recreated. */ spawn(handoff: TaskHandoff): Promise; /** * Guardian 兜底:为 subagent 目标拉起 `aiws pi watch --detect-stall` watchdog。 * detached + unref,目标消失后 watchdog 自行退出(--max-idle-rounds)。 * maxRecoveries 为 worker 级恢复预算(design 决策 4,默认 3),作为 * `--max-recoveries` 显式传给 pi watch;显式 watchdog 参数/配置不受影响。 * 失败仅 console.warn,不影响 spawn 流程。 */ private guardTarget; /** * Backend/provider-specific readiness detection (readyMode="tui"): poll the * tmux pane until the CLI TUI appears (box-drawing glyphs), so the first * handoff message lands on the CLI input prompt, not the shell prompt. * * Returns false if the TUI was not detected within the readiness window; * callers then fall back to a grace wait before sending the message. */ private waitForCliReady; /** * Wait for a spawned session to complete. * * Polls every 5 seconds. 完成优先(每轮固定顺序,设计决策 2): * 1. evidence/done.signal 存在(authoritative 完成信号)→ completed * 2. 否则 evidence/summary.md 存在且无占位文本 → completed(fallback) * 3. 否则目标 pane/session 连续消失 targetGoneRounds 轮 → failed * (错误含 "worker exited without completion evidence",设计决策 3) * 4. 否则 provider/harness 错误指纹连续 errorThresholdRounds 轮 → failed * (快速收敛,不拖到总 timeout) * 5. 否则 pane 无输出 idleRounds 轮 → 恢复消息;恢复 recoveryLimit 次无效 * → stalled * 6. 总 timeout 到期 → cancelled with "timeout" error * * 完成检查先于失败检查:worker 已完成但 pane 仍保留历史错误文本时不会误判。 * errorThresholdRounds / targetGoneRounds 可由 opts 注入(测试),默认均短于 * 总 timeout。 * * On completion (or cancellation/failure), the tmux session is killed. * * @param handle - Session handle from spawn(). * @param timeoutMs - Timeout in milliseconds (default: 1_800_000 = 30 min). */ waitOne(handle: SessionHandle, timeoutMs?: number, opts?: { idleRounds?: number; recoveryLimit?: number; paneText?: (target: string) => string; /** provider/harness 错误指纹需连续命中多少轮才判 failed(默认 4 ≈ 20s @ 5s poll)。 */ errorThresholdRounds?: number; /** provider transient error recovery budget (default 0 = fail fast). */ providerRecoveryLimit?: number; /** cooldown after a provider signal before sending recovery (ms). */ providerRecoveryCooldownMs?: number; /** 目标 pane/session 连续消失多少轮且无完成证据时判 failed(默认 2 ≈ 10s @ 5s poll)。 */ targetGoneRounds?: number; /** * 活跃性证据探针(pi-tmux-waitone-reliability L1-a):返回 worker 侧证据 * 文件(如 pi session JSONL)的 mtime 与行数;null 表示无证据源(向后兼容 * 纯 pane 文本判定)。idle 判定即将触发恢复/stalled 时 recheck 一次:任 * 一字段较上次变化 → 判定为活跃,复位 idleRounds,避免『长时间思考/长 * 工具执行中无 pane 输出』被误判 stalled(task-14 式误伤)。 */ activityProbe?: (target: string) => { mtimeMs: number; lines: number; } | null; }): Promise; /** * Wait for a single spawned session to complete. * Thin wrapper over waitOne() for the SessionSpawner interface. */ wait(handle: SessionHandle, timeoutMs?: number): Promise; /** * Wait for multiple sessions concurrently, honouring a parallelism cap. * * Runs waitOne() per handle inside a promise pool: at most `parallel` * sessions poll at the same time; whenever one finishes, a queued session * takes its slot. Each session has an independent timeout — one timing out * never blocks the others. waitOne() never rejects, so this method only * resolves with a result per handle.id. */ waitAll(handles: SessionHandle[], options?: { parallel?: number; timeoutMs?: number; onProgress?: (result: SessionResult, doneCount: number) => void; }): Promise>; /** * 组装续跑提示(pi-tmux-resume-continuation L2-a):终态非 completed 时调用, * 携带保留 session 的恢复信息(tmux session 名 + 原 launch 命令 + pi session 目录)。 */ private preserveHint; /** * Cancel a running session by killing its tmux session. * * Idempotent — if the session does not exist, the error is silently * ignored. Runtime-neutral handles without a tmuxSessionName are a no-op. * Session names that are not shell-safe are refused (defense in depth). */ cancel(handle: SessionHandle, opts?: { graceful?: boolean; killAfterGrace?: boolean; }): Promise; } /** * Result of detecting the appropriate session spawner strategy. */ export interface DetectResult { /** Detected strategy: "tmux" if both tmux and the CLI binary are available. */ strategy: "tmux" | "l1"; /** The spawner instance, only present when strategy is "tmux". */ spawner?: TmuxSessionSpawner; } /** * Auto-detect the best available session spawner strategy. * * Checks whether `tmux` and the configured CLI binary are available on the * system PATH. If both are found, returns a TmuxSessionSpawner with strategy * "tmux". Otherwise returns strategy "l1" (task() sub-agent degradation). * * @param options - Optional configuration, e.g. custom cliCommand/opencodeCmd. */ export declare function detectSessionSpawner(options?: TmuxSessionSpawnerOptions): DetectResult; /** * Build the first message typed into a spawned tmux session (send-keys). * * A custom `handoff.handoffMessage` is returned verbatim — callers fully * control the wording. Otherwise the default message keeps the legacy first * sentence (`请读取 /handoff.md 并执行 task-`) and appends * the artifact contract the worker must fulfil so the message agrees with the * ws-delegate contract and the waitOne() completion protocol: * 1. evidence/summary.md (execution summary) * 2. evidence/done.signal (authoritative completion marker) * 3. .aiws/changes//handoff-evidence.md (ws-delegate evidence) */ export declare function buildDispatchMessage(handoff: TaskHandoff): string;