import { isAutoApproveEnabled, isAutoApproveEffective, isTimeoutDefaultAllow, announceTimeoutDefaultIfAllow, stopTimeoutDefaultReminder } from './approval/policy.js'; import { humanizeFingerprint, inputFingerprint } from './approval/fingerprint.js'; import type { RunContext, Decision, ApprovalNotification, ApprovalNotifier, ResolvedInfo, ResolutionCause, ResolutionEvent, ResolutionListener, ApprovalBusOptions } from './approval/types.js'; import { threadKey } from './approval/types.js'; export type { RunContext, Decision, ApprovalNotification, ApprovalNotifier, ResolvedInfo, ResolutionCause, ResolutionEvent, ResolutionListener, ApprovalBusOptions, }; export { threadKey }; export { isAutoApproveEnabled, isAutoApproveEffective, isTimeoutDefaultAllow, announceTimeoutDefaultIfAllow, stopTimeoutDefaultReminder, }; export { humanizeFingerprint, inputFingerprint }; export declare class ApprovalBus { private server; private socketPath; /** Public so renderers (approval-router plain-text prompt, future card * variants) can show the actual budget instead of hardcoded "5 分钟". */ readonly approvalTimeoutMs: number; readonly autoAllowGraceMs: number; private runContexts; private pendingById; private pendingByThread; private connections; private notifier; private resolutionListener; /** compositeKey → set of `${toolName}::${prefix}` keys the user has marked * as auto-allow within this conversation. Cleared by clearAutoAllowForThread * (called from session.resetConversation) and on stop(). Key format is * `platform:channelId:threadId` to isolate across platforms/channels. */ private autoAllowByThread; /** Per-thread opt-out for readonly auto-allow. Default: enabled (= empty * set means everyone is opted in). Toggled via /approval readonly on|off. * In-memory only — survives no restarts; restart returns to default-on, * matching the env-var default. */ private readonlyAutoAllowDisabledThreads; /** * Lifetime counters surfaced via {@link getMetrics}. Help ops detect * leaks (pending growing unbounded), spikes (totalRequests rate), and * approval skew (deny:allow ratio). Reset on stop() so the gauge for a * fresh process starts at zero. */ private metricsSnapshot; /** Adapter exposing the minimal ApprovalRpcHost surface to socket-rpc handlers. */ private readonly rpcHost; constructor(opts?: ApprovalBusOptions); /** 注入"通知 IM 推送"的回调。messenger 层启动时调一次。 */ setNotifier(n: ApprovalNotifier | null): void; /** Subscribe to resolution events. Replaces any previous listener. * approval-router uses this to keep its UI cards in sync with bus-side * cancellations (timeout / sidecar disconnect / run terminated). The * user-driven path (button or y/n text) already edits its own card; the * listener still fires there with cause='user' so consumers can dedup. */ setResolutionListener(l: ResolutionListener | null): void; /** 启动 unix socket 服务。返回最终使用的 socket 路径。 */ start(socketPath?: string): Promise; stop(): Promise; registerRun(runId: string, ctx: RunContext): void; /** 进程结束时调。pending 全 deny,runContext 清掉。 */ unregisterRun(runId: string): void; /** Check if there are pending approvals for the given composite key. * `key` must be a composite produced by `threadKey(platform, channelId, * threadId)` — that's what register stores under. A bare threadId will * miss in multi-platform deployments. */ hasPendingFor(key: string): boolean; /** True iff a notifier has been installed (i.e. messenger layer has wired * the bus into IM). Callers that have a fallback path (e.g. the opencode * HTTP adapter) check this before registerSyntheticPending so they can * short-circuit when the bus is dormant — mostly relevant in tests and in * non-IM call paths (web, scheduler). */ hasNotifier(): boolean; /** * 由 messenger.onMessage 拦截层调用。把 thread 队列头部的 pending 用 * 给定决策 resolve 掉。返回被 resolve 的 pending 描述(platform / tool / * fingerprint / 是否处于 auto-allow grace 模式);router 用这些信息发回执。 * 没有 pending 时返回 null。 * * Auto-allow side-effect: a user-initiated deny against a pending that * was running in auto-allow mode revokes the matching rule (the user is * signaling "stop auto-approving this"). Revocation is intentionally * scoped to this user-path so sidecar disconnects / shutdown / run- * terminated denies don't accidentally clear rules the user still wants. */ /** Resolve the FIFO head for the given composite key. `key` must be a * composite produced by `threadKey(platform, channelId, threadId)`. The * text-reply path (cli's tryHandleApprovalReply) is the canonical caller; * button callbacks should use `resolvePendingByReqId` instead. */ resolvePending(key: string, decision: Decision, actorUserId?: string): ResolvedInfo | null; /** * Resolve a specific pending approval identified by reqId. Used by button * callbacks and the dashboard where the UI element carries the exact reqId, * avoiding the FIFO head-of-thread ambiguity of resolvePending(). */ resolvePendingByReqId(reqId: string, decision: Decision, actorUserId?: string): ResolvedInfo | null; /** Cancel a specific request from a non-user lifecycle event. Unlike * resolvePendingByReqId this preserves the cause so card renderers expire * stale buttons instead of treating an abort as a human click. */ cancelPendingByReqId(reqId: string, message: string, cause?: Exclude): boolean; private _resolveTarget; /** Drop every auto-allow rule registered for this thread. Called from * session.resetConversation so `/new` truly returns to "ask every time". * `key` must be a composite from `threadKey(platform, channelId, threadId)`. */ clearAutoAllowForThread(key: string): void; /** Test/diagnostic helper — current rule keys for a thread. */ getAutoAllowKeys(key: string): string[]; /** Whether readonly auto-allow is active for this thread. False if either * the global env flag is off OR the user explicitly opted this thread out. */ isReadonlyAutoAllowEnabled(key: string): boolean; /** Per-thread toggle. Returns the new effective state (after applying the * global env constraint — if the env disabled it, returning true is * impossible). */ setReadonlyAutoAllowEnabled(key: string, enabled: boolean): boolean; /** True iff the tool name is in the readonly allowlist. Surface for tests * and the /approval command's status output. */ isReadonlyTool(toolName: string): boolean; /** Snapshot of the readonly allowlist. Returns a copy so callers can't * mutate the bus's internal set. */ getReadonlyTools(): string[]; /** 测试用:当前 socket 路径。 */ getSocketPath(): string | null; /** * Operational metrics snapshot used by /api/metrics (M14). `pending` is * a live count; the totals are lifetime counters that monotonically * increase until stop(). The three result buckets (allowed / denied / * timedOut) are mutually exclusive and sum to totalResolved — see * cancelPending for the bucketing rule. Cheap to call — no allocations * beyond the returned object. */ getMetrics(): { pending: number; totalRequests: number; totalResolved: number; totalAllowed: number; totalDenied: number; totalTimedOut: number; totalReadonlyAutoAllowed: number; }; /** * Snapshot of every currently-pending approval, sanitized for surface * to the operator dashboard. Used by the web `/api/approvals` endpoint * (and any future ops tooling). Returns a stable JSON shape — input * is included verbatim so the UI can render the same preview the * IM-side card would; sockets / dispatch closures / timer handles are * intentionally omitted. * * Sorted oldest-first so the queue head is at index 0 (matches the * head-of-thread queue semantics used by resolvePending). */ listPending(): Array<{ reqId: string; runId: string; threadId: string; platform: string; toolName: string; input: Record; fingerprint: string; autoAllow: boolean; registeredAt: number; ageMs: number; }>; private handleConnection; private handleLine; private handleApproval; /** * Register an approval request that did NOT come from the unix-socket * sidecar. Used by the opencode HTTP bridge (P2): SSE event from opencode * → bridge calls this with a `dispatch` callback that POSTs the decision * back to opencode's REST API. * * Behavior is identical to the socket path — same notifier, same timeout, * same auto-allow rules — just the delivery channel differs. The * `dispatch` is invoked with the final Decision exactly once, on: * - user reply via {@link resolvePending} * - timeout (deny in normal mode, allow in auto-allow mode) * - {@link unregisterRun} (deny: "run terminated") * - {@link stop} (deny: "approval-bus shutting down") * * dispatch errors are logged and swallowed — the bus must not crash on * a misbehaving callback. * * Idempotent on duplicate reqId: returns silently without firing notify * (matches the socket path's "duplicate reqId" handling, minus the wire * deny since the synthetic caller has no socket to deny on). * * Throws synchronously only when the caller's bus state is invalid (no * notifier installed). The caller should avoid registering the synthetic * pending in that case and fall back to its own deny path. */ registerSyntheticPending(input: { runId: string; reqId: string; toolName: string; input: Record; /** Optional — used by Claude's MCP path; defaults to '' for synthetic. */ toolUseId?: string; /** Require a one-time human decision; bypasses every automatic allow path. */ requiresExplicitDecision?: boolean; /** Allow "Auto for similar" even when `requiresExplicitDecision` is set. */ allowSessionPin?: boolean; /** Override the pending timer (e.g. goal confirmation uses its own budget). */ timeoutMs?: number; ctx: RunContext; dispatch: (decision: Decision) => void; }): Promise; /** * Shared register-and-notify pipeline used by both the socket path and the * synthetic path. Builds the PendingApproval, wires the timer, fires the * notifier, and ensures the timer is cleared if the notifier itself throws. */ private _registerPending; private cancelPending; private sendDecision; } /** 进程级单例。agim 启动时 await approvalBus.start() 一次。 */ export declare const approvalBus: ApprovalBus; //# sourceMappingURL=approval-bus.d.ts.map