design.md 12 KB

Context

See proposal.md for motivation. Stock codex-acp currently starts the executable selected by CODEX_PATH with the single app-server argument and exchanges newline-delimited JSON-RPC over that child process's stdin and stdout. A separately managed Codex App Server already listens on ws://127.0.0.1:4500 and exposes the same JSON-RPC protocol as one JSON message per WebSocket text frame.

The solution spans a new standalone package repository and a later consumer configuration change in the user's dotgit-managed Emacs initialization. The shared server owns authentication and lifecycle; the bridge and agent-shell must remain clients only.

Emacs agent-shell
       |
       v
 stock codex-acp
       |  spawn: $CODEX_PATH app-server
       |  stdio JSONL
       v
codex-app-server-bridge
       |  one text frame per JSONL record
       |  ws://127.0.0.1:4500
       v
shared Codex App Server

Goals / Non-Goals

Goals:

  • Preserve stock codex-acp while substituting only the executable behind its existing CODEX_PATH contract.
  • Provide a small, inspectable, payload-private transport adapter with deterministic local failure behavior.
  • Compile the reviewed TypeScript source into a small Node 24 ESM runtime that npm can install globally from a local tarball.
  • Make every Emacs Codex ACP session a distinct client of the common App Server.
  • Keep the OpenSpec change and bridge implementation owned by the standalone repository while explicitly tracking the dotfiles integration task.

Non-Goals:

  • Live-peering into or resuming an existing Codex TUI thread.
  • Forking or patching codex-acp, agent-shell, or Codex.
  • Implementing JSON-RPC routing, inspection, filtering, authentication, or payload logging.
  • Discovering, starting, stopping, supervising, or reconnecting to App Servers.
  • Supporting remote non-loopback endpoints, TLS, credentials in URLs, or Node versions older than 24.
  • Publishing to an npm registry or committing generated tarballs.

Decisions

Use CODEX_PATH as the integration seam

codex-acp will remain unchanged. Emacs will resolve the globally installed codex-app-server-bridge executable and pass its absolute path as CODEX_PATH; codex-acp will therefore spawn codex-app-server-bridge app-server using its normal code path.

Alternatives considered:

  • Patch or fork codex-acp to add native WebSocket support. This removes the extra process but introduces an upstream fork and version coupling for a transport adaptation that can be isolated.
  • Modify agent-shell to speak the App Server protocol directly. This duplicates ACP adapter responsibility and expands the Emacs change substantially.
  • Point CODEX_PATH at a wrapper that delegates other Codex commands. This creates recursion and command-routing risks without a requirement; the bridge instead accepts exactly app-server.

Use a one-process, one-connection framing bridge

Each bridge instance owns one WebSocket connection for the lifetime of one codex-acp child. After validating arguments and configuration, it starts the connection with a five-second deadline. It does not begin consuming stdin until the socket is open, leaving early parent writes in the operating-system pipe rather than maintaining a custom startup queue.

After connection, a line reader maps each nonblank stdin record to one text frame. Incoming text frames are written to stdout with a newline delimiter. Payloads are not parsed, so method semantics—including account/logout—remain owned by the endpoints. Binary frames are fatal protocol errors. A dropped connection terminates the bridge and lets codex-acp surface failure; reconnecting would require replay and session-recovery semantics that version 1 intentionally excludes.

Alternative considered: an RPC-aware proxy that validates, filters, or rewrites methods. It could block globally scoped operations such as logout, but it would duplicate protocol knowledge, risk incompatibility with App Server evolution, and violate the selected transparent transport boundary.

Validate a fail-closed loopback URL grammar

CODEX_APP_SERVER_URL is mandatory and has no default. URL validation admits only ws://127.0.0.1:<port> and ws://[::1]:<port> at the root path, without credentials, query, or fragment. localhost is rejected to avoid name-resolution ambiguity. There is no private-server fallback.

Alternative considered: accept arbitrary ws:// or wss:// endpoints. That would require a broader network threat model, TLS trust configuration, credential handling, and remote failure policy that are unnecessary for the confirmed local shared-server use case.

Compile TypeScript to Node 24 ESM before packaging

The implementation uses strict TypeScript and Node's native WebSocket client plus core stream/process APIs, so the package has no runtime dependencies. tsconfig.json provides strict no-emit checking, while a dedicated build configuration reproducibly emits ESM JavaScript for Node 24 into ignored dist/ output.

The npm package declares engines.node >=24, version 0.1.0, private: true, and a bin entry. A minimal JavaScript bin bootstrap checks the Node major version before importing the compiled entry point so an unsupported runtime fails intentionally. npm pack runs the build and includes dist/ but excludes TypeScript source and tests. The generated JavaScript and tarball remain uncommitted. Pinned typescript and @types/node are the only development dependencies.

Alternatives considered:

  • Execute TypeScript directly with Node 24 type stripping. This works from a source checkout, but Node intentionally refuses type stripping for files installed beneath node_modules, including npm's global package store.
  • Use tsx or ts-node. These add runtime dependencies solely to execute source after installation.
  • Write plain JavaScript. This is simplest at runtime but gives up the requested checked TypeScript authoring experiment.

Distribute a private package as a reproducible tarball

The supported version 1 flow is npm ci, typecheck, build, test, npm pack, and global installation from the resulting .tgz. Packing rebuilds the ignored dist/ directory, and the tarball includes only the compiled runtime plus its bootstrap and documentation. Both generated output forms remain uncommitted. Marking the package private prevents accidental registry publication but does not prevent packing or local installation.

Alternative considered: install directly from the working directory or a Git URL. Both can work, but a packed tarball validates the actual installation unit and avoids requiring a registry or a remote repository.

Keep shared-server launch configuration separate from agent-shell configuration

The existing CLI tool-auth helper remains responsible for launching the shared server with its authentication environment and independently configured listen URL. The agent-shell environment function explicitly injects CODEX_APP_SERVER_URL=ws://127.0.0.1:4500; it does not inherit the value from Emacs's parent environment and does not share an Emacs variable with the server launcher in version 1.

For CODEX_PATH, the launcher calls executable-find and injects the resolved absolute path. Missing installation is reported before codex-acp starts. The normal C-c p x binding remains. The capital C-c p X binding and its agent-shell-specific privileged launcher are removed, while Pi and Claude bindings and the tool-auth CLI helper remain unchanged.

Alternative considered: define one Emacs variable for both endpoints. This reduces duplication but couples two independently managed concerns; the confirmed version 1 choice keeps them separate.

Keep diagnostics lifecycle-only

The bridge emits no payload or routine relay logs. Configuration, timeout, connection, protocol, and close failures go to stderr with a sanitized endpoint and available lifecycle reason. Stdin EOF, SIGINT, and SIGTERM initiate WebSocket closure and return success; failures and unexpected remote closure return nonzero.

A debug mode is excluded from version 1 because prompt and tool traffic can contain sensitive data. If later diagnostics require payload visibility, that must be a separately designed opt-in capability.

Use hermetic core-Node tests plus a live smoke check

Automated tests use node:test against TypeScript test files. A minimal local WebSocket test peer built from Node core APIs exercises handshake, text and binary framing, startup timeout, shutdown, and error paths without requiring Codex, credentials, or network access. Pure URL and invocation validation are tested directly.

The release validation sequence separately typechecks, runs hermetic tests, inspects npm pack contents, installs the tarball, and performs a live smoke check against an already-running loopback App Server. The live check verifies initialization and a normal request, confirms the shared server survives bridge exit, confirms no private codex app-server child is spawned, and confirms the resulting Emacs-owned thread is visible from the shared server.

Alternative considered: make the real Codex App Server part of npm test. That would couple normal tests to local credentials and lifecycle state and make failures nondeterministic.

Risks / Trade-offs

  • [Upstream codex-acp changes its CODEX_PATH app-server invocation contract] -> Pin and document the tested adapter version, cover the invocation with a packaged smoke test, and update the narrow bridge contract when needed.
  • [Codex changes WebSocket framing semantics] -> Keep the bridge byte-transparent at message level and validate against the installed App Server before rollout.
  • [The independently configured Emacs and server endpoints drift] -> Fail quickly with the sanitized configured endpoint; version 1 accepts this operational trade-off rather than coupling the settings.
  • [A user invokes logout from one client and signs out all shared clients] -> Preserve transparent behavior and document the global shared-authentication effect.
  • [The shared server is down] -> Fail within five seconds and require the operator to restore the common server; never create a hidden private fallback.
  • [Compiled output drifts from reviewed TypeScript source] -> Build with pinned TypeScript during tests and npm pack, keep dist/ uncommitted, and validate the installed tarball rather than a stale working-tree build.
  • [Native WebSocket buffering is less controllable than a stream abstraction] -> Keep version 1 traffic assumptions scoped to normal local Codex JSON-RPC volumes and terminate rather than attempt complex recovery after transport failure.

Migration Plan

  1. Implement and validate the package in the standalone repository with Node 24, including typecheck, reproducible compilation, hermetic tests, and packed-artifact inspection.
  2. Create the 0.1.0 tarball and install it globally; verify Emacs can resolve the executable.
  3. Ensure the independently managed shared Codex App Server is running on ws://127.0.0.1:4500 with the intended tool-auth environment.
  4. Update the dotgit-managed Emacs configuration to inject the resolved bridge path and endpoint for Codex ACP sessions, remove the capital binding and privileged agent-shell launcher, and retain the server tool-auth helper.
  5. Restart existing Codex agent-shell buffers so they use the new adapter process environment.
  6. Run the live shared-server smoke checks, including concurrent client visibility, server survival after buffer closure, and absence of private App Server children.

Rollback consists of reverting the Emacs dotfiles change and restarting affected agent-shell buffers, then uninstalling the bridge package if desired. The shared App Server requires no rollback because the bridge does not mutate its lifecycle or persistent configuration.