UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

187 lines • 9.17 kB
import { CliExecResult } from "../types.js"; import { type Config } from "../config.js"; /** * The `app-server` codex real-time-wake rail. * * codex >=0.145 has a SINGLE-WRITER thread-store: a codex agent open in an * interactive TUI holds an exclusive writer on its thread, so the `exec-resume` * rail (`codex exec resume <same id>`) fast-fails `-32600 already has an active * writer`. Here we inject the peer's message as a TURN through codex's * app-server daemon instead — the daemon is the single writer; the human's * `codex --remote` TUI and this bridge client are both just RPC clients, so * there is no conflict (proven end-to-end in scratchpad/appserver-ws-proof.mjs). * * Transport (all verified against codex 0.147): a WebSocket on the daemon's * unix socket at path `/rpc`, permessage-deflate DISABLED and `Host: localhost` * (the real client sends no extensions; otherwise the upgrade hangs up). No auth * token for the LOCAL unix socket. JSON-RPC messages are one WS text frame each * (no LSP/newline framing). */ /** Minimal surface of a `ws` WebSocket we use — injectable for tests. */ export interface WsLike { on(event: string, cb: (...args: any[]) => void): void; send(data: string): void; close(): void; } export type WsFactory = (url: string, opts: Record<string, unknown>) => WsLike; /** Control socket for the codex app-server daemon under a given codex home. */ export declare function appServerSocketPath(codexHome?: string): string; /** * Is an app-server daemon ALREADY listening on this codex home's control socket? * * 🔴 WHY ANCESTRY WAS NOT ENOUGH. {@link runningUnderAppServerDaemon} answers "was I spawned * under a daemon?" — but the daemon is a SHARED, MACHINE-WIDE SINGLETON (see ensureDaemon), so a * process that is not its descendant can still use it. A codex agent whose bridge runs outside a * `cogent-codex`-launched codex — a standalone monitor script, a systemd unit, an MCP server under * a plain codex — always answered "no", fell back to `exec-resume`, and `codex exec resume * <thread>` then took the EXCLUSIVE WRITER on that thread. On 2026-09-02 that locked the operator * out of their own live session (`already has an active writer (code -32600)`) while a perfectly * good daemon was running on the same machine. * * Deliberately narrow: this reports only whether a daemon is ALREADY up. It never starts one, so a * machine without a daemon keeps its previous behaviour exactly. A stale socket file costs one * failed attempt and falls through to `exec-resume`, which is the pre-existing path anyway. * * `isSocket()` rather than `existsSync` on purpose: a leftover REGULAR FILE at the socket path * must not be mistaken for a live daemon. */ export declare function appServerDaemonRunning(codexHome?: string): boolean; /** The `ws+unix://<sock>:/rpc` URL the daemon speaks JSON-RPC over. */ export declare function rpcUrl(sock: string): string; /** * When codex runs `--remote` (attached to the daemon, e.g. launched via * cogent-codex), codex's app-server DAEMON — not the codex TUI — is the process * that spawns the plugin's MCP server. So the wake decision cannot rely on an env * flag reaching the bridge (the daemon doesn't carry it). Instead we detect the * situation structurally: the bridge is a descendant of `codex … app-server` * exactly when codex is `--remote`. Then the app-server rail is the RIGHT one and * we use it automatically — no `COGENT_CODEX_WAKE` needed anywhere. * * Pure + unit-testable: walk an ancestry via the injected `getProc`, return true * if any ancestor's command line is the codex app-server. `codex-cli` (Homebrew/npm) * and other names never match; only `codex … app-server`. */ export declare function ancestryHasAppServer(startPid: number, getProc: (pid: number) => { cmd: string; ppid: number; } | null, maxDepth?: number): boolean; /** * Memoized: is THIS bridge process running under a `codex … app-server` daemon * (i.e. codex is `--remote`)? Cached for the process lifetime — ancestry is fixed. */ export declare function runningUnderAppServerDaemon(): boolean; export declare function resetAppServerAncestryCache(): void; export interface EnsureDaemonDeps { /** Run a command; injectable for tests. Returns collected stdout/stderr/code. */ run?: (cmd: string, args: string[], env: NodeJS.ProcessEnv) => Promise<{ stdout: string; stderr: string; code: number | null; }>; /** Does the control socket exist yet? Injectable for tests. */ socketExists?: (p: string) => Promise<boolean>; /** Total budget to wait for the socket to appear (ms). */ waitMs?: number; /** Poll interval while waiting (ms). */ pollMs?: number; } export declare function resetDaemonCache(): void; /** * Ensure the codex app-server daemon is running for `codexHome` and return its * control socket. Idempotent (`codex app-server daemon start` is a no-op when the * daemon is already up). Throws if the socket never appears — the caller * (`execRemote`) catches this and falls back to the `exec-resume` rail. */ export declare function ensureDaemon(codexHome?: string, deps?: EnsureDaemonDeps): Promise<string>; interface JsonRpcMessage { jsonrpc?: string; id?: number; method?: string; params?: any; result?: any; error?: { code?: number; message?: string; }; } /** * Extract the agent's reply text from the collected turn notifications. Prefers * the final `turn/completed` items; falls back to streamed `item/completed` * agent messages when the completed turn carries none. Tolerant of the exact * shape (experimental API) — the live test pins the real wire format. */ export declare function extractAgentMessageText(notes: JsonRpcMessage[]): string; /** * A thin JSON-RPC-over-WebSocket client for the codex app-server daemon. * `connect()` resolves once the socket is open + `initialize` succeeds. */ export declare class AppServerClient { private ws; private nextId; private pending; private notifyListeners; private opened; private openResolved; constructor(ws: WsLike); /** Await the open handshake. Rejects (before open) on connect/upgrade errors. */ waitOpen(): Promise<void>; private _failAll; private _onMessage; /** Issue a JSON-RPC request and resolve with its result (rejects on rpc error). */ request(method: string, params?: unknown): Promise<any>; /** Subscribe to server notifications; returns an unsubscribe fn. */ onNotification(cb: (msg: JsonRpcMessage) => void): () => void; close(): void; } export interface AppServerDeps { /** Injectable WebSocket factory (tests supply a fake). */ wsFactory?: WsFactory; /** Override the daemon socket path (default derives from codex home). */ socketPath?: string; /** * Stable id of the inbound message (relay MessageRecord.id). When set, a * duplicate injection of the SAME message into the SAME thread is coalesced * (still in-flight) or short-circuited (recently completed) so the daemon * never runs the peer's turn twice — the async-daemon-turn duplicate risk * (the daemon keeps running a turn after our client times out/disconnects). * Also sent as turn/start `clientUserMessageId` for daemon-side correlation. */ idempotencyKey?: string; } /** * codex 0.147 SandboxPolicy (shape from `codex app-server generate-ts`). * `workspaceWrite` carries the exclude flags; `readOnly` carries networkAccess. */ type SandboxPolicy = { type: "dangerFullAccess"; } | { type: "readOnly"; networkAccess: boolean; } | { type: "workspaceWrite"; writableRoots: string[]; networkAccess: boolean; excludeTmpdirEnvVar: boolean; excludeSlashTmp: boolean; }; /** * Map COGENT_CODEX_SANDBOX to an explicit turn/start `sandboxPolicy`, so a PEER's * injected turn is bounded by the CONFIGURED sandbox — not by whatever privilege * the human happened to launch codex with (a live thread may be * danger-full-access). Keeps the app-server rail's sandbox consistent with the * exec-resume rail. Default (workspace-write) allows edits under the cwd, no net. */ export declare function sandboxPolicyFor(mode: Config["COGENT_CODEX_SANDBOX"], cwd: string): SandboxPolicy; /** Clear the injection idempotency cache (test isolation). */ export declare function resetAppServerIdempotency(): void; /** * Inject `message` as a turn into the codex thread `threadId` via the app-server * daemon, and return the agent's reply as a {@link CliExecResult} (parallel to * `execCodex`). When `deps.idempotencyKey` is set, a duplicate injection of the * same message into the same thread is coalesced/short-circuited (never a second * daemon turn). Closing our client afterward only detaches THIS client — the * daemon + thread (and the human's TUI) persist. */ export declare function execCodexViaAppServer(threadId: string, message: string, cwd: string, timeoutMs?: number, deps?: AppServerDeps): Promise<CliExecResult>; export {}; //# sourceMappingURL=codex-app-server.d.ts.map