/**
 * @license
 * Copyright 2026 HopCode Team
 * SPDX-License-Identifier: Apache-2.0
 */
import { type BridgeEvent, type SubscribeOptions } from './eventBus.js';
import type { CancelNotification, PromptRequest, PromptResponse, RequestPermissionResponse, SetSessionModelRequest, SetSessionModelResponse, Stream } from '@agentclientprotocol/sdk';
/**
 * Stage 1 HTTP→ACP bridge.
 *
 * Per #3803 §02 (architectural revision) and design §08 (Roadmap, Stage 1):
 *   - **1 daemon = 1 workspace**: every bridge instance is bound to a
 *     single canonical workspace path at construction
 *     (`BridgeOptions.boundWorkspace`). All `spawnOrAttach` calls must
 *     target that workspace; cross-workspace requests throw
 *     `WorkspaceMismatchError`. Multi-workspace deployments use multiple
 *     daemon processes (one per workspace, supervised externally).
 *   - One `hopcode --acp` child total; multiple sessions multiplex onto it
 *     via `connection.newSession()` (the agent's native
 *     `sessions: Map<string, Session>` — see `acp-integration/acpAgent.ts:194`).
 *     Sessions share the child's process / OAuth state / `FileReadCache` /
 *     hierarchy-memory parse.
 *   - HTTP request bodies are forwarded as ACP NDJSON over the child's stdin.
 *   - Child stdout NDJSON notifications publish onto each session's
 *     `EventBus`; HTTP SSE subscribers (`GET /session/:id/events`) drain
 *     it. Cross-client fan-out + `Last-Event-ID` reconnect supported.
 *   - Multi-client requests against the same session serialize through this
 *     bridge (FIFO; honors ACP's "one active prompt per session" invariant).
 *     Different sessions on the same channel can prompt concurrently —
 *     the ACP layer demultiplexes by sessionId.
 *
 * Stage 2 replaces the spawn step with an in-process call into core's
 * ACP-equivalent API. The `HttpAcpBridge` interface stays the same so HTTP
 * route handlers don't need to change.
 */
export interface BridgeSpawnRequest {
    /** Absolute path to the workspace root the child inherits as cwd. */
    workspaceCwd: string;
    /** Optional explicit model service id; falls back to settings default. */
    modelServiceId?: string;
}
export interface BridgeSession {
    sessionId: string;
    workspaceCwd: string;
    /** True if this attach reused an existing session under `sessionScope: 'single'`. */
    attached: boolean;
}
/** Sparse summary used by `GET /workspace/:id/sessions`. */
export interface BridgeSessionSummary {
    sessionId: string;
    workspaceCwd: string;
}
export interface HttpAcpBridge {
    /**
     * Create a new session, or — under `sessionScope: 'single'` — attach to an
     * existing session for the same workspace.
     */
    spawnOrAttach(req: BridgeSpawnRequest): Promise<BridgeSession>;
    /**
     * Forward a prompt to the agent. Concurrent prompts against the same
     * session FIFO-serialize through a per-session queue (ACP guarantees
     * "one active prompt per session"). Throws `SessionNotFoundError` when
     * the id is unknown.
     *
     * Optional `signal` — abort cancels the in-flight prompt by sending an
     * ACP `cancel` notification to the agent (which causes the agent to
     * resolve its `prompt()` with `stopReason: 'cancelled'`). Used by the
     * SSE route to propagate `req.on('close')` so a disconnected HTTP
     * client unblocks the per-session FIFO instead of poisoning it.
     */
    sendPrompt(sessionId: string, req: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>;
    /**
     * Cancel the in-flight prompt on the session. ACP-side this is a
     * notification, not a request — the agent acknowledges by resolving the
     * active `prompt()` with a `cancelled` stop reason. Throws
     * `SessionNotFoundError` when the id is unknown.
     */
    cancelSession(sessionId: string, req?: CancelNotification): Promise<void>;
    /**
     * Subscribe to the session's event stream. Returns an AsyncIterable that
     * yields published events; supports `Last-Event-ID` reconnect through
     * `opts.lastEventId`. Throws `SessionNotFoundError` when the id is
     * unknown.
     */
    subscribeEvents(sessionId: string, opts?: SubscribeOptions): AsyncIterable<BridgeEvent>;
    /**
     * Cast a vote on a pending `permission_request` (first-responder wins).
     * Returns true when the vote was accepted, false when the requestId is
     * unknown — either never existed or already resolved by another client.
     */
    respondToPermission(requestId: string, response: RequestPermissionResponse): boolean;
    /**
     * List all live sessions whose canonical workspace path matches the
     * supplied cwd. Empty array (not throw) when no sessions exist —
     * a session-picker UI shouldn't 404 just because the workspace is idle.
     */
    listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[];
    /**
     * Switch the active model service for a session. Forwards through ACP's
     * (currently unstable) `unstable_setSessionModel` and broadcasts a
     * `model_switched` event so cross-client UIs reflect the change.
     * Throws `SessionNotFoundError` for unknown ids.
     */
    setSessionModel(sessionId: string, req: SetSessionModelRequest): Promise<SetSessionModelResponse>;
    /**
     * Kill the agent process for the session and remove it from the maps.
     * Used by the HTTP route layer to reap orphans created when a client
     * disconnects mid-spawn (the server-side child kept being created
     * even though no caller will ever know the sessionId). Idempotent —
     * unknown / already-dead sessions are no-ops.
     */
    /**
     * Tear down a session — kill the child, drop from maps, publish
     * `session_died`. Idempotent on already-dead sessions.
     *
     * `requireZeroAttaches: true` makes the call a no-op when at
     * least one other client has called `spawnOrAttach` for this
     * entry and got `attached: true`. Used by the disconnect-reaper
     * in `server.ts` so a fast reattach by client B doesn't lose its
     * session to client A's "I disconnected mid-spawn" cleanup.
     */
    killSession(sessionId: string, opts?: {
        requireZeroAttaches?: boolean;
    }): Promise<void>;
    /**
     * Roll back a prior attach: decrement `attachCount` and, if the
     * session now has neither attaching clients (`attachCount === 0`)
     * nor live SSE subscribers, reap it.
     *
     * Called from the server's `POST /session` route handler when the
     * attaching client disconnected before the response could be
     * written (`!res.writable && session.attached === true`). Without
     * this, the BQ9tV `attachCount`-based race guard would persist
     * monotonically: once any attach bumped the counter, the
     * spawn-owner's disconnect-reaper would never run again — even if
     * the attacher themselves disconnected (tanzhenxin issue 2). This
     * is the symmetric "I bumped, but my socket died so the bump is
     * fictitious" cleanup.
     */
    detachClient(sessionId: string): Promise<void>;
    /** Test/inspection hook: number of live sessions. */
    readonly sessionCount: number;
    /** Test/inspection hook: number of permission requests awaiting a vote. */
    readonly pendingPermissionCount: number;
    /**
     * Bd1y6: synchronous force-kill of every live channel. Called by
     * the runQwenServe SIGINT/SIGTERM handler when the operator
     * double-taps — the second signal can't afford the async
     * `shutdown()` Promise that the first signal is still in the
     * middle of. Without this, `process.exit(1)` would leave agent
     * children running after the daemon vanishes.
     */
    killAllSync(): void;
    /** Close all live child processes; called on daemon shutdown. */
    shutdown(): Promise<void>;
}
/**
 * Routes catch this to map to HTTP 404. Distinct from generic Error so the
 * route layer doesn't have to brittle-match on message text.
 */
export declare class SessionNotFoundError extends Error {
    readonly sessionId: string;
    constructor(sessionId: string, extra?: string);
}
/**
 * Thrown by `spawnOrAttach` when a fresh-spawn would push `sessionCount`
 * past `BridgeOptions.maxSessions`. The HTTP route maps this to 503
 * with a `Retry-After` hint. Attaches (same workspace under `single`
 * scope) never trip this — only NEW children. Distinct error type so
 * routes can branch without text-matching.
 */
export declare class SessionLimitExceededError extends Error {
    readonly limit: number;
    constructor(limit: number);
}
/**
 * Thrown by `spawnOrAttach` when the requested `workspaceCwd` doesn't
 * canonicalize to the daemon's bound workspace. Per #3803 §02 every
 * bridge instance is bound to exactly one workspace; cross-workspace
 * requests are rejected at the daemon boundary. The server route
 * translates this to a 400 response with `code: 'workspace_mismatch'`
 * and both paths in the body so clients can fall through to spawning
 * their own daemon / routing to a different one via an orchestrator.
 */
export declare class WorkspaceMismatchError extends Error {
    readonly bound: string;
    readonly requested: string;
    constructor(bound: string, requested: string);
}
/**
 * PATH_MAX on Linux is 4096; macOS / BSD is 1024. We use the Linux
 * value as a generous ceiling — anything bigger is either a
 * malformed client request (memory amplification attack against the
 * 400 / stderr / error-message echo paths) or a synthetic test
 * input. The route's POST /session pre-check rejects bodies past
 * this; `WorkspaceMismatchError` truncates for any caller that
 * skips the pre-check.
 */
export declare const MAX_WORKSPACE_PATH_LENGTH = 4096;
/**
 * One ACP NDJSON channel to a single agent. Tests inject a fake by replacing
 * the channel factory; production uses `defaultSpawnChannelFactory`.
 */
export interface AcpChannel {
    stream: Stream;
    /** Best-effort terminate; resolves when teardown is complete. */
    kill(): Promise<void>;
    /**
     * Bd1y6: synchronous force-kill for the second-signal force-exit
     * path. Fires SIGKILL on the underlying child (or equivalent
     * in-process tear-down) and returns immediately — no Promise. The
     * daemon's signal handler can call this before `process.exit(1)`
     * so that double-Ctrl+C doesn't leave the agent child running
     * after the daemon vanishes.
     */
    killSync(): void;
    /**
     * Resolves when the channel has terminated for any reason — planned
     * (`kill()` called) OR unexpected (child process crashed, stream closed).
     * The bridge subscribes to this so a SessionEntry whose underlying
     * channel dies between requests is removed from `byId` /
     * `defaultEntry` instead of lingering as a stuck session.
     *
     * Resolves to `{ exitCode, signalCode }` when the spawn factory can
     * capture them (the standard `child.on('exit', code, signal)` path),
     * or `undefined` when termination didn't go through the OS exit path
     * (programmatic kill via the in-process channel, channel-factory
     * error path, etc.). The bridge threads this through the
     * `session_died` event so an operator triaging a crash doesn't need
     * to grep stderr for the pid (BX9_P).
     */
    exited: Promise<AcpChannelExitInfo | undefined>;
}
export interface AcpChannelExitInfo {
    exitCode: number | null;
    signalCode: NodeJS.Signals | null;
}
export type ChannelFactory = (workspaceCwd: string) => Promise<AcpChannel>;
export interface BridgeOptions {
    /**
     * §03 decision §1. `single` shares one session per workspace across HTTP
     * clients (live-collaboration default); `thread` gives each `spawnOrAttach`
     * call its own session for strict isolation.
     *
     * FIXME(stage-1.5, chiga0 must-have 1):
     * Today this is a daemon-wide setting — clients can't override per
     * request. A VSCode extension that wants a private session per
     * window can't ask for it against a daemon configured for `single`.
     * Stage 1.5 should accept `sessionScope` on the `POST /session`
     * body, treating the daemon-wide value as a hint not a hard rule.
     * Reference:
     * https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427875644
     */
    sessionScope?: 'single' | 'thread';
    /** Channel factory; defaults to spawning `hopcode --acp` as a child process. */
    channelFactory?: ChannelFactory;
    /** How long to wait for the child's `initialize` reply before giving up. */
    initializeTimeoutMs?: number;
    /**
     * Cap on concurrent live sessions. `spawnOrAttach` calls that would
     * cross this throw `SessionLimitExceededError`; attaches to an
     * existing session (same workspace under `single` scope) are not
     * counted. `0` / `Infinity` disable the cap. Defaults to 20 — see
     * `ServeOptions.maxSessions` for the rationale.
     */
    maxSessions?: number;
    /**
     * Bd1yh: per-`requestPermission` wall clock. After this many ms with
     * no client vote, the agent's permission promise resolves as
     * cancelled — the per-session FIFO can drain instead of poisoning
     * forever on a missing SSE subscriber. Defaults to 5 minutes.
     * `0` / `Infinity` / non-finite disable the timeout (matches
     * legacy behavior, NOT recommended).
     */
    permissionResponseTimeoutMs?: number;
    /**
     * Bd1z5: per-session cap on pending permissions in flight. New
     * `requestPermission` calls past this cap resolve as cancelled with
     * a stderr warning. Defaults to 64. `0` / `Infinity` disable the
     * cap.
     */
    maxPendingPermissionsPerSession?: number;
    /**
     * Absolute, **already-canonical** path this daemon is bound to (per
     * #3803 §02: 1 daemon = 1 workspace). `spawnOrAttach` calls whose
     * `workspaceCwd` doesn't canonicalize to this same value throw
     * `WorkspaceMismatchError` (route → 400 with code `workspace_mismatch`).
     *
     * **Caller contract**: pass the result of
     * `canonicalizeWorkspace(path)`. `runQwenServe` does this at boot
     * and threads the same canonical value into the bridge AND
     * `createServeApp` (via `deps.boundWorkspace`) so all three —
     * `/capabilities.workspaceCwd`, the `POST /session` cwd fallback,
     * and this bridge's mismatch check — share one canonical form. The
     * constructor only checks `path.isAbsolute`; it does NOT
     * re-canonicalize (a redundant `realpathSync.native` could
     * theoretically diverge from the runQwenServe canonicalize on
     * NFS-transient / mid-rename filesystems, landing the bridge with
     * one canonical form while `/capabilities` advertises another).
     * Direct embeds / tests calling `createHttpAcpBridge` themselves
     * MUST canonicalize before passing.
     */
    boundWorkspace: string;
}
/**
 * BkwQI: thrown by `bridge.respondToPermission` when the voter's
 * `optionId` isn't in the set of options the agent originally
 * offered. Server route catches this and returns 400 (distinct from
 * 404 unknown-requestId).
 */
export declare class InvalidPermissionOptionError extends Error {
    readonly requestId: string;
    readonly optionId: string;
    constructor(requestId: string, optionId: string);
}
export declare function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge;
/**
 * Canonicalize a workspace path so the boot-time bound path and every
 * request's `workspaceCwd` collapse to the same key. `path.resolve`
 * alone normalizes `..` and `.` segments and absolutizes, but on
 * case-insensitive filesystems (macOS APFS, Windows NTFS) `/Work/A`
 * and `/work/a` are the same directory yet `resolve` returns them
 * verbatim — without normalization the `boundWorkspace` check would
 * reject every request that spelled the path with different casing
 * and `sessionScope: 'single'` re-attach would silently degrade to
 * "one per spelling".
 *
 * `realpathSync.native` (when the path exists) walks symlinks and returns
 * the on-disk casing; this matches what `config.ts` / `settings.ts` /
 * `sandbox.ts` use for their own workspace resolution. When the path
 * doesn't exist (test fixtures, ahead-of-mkdir flows) we fall back to
 * the resolved-but-uncanonicalized form rather than throwing — the
 * downstream `spawn({cwd})` will fail with a useful ENOENT if the
 * workspace truly doesn't exist.
 *
 * NOTE: This is a **cross-module contract** (BX9_q) — `config.ts`,
 * `settings.ts`, `sandbox.ts`, and this file all need to canonicalize
 * the same way for the bound-workspace check + `sessionScope:
 * 'single'` re-attach to work correctly across paths. The contract:
 * use `realpathSync.native` on the resolved absolute path; fall back
 * to `path.resolve` only when the path doesn't exist yet. If a future
 * change breaks this alignment (e.g. one module starts lowercasing on
 * Windows but this one doesn't), the canonicalized request path
 * won't match the canonicalized bound path → every request returns
 * `workspace_mismatch` even though the human-readable paths look
 * equivalent. There's no test that pins the alignment; the
 * integration suite would catch a divergence only if it tested the
 * specific casing / symlink path the affected module changed.
 *
 * Stage 2 in-process (#3803 §10) collapses the bridge into core,
 * removing the bridge-side path resolution entirely. Stage 1.5
 * `@hoptrendy/acp-bridge` lift (chiga0 finding 1) is the natural
 * place to extract a shared `canonicalizeWorkspace` primitive that
 * all four modules consume — the lowest-common-denominator
 * extraction is fine THERE because the package boundary forces the
 * call sites to converge. Until then, *any* change to how those
 * modules resolve workspace paths needs a matching change here.
 */
export declare function canonicalizeWorkspace(p: string): string;
/**
 * Default channel factory: spawn the current Node executable running this
 * CLI's entry script in `--acp` mode. `process.argv[1]` resolves to the hopcode
 * entry script when launched via the `hopcode` bin shim.
 *
 * Note on `cwd`: CodeQL flags the `workspaceCwd` flow into `spawn({cwd})`
 * as an "uncontrolled data used in path expression" finding. That's the
 * Stage 1 trust model speaking — the caller (a token-authenticated HTTP
 * client) is treated as an extension of the operator. The agent already
 * runs as the same UID with shell-tool access, so restricting the spawn
 * cwd to a sandbox here would be theatre. Stage 4+ remote-sandbox swaps
 * this factory for a sandbox-aware variant; see issue #3803 §11.
 */
export declare const defaultSpawnChannelFactory: ChannelFactory;
