Run Claude Code and Codex side by side in a single unified console. Direct messages to either tool with @mentions, relay output between them, and let them talk to each other autonomously.
_______________________ _______________________
| Claude Code | Codex |
| | |
| I think @codex | Good point @claude, |
| should review the | I agree but we also |
| error handling... | need to consider... |
|_______________________|_______________________|
| Duet Router |
| [converse] round 2/10: claude -> codex |
| duet> |
|_______________________________________________|
- tmux 3.4+
- Claude Code (
claudeCLI) - Codex (
codexCLI) - Node.js 18+
npm install # install dependencies
npm run build # compile TypeScript to dist/./duet.sh # launch (uses built dist/ by default)
./duet.sh resume # resume most recent run for current directory
./duet.sh resume ID # resume a specific run (global lookup)
./duet.sh list # show all runs
./duet.sh destroy ID # remove a runThis opens a tmux session with three panes: Claude Code (top-left), Codex (top-right), and the Duet router (bottom). Both CLIs launch automatically.
The package also exposes a duet bin entry (dist/cli/duet.js) for use via npx or npm link:
npx duet # if installed as a dependency
node dist/cli/duet.js # direct invocation without shell shimFor working on Duet itself, use source mode (requires tsx):
DUET_USE_SOURCE=1 ./duet.shThis runs TypeScript source directly via the tsx ESM loader, skipping the build step. If dist/ doesn't exist and DUET_USE_SOURCE is not set, the shell shim fails with a clear error.
| Command | Description |
|---|---|
@claude <msg> |
Send a message to Claude Code |
@codex <msg> |
Send a message to Codex |
@both <msg> |
Send the same message to both |
| Command | Description |
|---|---|
@relay claude>codex |
Send Claude's last response to Codex |
@relay codex>claude |
Send Codex's last response to Claude |
@relay claude>codex <prompt> |
Same, but prepend a custom prompt |
Relay reads the source tool's structured session log (JSONL) to extract the last response. The source tool must have an active session binding.
duet> @claude analyze the error handling in src/auth.ts
duet> @relay claude>codex do you agree with this analysis?
duet> @relay codex>claude implement the fixes codex suggested
The tools can talk to each other. There are two modes:
Converse mode starts a multi-round discussion on a topic. Codex goes first, its response is automatically relayed to Claude, Claude's response goes back to Codex, and so on.
| Command | Description |
|---|---|
/converse <topic> |
Start a 10-round discussion |
/converse <n> <topic> |
Start an n-round discussion |
/stop |
Stop the conversation early |
/status |
Show current converse/watch state |
duet> /converse How should we refactor the auth module?
duet> /converse 5 Review the test coverage and suggest improvements
duet> /status
duet> /stop
Watch mode monitors session logs for @mentions. When Claude includes @codex in its output, the router automatically relays it -- and vice versa. This lets them organically pull each other into the conversation. Watch mode requires session bindings; tools with pending bindings are reported as waiting (and auto-activate when bound), while degraded tools are reported as unavailable.
| Command | Description |
|---|---|
/watch |
Start monitoring for @mentions |
/stop |
Stop monitoring |
duet> /watch
duet> @claude analyze src/auth.ts — mention @codex if you want a second opinion
[auto] claude mentioned @codex — relaying
[auto] codex mentioned @claude — relaying
duet> /stop
Both tools are told they can @mention the other. An 8-second per-direction cooldown between auto-relays prevents runaway loops while allowing natural back-and-forth replies (claude→codex and codex→claude are tracked independently).
| Command | Description |
|---|---|
/focus claude |
Switch keyboard focus to the Claude pane |
/focus codex |
Switch keyboard focus to the Codex pane |
/snap claude [n] |
Print the last n lines from Claude's pane (default 40) |
/snap codex [n] |
Print the last n lines from Codex's pane (default 40) |
/rebind claude|codex |
Re-discover session file after manual /resume |
/clear |
Clear the router screen |
/help |
Show command reference |
/quit |
Kill the session and exit |
Mouse mode is enabled -- click any pane to focus it directly, then click the router pane to return.
You can also use standard tmux navigation: Ctrl-B then arrow keys to move between panes, or Ctrl-B ; to jump back to the last pane.
When you need to use a tool's native commands (like Claude's /compact or Codex's built-in shortcuts), either:
- Click the tool's pane directly with the mouse
- Use
/focus claudeor/focus codexfrom the router - Use
Ctrl-B+ arrow keys
Everything you type goes directly to that tool until you switch back to the router pane.
You can give each tool a project-specific role by placing markdown files in your workspace root:
CLAUDE_ROLE.md — appended to Claude Code's system prompt
CODEX_ROLE.md — appended to Codex's model instructions
Both files are optional. When present, the contents are appended to the base DUET.md prompt under a labeled section. Role prompts are applied on fresh launch, resume, and fork. Attaching to an already-running tmux session does not reapply them.
If you edit a role file, the changes take effect the next time the run is launched or resumed.
duet.sh Shell shim — execs dist/cli/duet.js (or src/ in dev mode)
dist/cli/duet.js CLI entry — preflight, subcommand dispatch
dist/launcher/commands.js Launcher — creates tmux layout, launches tools + router
dist/router/controller.js Router — parses commands, dispatches via tmux, watches for @mentions
The router communicates with tool panes through tmux primitives:
- send-keys sends typed text to a pane (as if you typed it)
- capture-pane reads visible text from a pane (used by
/snaponly — diagnostic, not automation) - paste-buffer pastes multiline text into a pane (used for relay delivery)
Automation (watch, converse, @relay) uses session-only relay: fs.watch() on the JSONL session log file. New content triggers a relay after a short debounce (200ms with a completion signal, 800ms otherwise). This gives sub-second latency with authoritative, structured output.
Tools with pending bindings are polled at the binding level (not pane level) — the router periodically checks whether bindings.json has transitioned from pending to bound, then starts file watching. The 8-second cooldown is per-direction, so a claude→codex relay does not block an immediate codex→claude reply. In converse mode, the cooldown is bypassed entirely since turn tracking already prevents loops.
Both CLIs run as full interactive processes in their own pseudo-terminals. Duet does not use the APIs -- it wraps the actual CLI tools, preserving all native features.
npm run build # required for dist smoke tests
npm test # runs all tests via node --import tsx/esm --test
npm run verify # typecheck + build + test (full release check)336+ tests across 70+ suites: shell escaping, input parsing (including converse/watch/stop), content diffing (getNewContent), @mention detection (detectMentions), tmux integration (sendKeys, capturePane, pasteToPane, focusPane, cross-pane relay), launcher layout, response extraction (Claude and Codex formats), completion detection (isResponseComplete), incremental session reader, end-to-end session binding (bindings.json manifest), binding lifecycle (manifest caching and re-reads), launcher binding contract (bind-sessions.sh with fallback coverage), session-only automation enforcement, explicit binding enforcement, watch/status messaging, CODEX_HOME isolation, typed manifest schemas, workspace management (cwdHash, resolveRunId, listRuns, destroyRun, buildToolPrompt), and edge cases. Integration tests run against real tmux sessions.
- Session binding required for automation:
/converse,/watch, and@relayrequire active session bindings. If binding fails (tool marked asdegraded), these commands report the tool as unavailable. Use/statusto check binding state. - Single-line input:
@claudeand@codexsend a single line. For multi-line prompts, use/focusto interact natively. - In-tool
/resume: Using Claude's built-in/resumecommand inside a live Duet session invalidates the router's session binding. Use/rebind claudeto re-discover the new session file, or preferduet.sh resume/duet.sh forkinstead. - tmux 3.4: The
split-window -p(percentage) flag fails on detached sessions. Duet uses-l(absolute lines/columns) with dimensions queried from the actual tmux window after session creation.