@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
TypeScript
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