dsh-codex-bridge
Observe and steer a local Codex (OpenAI) session from DeepSeek Harness.
The plugin talks to codex app-server — the same protocol the Codex app itself
uses — which is what makes the live parts possible at all:
Reading the rollout .jsonl |
This plugin | |
|---|---|---|
| Transcript | after it is written | live deltas, plus polls |
| Reasoning | only what was flushed | summary deltas as they stream |
| Rate-limit budget | not present | account/rateLimits/read |
| Send a message | not possible | queue (the only cross-process path) |
| Answer an approval | not possible | only for turns this bridge started |
It was built as a DSH counterpart to a Codex session running in the Codex desktop app: read what that session is doing, hand it the next instruction, and know exactly which parts are impossible rather than guessing.
Tools
| Tool | What it does |
|---|---|
codex_status |
Account, plan, rate-limit budget, local processes, latest thread, pending approvals. Degrades to a readable message when Codex is not reachable instead of throwing. |
codex_threads |
Recent threads, optionally filtered by a search string. |
codex_transcript |
Items of one thread, newest first. |
codex_watch |
Watches a thread for N seconds and returns what streamed past, merging the live notification stream with transcript polls and labelling each event's source. |
codex_send |
queue (default), turn, or steer. Falls back to the queue when the desktop app holds the thread's write lock. |
codex_approvals |
Lists pending approval requests and answers them. |
codex_queue |
Shows the messages still waiting on a thread (action=list), and cancels one with action=delete. Reports what the app-server confirmed, since a live session usually consumes a queued message first. |
Board
The same projection is also a panel inside Harness: a sidebar row named Codex opens a board whose columns are waiting for approval, turn observed here, queued messages and no observed activity.
It is deliberately read-only. The panel polls one host route (a single
snapshot endpoint) and holds no path to codex_send or codex_approvals at all:
a board that could decline an approval by accident would be worse than no board.
Four rules keep it honest, and they are why the columns are named the way they are:
- A thread waiting on an approval is never filed under Queue, even when it also has queued messages.
- Running means this bridge's connection saw
turn/startedand has not seen it finish — a statement about the connection, not about the machine. So the last column is no observed activity, never "idle": silence from a connection-scoped protocol is not proof that nothing is happening. - Every card carries a last line: the newest item the rollout reports that is not
encrypted reasoning (
reasoningitems trail almost every turn and their text is sealed), with that item's own status printed verbatim —inProgress,failed, whatever the protocol said. It reports what the thread last did; it is not a claim that a turn is running. - Queue and last-action inspection are sampled (a bounded number of threads per
refresh, reported in the snapshot's
probeblock), and the board says so rather than presenting a partial read as a total.
The client half is plain JS loaded by the web client — no build step, like the shipped client plugins. It shows up after a Harness restart.
What you cannot do, and why
Four of these are structural, not missing features — full evidence in
docs/limitations.md:
- The desktop app holds an exclusive write lock on every thread it has opened,
even while idle. So
turn/steerare unavailable for those threads andqueueis the only write path. - Approvals go to the client that owns the turn. For a turn the Codex app is
running,
codex_approvalssees nothing and cannot answer for it. Approving the app's turns from here is impossible over this protocol. - Live deltas go only to the owning connection. Watching a desktop-owned
thread works, but through polling, so it is up to ~1.2s behind and arrives as
source: "transcript". - Reasoning bodies are encrypted. You get the emitted summary — rendered as
(reasoning not exposed: encrypted)when there is none — not the hidden chain of thought.
Also: codex_send is a real write. A queued message is picked up promptly — in
testing, almost immediately — so queueing is sending, not drafting.
codex_queue action=delete can cancel an entry that is still waiting, but that is
a race rather than an undo: the tool reports the app-server's own answer instead of
assuming the message never went out.
Install
The plugin is loaded by name, so the profile needs two symlinks. The script creates them, moves any existing vendored copy aside (never deletes it), and records what it did so the change is reversible:
node scripts/install.mjs --dry-run # show the plan
node scripts/install.mjs # apply it
node scripts/install.mjs --write-config # also append the loader entry if missing
It targets ~/.dsh/profiles/desktop by default; override with
--profile <dir> or DSH_PROFILE_DIR.
The npm package is @juyanjy/dsh-codex-bridge — the unscoped dsh-codex-bridge
on npm belongs to an unrelated project, so it is not ours to use. The loader
entry keeps the short id dsh-codex-bridge on purpose: a profile resolves that
name as a module specifier, so it has to match the directory the install
creates, and that is the directory shown above.
Then restart DeepSeek Harness. This is not optional: patchReload: live
re-reads config, not module code, so a running Harness keeps the module it already
imported (see limitation 6).
To undo:
node scripts/uninstall.mjs --dry-run
node scripts/uninstall.mjs
The loader entry in the profile's cordis.patch.yml is hand-maintained config and
is never edited silently — install.mjs prints the exact YAML to paste when it is
missing, and uninstall.mjs tells you which block to remove.
Configuration
- insert:
- id: dsh-codex-bridge
name: dsh-codex-bridge
config:
binary: "" # explicit path to `codex`; empty = auto-detect
requestTimeoutMs: 30000
Binary resolution order:
- explicit
binary $DSH_CODEX_BIN/Applications/ChatGPT.app/Contents/Resources/codex/opt/homebrew/bin/codex/usr/local/bin/codex/usr/bin/codex- bare
codex(resolved onPATH)
Development
npm test # 76 unit tests, no Codex install required
node scripts/smoke.mjs # live handshake against the real app-server
tests/helpers/fake-codex.js is a real codex app-server stand-in: it speaks the
protocol over stdio in a child process, so the transport, framing, correlation and
timeout paths are exercised for real rather than mocked. Its behaviour is driven
by environment variables:
FAKE_CODEX_LOCK=1—thread/resumeandturn/steeranswer with the active-writer error, which is what a desktop-owned thread does.FAKE_CODEX_SILENT=1— never answers, for the timeout path.
The smoke script is the only test that needs Codex installed, and it writes to no thread.
tests/client.test.js renders the panel's React tree in the test process against
a snapshot the host route would really serve, using a minimal React stand-in
(no DOM, no browser). It covers the fields the panel reads, the coverage notes,
the badge rules and the failure path. It does not cover the shell mounting the
two seats, or anything visual — check those by eye.
Layout
src/index.js cordis entrypoint: name/inject/Config/apply
src/bridge.js one shared app-server connection, lazily started
src/app-server.js spawn, JSONL framing, request correlation, inbound requests
src/codex.js protocol payloads -> compact records
src/notifications.js which notification methods matter, and how to describe them
src/errors.js the active-writer error and the explanation shown to users
src/board.js the read-only projection the tools and the board share
src/board-channel.js the host route the panel polls (one POST endpoint)
src/client.js the panel itself; loaded by the web client, never by Node
src/tools/*.js one factory per tool
Requirements
- Node >= 22 (uses
node:test, ESM,randomUUID) - A Codex install for anything live; the unit tests need none
License
MIT — see LICENSE.
No comments yet. Be the first to write one.