claude-codex-pair 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -10,8 +10,8 @@ and they talk through a shared directory that works like a chat channel.
10
10
  | Topology | Two live sessions. No headless spawning. |
11
11
  | Roles | Symmetric. Either agent can author or review. |
12
12
  | Interface | One `pair` CLI (Node/TS). The agents learn it from a thin prompt: a Claude skill and an AGENTS.md section for Codex. |
13
- | Waiting | `pair wait` checks the files every second and returns as soon as a message arrives. By default it gives up after 540s, printing `no message … yet` and exiting 0 (agent UIs show any non-zero exit as a failure), which keeps a foreground run under Claude Code's 10-minute Bash cap. Interactive Claude Code sessions run `pair wait --timeout 3600` as a background command and spend no model turns until it exits. Codex starts one `pair wait --timeout 3600` and checks on the running command. In the sessions observed so far, its instructions limit each check (a single blocking tool wait) to 60 seconds and ask for a progress update at least every minute, so an idle Codex wakes about once a minute to check. That limit applies to each check, not to how long the command runs, so Codex doesn't restart `pair wait` every minute. Headless runs wait in the foreground. |
14
- | Pickup | The reviewer runs `pair wait` in a loop ("be my reviewer"). |
13
+ | Waiting | `pair wait` checks the files every second and returns as soon as a message arrives. By default it gives up after 540s, printing `no message … yet` and exiting 0 (agent UIs show any non-zero exit as a failure), which keeps a foreground run under Claude Code's 10-minute Bash cap. Interactive Claude Code sessions run `pair wait --timeout 3600` as a background command and spend no model turns until it exits. Codex can't do the same, because a finished background command doesn't resume it. Instead, pair's MCP server queues a fixed note into the Codex session when a message is pending (see Notifications), so an idle Codex makes no model calls. Codex never polls, because each check of a running command is a model call: without the MCP server it checks once and the user asks it to check again. Headless runs wait in the foreground. |
14
+ | Pickup | The reviewer waits in a loop ("be my reviewer"): Claude on a background `pair wait`, Codex woken by `pair mcp`. |
15
15
  | Request | The author's summary plus a list of files. The reviewer reads the files from the shared repo. |
16
16
  | Review | Numbered findings with severity, and a verdict of `approve` or `changes`. Multiple rounds. |
17
17
  | Disputes | Cap of 3 rounds (`PAIR_MAX_ROUNDS`). A review that still says `changes` at the cap marks the thread `escalated`, and both agents stop to ask the user. |
@@ -31,6 +31,11 @@ and they talk through a shared directory that works like a chat channel.
31
31
  004-review.md # round 2, verdict approve, status approved
32
32
  codex-to-claude/
33
33
  archive/<iso-ts>-<rand>-<lane>-<approved|escalated>/
34
+ codex.session # Codex session that pair_listen may notify (pair register)
35
+
36
+ ${XDG_STATE_HOME:-~/.local/state}/pair/<repo>-<hash>/ # written only by pair mcp, outside agent sandboxes
37
+ codex.listen.log # what the MCP listener did, newest last; .1 holds the previous 256 KB
38
+ claims/<thread>.<message file>.<session id>
34
39
  ```
35
40
 
36
41
  Each message has this frontmatter, written by the CLI: `from`, `type`, `round`, `verdict` (reviews only), and `status` (`open`/`approved`/`escalated`).
@@ -77,6 +82,56 @@ cause these failures. Input message files, instruction files, and installed asse
77
82
  separately. Automatic pruning still warns and continues; its diagnostics follow the delivered
78
83
  message so `wait` keeps its protocol header first even when stdout and stderr are merged.
79
84
 
85
+ ## Notifications
86
+
87
+ `codex queue --thread <id> --message <text>` starts a turn in an idle Codex session, and queues
88
+ behind a busy one. It works only outside both sandboxes, because it opens Codex's state databases
89
+ under `~/.codex` read-write. Claude's sandbox can't run it unless it's given write access to
90
+ `~/.codex`, which holds Codex's own config. So `send` doesn't call it. Instead, `pair mcp` runs as
91
+ a stdio MCP server that Codex starts outside its shell sandbox.
92
+
93
+ 1. Codex runs `pair register` in its shell. The CLI writes `codex.session` with `CODEX_THREAD_ID`,
94
+ which Codex sets in its shell environment, into the channel of that shell's worktree.
95
+ 2. Codex calls `pair_listen`, which takes no arguments. The server reads the session id from the
96
+ call's `_meta.threadId`, which Codex sets, and requires exactly one channel whose `codex.session`
97
+ names that id. The model supplies no id or path, and the server's own cwd is never used.
98
+ 3. Every second, the server reads `pendingFor(channel, "codex")`. It never calls `wait`, so it
99
+ never archives anything. When the newest pending message file differs from the last one it
100
+ announced, it runs `codex queue` with a fixed note telling Codex to run `pair wait --timeout 5`.
101
+ Message text never goes into the note, so the partner's words never arrive as user input. Before
102
+ every dispatch it checks that `codex.session` still names its session. `pair unregister` or a
103
+ newer `pair register` stops it.
104
+
105
+ Before sending, a server creates a claim file for the message and session, using an exclusive
106
+ create, and writes `sending` in it. Only the server that creates it sends the note, so
107
+ two Codex processes attached to one session, or a repeated `pair_listen`, don't send it twice. After
108
+ `codex queue` succeeds, the claim says `sent`. Other servers then treat the message as notified.
109
+ While it says `sending`, they wait. If `codex queue` fails, the claim is removed, so the retry can
110
+ claim it again. If a claim still says `sending` after 60s, twice the queue timeout, its sender died
111
+ mid-send, and the next server takes it over. Two servers taking over at the same moment can both
112
+ send, which costs one extra short turn. A queue call that accepted the note but timed out before
113
+ exiting can also be retried and send one extra note. Claims older than 7 days are deleted when a
114
+ session starts listening.
115
+
116
+ The server runs outside both sandboxes, and agents can write the channel. An agent could replace a
117
+ file there, or a whole directory on the path to it, with a symlink, and could do it between any
118
+ check and the write that follows. So `pair mcp` never writes in a channel. Its log and claims live
119
+ in `${XDG_STATE_HOME:-~/.local/state}/pair/`; relative `XDG_STATE_HOME` values are ignored. Agent
120
+ sandboxes cannot write there by default. The server still reads the channel, opening those files
121
+ with `O_NOFOLLOW`. It runs `PAIR_CODEX`, an absolute path set when the server is registered, never
122
+ `codex` from `PATH`, because an agent-writable `PATH` entry would turn the fixed call into
123
+ unsandboxed code execution.
124
+
125
+ A message is announced once. A failed `codex queue` is retried with backoff up to 60s. If a message
126
+ is still pending 10 minutes after its note, the server logs a stall warning and doesn't announce it
127
+ again: the turn may have failed or been cancelled, may be waiting for an approval, or the review may
128
+ just be long. Watches live in the MCP process. Codex's app-server daemon starts it and ends it when it unloads
129
+ the session, which took 1–2 minutes after the last TUI on that session quit. Until then, a new
130
+ message still starts a turn with no TUI attached. `pair unregister` stops notifications right away.
131
+ A note already accepted by the daemon still runs either way, so the prompt tells Codex to ignore a
132
+ note that arrives after the user said stop. A repeated note costs one short
133
+ turn: `pair wait --timeout 5` prints `no message` and Codex ends its turn.
134
+
80
135
  ## Usage
81
136
 
82
137
  ```
@@ -95,6 +150,9 @@ pair status
95
150
  pair history [thread-id]
96
151
  pair clean --keep N
97
152
  pair init
153
+ pair uninit
154
+ pair register | unregister
155
+ pair mcp
98
156
  ```
99
157
 
100
158
  ## Known limits
@@ -102,4 +160,13 @@ pair init
102
160
  - A lane still in the old flat layout (before 1c588ed) isn't migrated. Commands fail with a hint to move its files into a subdirectory, and the messages are left untouched.
103
161
  - Polling, not fs events. A 1s delay is fine for chat between agents.
104
162
  - Archiving is safe against concurrent readers and stale `wait`s. A thread disappearing mid-read counts as closed. A stale archive can only target its own, already-archived directory, so it never touches a newer thread.
105
- - Completion-driven waiting is verified only for interactive Claude Code sessions, with waits of up to a few minutes. A full hour is untested. Headless `claude -p` ends background commands after its final result, so the prompt falls back to a foreground wait. Codex's built-in instructions (observed in these sessions: no blocking sleep or wait call over 60 seconds, and a progress update at least every minute) outrank a repo prompt, so the prompt leaves the length of each check to those instructions and only fixes the command's lifetime. Two earlier versions got this wrong. One asked for a 5-minute check window, which conflicted with the cap. The next applied the cap to the command itself (`--timeout 55`), so Codex restarted `pair wait` every minute and each run showed up as a separate entry. Whether Codex resumes automatically after ending its turn is unverified.
163
+ - Notifications are verified end to end with Codex CLI 0.162.1 on the shared app-server daemon. A registered session that had ended its turn was woken about 1s after Claude's request, then reviewed and replied in one turn. Other cases also verified:
164
+ - A message sent while Codex was closed was queued about 1s after `pair_listen` on resume.
165
+ - `pair unregister` stopped the watch.
166
+ - A fork's `register` stopped the original session's watch within 40ms.
167
+ - Two TUIs on one session share one MCP process and got one note.
168
+ - Two worktrees each woke only their own session.
169
+ - With Codex as the author, a `changes` review and the final approval each woke it once, and its `pair wait` archived the approved thread.
170
+ - Approval policy `never` makes `pair_listen` fail, and Codex reports it and stops without polling.
171
+ - The MCP server is a child of the app-server daemon, not of the TUI. After the last TUI on a session quit, it ran 1–2 more minutes, until the daemon unloaded the session. A message in that gap still wakes the session with no TUI attached. Desktop and IDE sessions, `--no-daemon`, and `--remote` are untested.
172
+ - Completion-driven waiting is verified only for interactive Claude Code sessions, with waits of up to a few minutes. A full hour is untested. Headless `claude -p` ends background commands after its final result, so the prompt falls back to a foreground wait. Codex was tried with one long `pair wait` that it kept checking. Its built-in instructions cap each check at about 60 seconds, so an idle Codex made a model call every minute, enough to run a workspace out of credits. A finished background command doesn't resume Codex (measured with CLI 0.162.1), which is why Codex uses `pair mcp` instead.
package/README.md CHANGED
@@ -60,14 +60,74 @@ writable_roots = ["/Users/you/.pair"]
60
60
  "sandbox": { "filesystem": { "allowWrite": ["~/.pair"] } }
61
61
  ```
62
62
 
63
+ ### Let pair wake Codex (needed for automatic pickup)
64
+
65
+ Codex can't wait in the background the way Claude Code does: a finished background command doesn't
66
+ resume it, and keeping a `pair wait` running costs a model call for every check. So Codex doesn't
67
+ poll. Pair's MCP server wakes it when a message arrives. Without the server, Codex checks once, tells
68
+ you it can't be woken, and checks again only when you ask ("check pair"). Register the server once;
69
+ `pair init` prints this command with your actual paths:
70
+
71
+ ```sh
72
+ codex mcp add --env 'PAIR_CODEX=/opt/homebrew/bin/codex' pair -- '/path/to/node_modules/claude-codex-pair/dist/pair.js' mcp
73
+ ```
74
+
75
+ Codex then runs `pair register` in its shell and calls the `pair_listen` tool before it waits. The
76
+ MCP server watches the channel and runs `codex queue` with a fixed note when a message for Codex is
77
+ pending. The note never contains message text. Codex reads the message itself with `pair wait`.
78
+
79
+ - Point the command at the installed package, not at a checkout you edit. `PAIR_CODEX` pins the
80
+ `codex` executable the server runs. The server runs outside Codex's sandbox, so it never looks
81
+ `codex` up on `PATH`. It only reads the channel and keeps its own writes in a state directory
82
+ (see below). `pair init` won't pin a
83
+ `codex` that is in, or links into, the worktree, the channel directory, or a temp directory,
84
+ because agents can write there.
85
+ - Codex starts MCP servers with only a few variables from your shell, such as `HOME` and `PATH`.
86
+ If you set `PAIR_HOME` or `XDG_STATE_HOME`, `pair init` adds them to the command. If you change
87
+ them later, run `codex mcp remove pair` and add the server again.
88
+ - `pair_listen` needs MCP tool approval, so Codex's `never` approval policy is unsupported. With
89
+ `on-request`, approve the first call.
90
+ - Notifications stop when Codex runs `pair unregister` ("stop as reviewer"), or when Codex's
91
+ background service unloads the session, which happened 1–2 minutes after the last window on it
92
+ closed. A message that arrives in that gap still starts a turn with no window attached, and so does
93
+ a note that pair already queued. Say "stop as reviewer" before closing Codex if that matters.
94
+ - Verified end to end with Codex CLI 0.162.1 on the shared app-server daemon. A registered session
95
+ that ended its turn was woken by the note about 1s after Claude's request, and it reviewed and
96
+ replied in one turn. These also behaved as described:
97
+ - a message that arrived while Codex was closed was delivered right after resume;
98
+ - `pair unregister` stopped notifications;
99
+ - a forked session took over the registration;
100
+ - two windows on one session got one note;
101
+ - two worktrees each woke only their own Codex;
102
+ - with Codex as the author, a `changes` review and the final approval each woke it once, and its
103
+ `pair wait` archived the approved thread;
104
+ - the `never` approval policy failed.
105
+
106
+ Desktop and IDE sessions are untested.
107
+ - `pair status` shows the registered session and the listener's last event. The full log is
108
+ `~/.local/state/pair/<repo>-<hash>/codex.listen.log` (under `$XDG_STATE_HOME` if it is absolute).
109
+ The server writes only there, never in the channel, which agents can write. At 256 KB it becomes
110
+ `codex.listen.log.1`, replacing the previous one.
111
+
112
+ ### Start the agents
113
+
63
114
  After setup, start Claude Code and Codex in separate terminals in the same worktree of this repo. Each
64
115
  worktree has its own channel, so an agent started in another checkout won't see the messages
65
116
  (see [Worktrees](#worktrees)). Restart any sessions that were already open so they load the new
66
117
  instructions and sandbox settings.
67
118
 
119
+ ## Upgrading from 0.1.x
120
+
121
+ Codex no longer keeps a long `pair wait` running. To keep automatic pickup after upgrading, install
122
+ the new package with `npm install -g claude-codex-pair`, run `pair init` in each worktree, run the
123
+ printed `codex mcp add` command once, and restart Codex with an approval policy that permits MCP
124
+ tools (`-a on-request` worked in the live test). Without the MCP server, Codex checks once and then
125
+ checks again only when you ask it to "check pair", whether it is the author or reviewer.
126
+
68
127
  ## Use
69
128
 
70
- 1. In the reviewer's terminal, say **"be my reviewer"**. The agent loops on `pair wait`.
129
+ 1. In the reviewer's terminal, say **"be my reviewer"**. Claude waits on `pair wait` in a background
130
+ command. Codex registers with pair's MCP server and ends its turn until a message wakes it.
71
131
  2. In the author's terminal, finish a task and say **"ask codex for a review"** (or "ask claude").
72
132
  3. Watch them go back and forth. The author verifies each finding before fixing it, and can reject
73
133
  one with a reason.
@@ -99,11 +159,15 @@ The agents run `send` and `wait` themselves. You'll mostly use `init`, `status`,
99
159
 
100
160
  ```
101
161
  pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
102
- pair wait --as <claude|codex> [--timeout seconds] # prints "no message … yet" on timeout: run again
162
+ pair wait --as <claude|codex> [--timeout seconds] # prints "no message … yet" on timeout
103
163
  pair status
104
164
  pair history [thread-id] # list finished threads (latest first), or print one (any unique part of its id)
105
165
  pair clean --keep N # delete all but the N newest finished threads
106
166
  pair init
167
+ pair uninit # undo pair init in this worktree (keeps history; see "Uninstall")
168
+ pair register | unregister # run by Codex in its shell: let pair_listen notify this session
169
+ pair mcp # stdio MCP server for Codex (see "Let pair wake Codex")
170
+ pair ps # list running pair mcp and pair wait processes, where they run, and what started them
107
171
  ```
108
172
 
109
173
  `pair status` shows the channel, worktree root, installed package version, and each lane's turn.
@@ -112,12 +176,14 @@ It also checks the generated instructions on disk:
112
176
  ```
113
177
  channel: /Users/you/.pair/your-repo-12345678
114
178
  worktree: /Users/you/your-repo
115
- version: claude-codex-pair 0.1.0
179
+ version: claude-codex-pair 0.2.0
116
180
  claude-to-codex: idle
117
181
  codex-to-claude: idle
118
182
  instructions on disk (does not verify what running agents loaded):
119
183
  AGENTS.md (pair section): current
120
184
  .claude/skills/pair/SKILL.md: current
185
+ codex notifications: session 01a13922-c08f-79e3-a41f-6780876fd9c5, registered 2026-10-14T08:00:00.000Z
186
+ last listener event: 2026-10-14T08:05:12.345Z notified 01a13922-… about /Users/you/.pair/…/001-request.md
121
187
  ```
122
188
 
123
189
  Instruction states are `current`, `stale`, `missing`, or `unreadable`. The check compares only
@@ -133,11 +199,33 @@ so a cleanup problem doesn't prevent delivering a review or starting the next th
133
199
 
134
200
  | Env var | Default | Meaning |
135
201
  |---|---|---|
136
- | `PAIR_HOME` | `~/.pair` | Root directory for channels |
202
+ | `PAIR_HOME` | `~/.pair` | Root directory for channels. A relative path resolves against the worktree root; empty means the default |
137
203
  | `PAIR_AGENT` | (none) | Default for `--as` |
138
204
  | `PAIR_MAX_ROUNDS` | `3` | Number of review rounds before escalation |
139
205
  | `PAIR_KEEP` | `100` | Finished threads to keep, by finish time. Older ones are deleted whenever a thread finishes. `all` keeps everything, `0` keeps none |
140
206
 
207
+ ## Uninstall
208
+
209
+ In each worktree where you ran `pair init`:
210
+
211
+ ```sh
212
+ pair uninit
213
+ ```
214
+
215
+ This removes three things: `.claude/skills/pair/SKILL.md`, the pair section of `AGENTS.md` (or the
216
+ whole file, if it held nothing else), and the Codex session registration. It also cleans up files
217
+ written by older versions, and it leaves your own content alone: text outside the pair section stays
218
+ as it was, and a `SKILL.md` you edited is kept and reported. Message history stays in
219
+ `~/.pair/<repo>-<hash>/` and `~/.local/state/pair/<repo>-<hash>/`; `pair uninit` prints the
220
+ `rm -rf` command to delete it. Once no repo uses pair, also run:
221
+
222
+ ```sh
223
+ codex mcp remove pair
224
+ npm uninstall -g claude-codex-pair
225
+ ```
226
+
227
+ Then remove `~/.pair` from the sandbox settings you added during setup.
228
+
141
229
  ## Example review
142
230
 
143
231
  These are messages from a real run, shortened. The bug in round 1 was planted on purpose.
package/dist/pair.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
2
+ import { execFile, execFileSync } from "node:child_process";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
- import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
- import { homedir } from "node:os";
6
- import { basename, dirname, join, resolve } from "node:path";
4
+ import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
+ import { createInterface } from "node:readline";
7
8
  import { setTimeout as sleep } from "node:timers/promises";
8
9
  import { fileURLToPath } from "node:url";
9
10
  import { parseArgs } from "node:util";
@@ -19,11 +20,27 @@ const MESSAGE_FILE = /^(\d{3})-(request|review|response)\.md$/;
19
20
  // archive/<thread id>-<author>-to-<reviewer>-<outcome>; the id starts with a timestamp
20
21
  const ARCHIVED_THREAD = /^(.+)-(claude|codex)-to-(claude|codex)-(approved|escalated)$/;
21
22
  const PAIR_SECTION = /<!-- pair:start -->[\s\S]*?<!-- pair:end -->\n?/;
23
+ const PAIR_SECTIONS = /<!-- pair:start -->[\s\S]*?<!-- pair:end -->(?:\r?\n)?/g; // for removal, CRLF too
24
+ // SKILL.md as released versions wrote it (0.1.0 and 0.1.1), so `uninit` can tell it from an edited one.
25
+ const RELEASED_SKILL_SHA256 = ["e9afb64c53d5e90f935bcb66406601a31a91b37758467cf867713e245f8af288"];
22
26
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // any UUID version; Codex uses v7
28
+ const SESSION_FILE = "codex.session"; // the Codex session that pair_listen may notify, per channel
29
+ const LISTEN_LOG = "codex.listen.log";
30
+ const LISTEN_LOG_MAX_BYTES = 256 * 1024; // then it becomes codex.listen.log.1, replacing the previous one
31
+ const CLAIM_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
32
+ const STALL_MS = 10 * 60 * 1000;
33
+ const QUEUE_TIMEOUT_MS = 30_000;
34
+ const CLAIM_LEASE_MS = 2 * QUEUE_TIMEOUT_MS; // longer than any live `codex queue` call can hold a claim
35
+ // Fixed text on purpose: nothing from the channel may reach Codex as user input.
36
+ const NUDGE = "pair: a message is waiting for you. Run `pair wait --as codex --timeout 5`, act on what it prints as the pair instructions in AGENTS.md describe, send your reply if one is due, then end your turn. Don't start another pair wait.";
23
37
  class PairError extends Error {
24
38
  }
39
+ function hasCode(error, code) {
40
+ return error instanceof Error && "code" in error && error.code === code;
41
+ }
25
42
  function isMissing(error) {
26
- return error instanceof Error && "code" in error && error.code === "ENOENT";
43
+ return hasCode(error, "ENOENT");
27
44
  }
28
45
  function sandboxConfig(channel) {
29
46
  const pairHome = JSON.stringify(dirname(channel));
@@ -115,11 +132,16 @@ function gitPath(flag) {
115
132
  function worktreeRoot() {
116
133
  return realpathSync(gitPath("--show-toplevel") ?? process.cwd());
117
134
  }
135
+ /** A relative PAIR_HOME resolves against the worktree root, not cwd, so every subdirectory finds one channel. */
136
+ function pairHome(root) {
137
+ const configured = process.env.PAIR_HOME;
138
+ return configured ? resolve(root, configured) : join(homedir(), ".pair"); // empty means unset
139
+ }
118
140
  function channelDir(root) {
119
141
  // One channel per worktree: a reviewer reads the author's files, so both must share a checkout.
120
142
  // realpath so /tmp/x and /private/tmp/x (or any symlinked checkout) share one channel.
121
143
  const hash = createHash("sha1").update(root).digest("hex").slice(0, 8);
122
- return join(resolve(process.env.PAIR_HOME ?? join(homedir(), ".pair")), `${basename(root)}-${hash}`);
144
+ return join(pairHome(root), `${basename(root)}-${hash}`);
123
145
  }
124
146
  function parseFrontmatter(text) {
125
147
  const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
@@ -137,7 +159,7 @@ function parseFrontmatter(text) {
137
159
  }
138
160
  /** Parses a stored message and rejects anything the CLI would not have written. */
139
161
  function readMessage(path, thread) {
140
- const { fields, body } = parseFrontmatter(readFileSync(path, "utf8"));
162
+ const { fields, body } = parseFrontmatter(readChannelFile(path));
141
163
  const corrupt = `corrupt message ${path}:`;
142
164
  const type = oneOf(fields.type, MESSAGE_TYPES, `${corrupt} type`);
143
165
  if (!basename(path).endsWith(`-${type}.md`)) {
@@ -367,6 +389,417 @@ async function wait(channel, me, timeoutSeconds) {
367
389
  await sleep(POLL_MS);
368
390
  }
369
391
  }
392
+ /**
393
+ * `pair mcp` runs outside the agents' sandboxes, but agents can write the channel. So the files it
394
+ * opens there must never be symlinks, or an agent could make it read or write any file.
395
+ */
396
+ function openChannelFile(path, flags) {
397
+ try {
398
+ return openSync(path, flags | constants.O_NOFOLLOW, 0o600);
399
+ }
400
+ catch (error) {
401
+ if (hasCode(error, "ELOOP")) {
402
+ throw new PairError(`refusing to follow the symlink at ${path}`);
403
+ }
404
+ throw error;
405
+ }
406
+ }
407
+ function readChannelFile(path) {
408
+ const fd = openChannelFile(path, constants.O_RDONLY);
409
+ try {
410
+ return readFileSync(fd, "utf8");
411
+ }
412
+ finally {
413
+ closeSync(fd);
414
+ }
415
+ }
416
+ function writeChannelFile(path, text, flags) {
417
+ const fd = openChannelFile(path, constants.O_WRONLY | constants.O_CREAT | flags);
418
+ try {
419
+ writeSync(fd, text);
420
+ }
421
+ finally {
422
+ closeSync(fd);
423
+ }
424
+ }
425
+ function readRegistration(channel) {
426
+ const path = join(channel, SESSION_FILE);
427
+ let parsed;
428
+ try {
429
+ parsed = JSON.parse(readChannelFile(path));
430
+ }
431
+ catch (error) {
432
+ if (isMissing(error)) {
433
+ return undefined;
434
+ }
435
+ throw error instanceof SyntaxError ? new PairError(`corrupt registration ${path}: ${error.message}`) : error;
436
+ }
437
+ const { thread, worktree, registeredAt } = parsed;
438
+ if (typeof thread !== "string" || !SESSION_ID.test(thread) || typeof worktree !== "string" || typeof registeredAt !== "string") {
439
+ throw new PairError(`corrupt registration ${path}: expected thread, worktree and registeredAt`);
440
+ }
441
+ return { thread, worktree, registeredAt };
442
+ }
443
+ /** The calling Codex session, or undefined outside Codex. */
444
+ function codexThreadId() {
445
+ const id = process.env.CODEX_THREAD_ID;
446
+ if (id === undefined || id === "") {
447
+ return undefined;
448
+ }
449
+ if (!SESSION_ID.test(id)) {
450
+ throw new PairError(`CODEX_THREAD_ID is not a session id (got "${id}")`);
451
+ }
452
+ return id;
453
+ }
454
+ /** Runs in Codex's shell, so the worktree comes from the session's real cwd, not from a tool argument. */
455
+ function register(channel, root) {
456
+ const thread = codexThreadId();
457
+ if (!thread) {
458
+ throw new PairError("register must run in a Codex session's shell: CODEX_THREAD_ID is not set");
459
+ }
460
+ const previous = readRegistration(channel);
461
+ mkdirSync(channel, { recursive: true });
462
+ const registration = { thread, worktree: root, registeredAt: new Date().toISOString() };
463
+ const tmpPath = join(channel, `.${SESSION_FILE}.${randomBytes(3).toString("hex")}.tmp`);
464
+ writeFileSync(tmpPath, `${JSON.stringify(registration)}\n`);
465
+ renameSync(tmpPath, join(channel, SESSION_FILE));
466
+ const replaced = previous && previous.thread !== thread ? ` (replaced ${previous.thread})` : "";
467
+ return `registered codex session ${thread} for ${root}${replaced}. Now call the pair_listen tool.`;
468
+ }
469
+ function unregister(channel) {
470
+ const current = readRegistration(channel);
471
+ if (!current) {
472
+ return "no codex session is registered for this worktree";
473
+ }
474
+ const caller = codexThreadId();
475
+ // An old session saying "stop" must not silence a newer one. A shell outside Codex is the user.
476
+ if (caller !== undefined && caller !== current.thread) {
477
+ throw new PairError(`a different codex session (${current.thread}) is registered here; nothing changed`);
478
+ }
479
+ // ponytail: check-then-delete; a re-register in the same instant would be removed too. status shows it.
480
+ rmSync(join(channel, SESSION_FILE), { force: true });
481
+ return `unregistered codex session ${current.thread}: pair stops notifying it`;
482
+ }
483
+ /** Keeps the log to at most two files, so a long-lived channel doesn't grow without bound. */
484
+ function rotateLog(path) {
485
+ const stats = lstatSync(path, { throwIfNoEntry: false });
486
+ if (stats && !stats.isFile()) {
487
+ rmSync(path, { force: true }); // a symlink planted here: remove the link itself, never its target
488
+ return;
489
+ }
490
+ if ((stats?.size ?? 0) <= LISTEN_LOG_MAX_BYTES) {
491
+ return;
492
+ }
493
+ try {
494
+ renameSync(path, `${path}.1`);
495
+ }
496
+ catch (error) {
497
+ if (!isMissing(error)) {
498
+ throw error; // ENOENT: another server rotated it first
499
+ }
500
+ }
501
+ }
502
+ /**
503
+ * Where `pair mcp` keeps its own files, the log and the claims: outside every agent's writable roots.
504
+ * Agents can write the channel, and could swap a directory on a channel path for a symlink between
505
+ * any check and the write that follows, so the unsandboxed server only ever reads channels.
506
+ */
507
+ function stateDir(channel) {
508
+ const configured = process.env.XDG_STATE_HOME;
509
+ const root = configured && isAbsolute(configured) ? configured : join(homedir(), ".local", "state");
510
+ return join(resolve(root), "pair", basename(channel));
511
+ }
512
+ function logListen(channel, text) {
513
+ try {
514
+ const path = join(stateDir(channel), LISTEN_LOG);
515
+ mkdirSync(dirname(path), { recursive: true });
516
+ rotateLog(path);
517
+ const singleLine = JSON.stringify(text).slice(1, -1); // escape control characters in agent-chosen paths
518
+ writeChannelFile(path, `${new Date().toISOString()} ${singleLine}\n`, constants.O_APPEND);
519
+ }
520
+ catch (error) {
521
+ console.error(`pair: cannot write ${LISTEN_LOG}: ${String(error)}\n${text}`); // stderr: stdout is the MCP stream
522
+ }
523
+ }
524
+ /**
525
+ * The MCP server runs outside Codex's sandbox, so it must not run whatever `codex` PATH points at:
526
+ * an agent-writable PATH entry would turn the fixed queue call into unsandboxed code execution.
527
+ */
528
+ function pinnedCodex() {
529
+ const path = process.env.PAIR_CODEX;
530
+ if (!path || !isAbsolute(path)) {
531
+ throw new PairError("PAIR_CODEX must be the absolute path of the codex executable. Re-add the MCP server with the command `pair init` prints.");
532
+ }
533
+ return path;
534
+ }
535
+ function realpathOrSelf(path) {
536
+ try {
537
+ return realpathSync(path);
538
+ }
539
+ catch {
540
+ return path; // doesn't exist (yet); compare it as written
541
+ }
542
+ }
543
+ /**
544
+ * For `init`: the codex a shell here would run, unless it is, or links to, a file in a place agents
545
+ * can write: the worktree, the channels, or a temporary directory.
546
+ */
547
+ function findCodex(root) {
548
+ const untrusted = [root, pairHome(root), tmpdir(), process.env.TMPDIR, "/tmp", "/var/tmp"]
549
+ .filter((dir) => Boolean(dir))
550
+ .flatMap((dir) => [resolve(dir), realpathOrSelf(dir)]);
551
+ const agentWritable = (path) => untrusted.some((dir) => path === dir || path.startsWith(`${dir}${sep}`));
552
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
553
+ if (!isAbsolute(dir)) {
554
+ continue;
555
+ }
556
+ const candidate = join(dir, "codex");
557
+ try {
558
+ accessSync(candidate, constants.X_OK);
559
+ }
560
+ catch {
561
+ continue; // not in this PATH entry
562
+ }
563
+ return agentWritable(candidate) || agentWritable(realpathSync(candidate)) ? undefined : candidate;
564
+ }
565
+ return undefined;
566
+ }
567
+ /** Finds the one channel where this session ran `pair register`. Thread id comes from trusted call metadata. */
568
+ function listen(thread, watches) {
569
+ if (typeof thread !== "string" || !SESSION_ID.test(thread)) {
570
+ throw new PairError("the tool call carried no Codex session id (_meta.threadId), so pair can't notify this session");
571
+ }
572
+ const codex = pinnedCodex();
573
+ const home = pairHome(process.cwd()); // the server has no worktree; `pair init` passes an absolute PAIR_HOME
574
+ const registered = readdirOrEmpty(home)
575
+ .filter((entry) => entry.isDirectory())
576
+ .map((entry) => join(home, entry.name))
577
+ .filter((channel) => {
578
+ try {
579
+ return readRegistration(channel)?.thread === thread;
580
+ }
581
+ catch {
582
+ return false; // another channel's corrupt registration is that channel's problem; status reports it
583
+ }
584
+ });
585
+ if (registered.length === 0) {
586
+ throw new PairError("this session isn't registered: run `pair register` in your shell, then call pair_listen again");
587
+ }
588
+ if (registered.length > 1) {
589
+ throw new PairError(`this session is registered in ${registered.length} channels (${registered.join(", ")}); run \`pair unregister\` in the worktrees you don't want`);
590
+ }
591
+ const [channel] = registered;
592
+ if (watches.get(thread)?.channel !== channel) {
593
+ watches.set(thread, { channel, codex, notifiedAt: 0, stallReported: false, dispatching: false, failures: 0, retryAt: 0 });
594
+ logListen(channel, `listening for ${thread}`);
595
+ pruneClaims(channel);
596
+ }
597
+ return `listening: pair will queue a short "pair:" note into this session when a message for codex arrives in ${channel}. End your turn now.`;
598
+ }
599
+ /**
600
+ * One nudge per session and message, even with several MCP processes on one session. It says
601
+ * "sending" while a server holds the right to send, and "sent" once `codex queue` succeeded.
602
+ */
603
+ function claimPath(channel, thread, pending) {
604
+ return join(stateDir(channel), "claims", `${basename(dirname(pending))}.${basename(pending)}.${thread}`);
605
+ }
606
+ /** Claims outlive their threads, so old ones are dropped. A still-pending message may get one more note. */
607
+ function pruneClaims(channel) {
608
+ const dir = join(stateDir(channel), "claims");
609
+ try {
610
+ for (const entry of readdirOrEmpty(dir)) {
611
+ const path = join(dir, entry.name);
612
+ const stats = lstatSync(path, { throwIfNoEntry: false });
613
+ if (stats && Date.now() - stats.mtimeMs > CLAIM_MAX_AGE_MS) {
614
+ rmSync(path, { force: true });
615
+ }
616
+ }
617
+ }
618
+ catch (error) {
619
+ logListen(channel, `couldn't prune old claims: ${String(error)}`);
620
+ }
621
+ }
622
+ /** Exclusive create: true if this server now holds the claim. O_EXCL never follows a symlink. */
623
+ function createClaim(path) {
624
+ mkdirSync(dirname(path), { recursive: true });
625
+ try {
626
+ writeChannelFile(path, "sending\n", constants.O_EXCL);
627
+ return true;
628
+ }
629
+ catch (error) {
630
+ if (!hasCode(error, "EEXIST")) {
631
+ throw error;
632
+ }
633
+ return false;
634
+ }
635
+ }
636
+ function claim(path) {
637
+ if (createClaim(path)) {
638
+ return "claimed";
639
+ }
640
+ const stats = lstatSync(path);
641
+ if (stats.isFile() && readChannelFile(path).trim() === "sent") {
642
+ return "sent";
643
+ }
644
+ if (stats.isFile() && Date.now() - stats.mtimeMs < CLAIM_LEASE_MS) {
645
+ return "busy"; // another server is sending; if it fails it removes the claim and we try again
646
+ }
647
+ // Its sender died mid-send, or this isn't a plain file (say, a planted symlink). Unlinking removes
648
+ // the entry itself, never a link's target. ponytail: two servers taking over at the same moment
649
+ // may both send, which costs one extra short turn; a lock for the takeover would be the upgrade.
650
+ rmSync(path, { force: true });
651
+ return createClaim(path) ? "claimed" : "busy";
652
+ }
653
+ /** Replaces the claim's directory entry, so a symlink put there is replaced, not written through. */
654
+ function markSent(path) {
655
+ const tmpPath = `${path}.${randomBytes(3).toString("hex")}.tmp`;
656
+ writeChannelFile(tmpPath, "sent\n", constants.O_EXCL);
657
+ renameSync(tmpPath, path);
658
+ }
659
+ function dispatch(thread, watch, pending) {
660
+ watch.dispatching = true;
661
+ const child = execFile(watch.codex, ["queue", "--thread", thread, "--message", NUDGE], { timeout: QUEUE_TIMEOUT_MS }, (error, _stdout, stderr) => {
662
+ watch.dispatching = false;
663
+ if (error) {
664
+ rmSync(claimPath(watch.channel, thread, pending), { force: true }); // let the retry, here or in another process, claim it again
665
+ watch.failures += 1;
666
+ const delay = Math.min(60_000, 1000 * 2 ** watch.failures);
667
+ watch.retryAt = Date.now() + delay;
668
+ logListen(watch.channel, `codex queue failed for ${pending}, retrying in ${delay / 1000}s: ${stderr.trim() || error.message}`);
669
+ return;
670
+ }
671
+ Object.assign(watch, { notified: pending, notifiedAt: Date.now(), stallReported: false, failures: 0 });
672
+ logListen(watch.channel, `notified ${thread} about ${pending}`);
673
+ try {
674
+ markSent(claimPath(watch.channel, thread, pending));
675
+ }
676
+ catch (claimError) {
677
+ if (!isMissing(claimError)) {
678
+ logListen(watch.channel, `couldn't mark ${pending} as sent, so another server may send it again: ${String(claimError)}`);
679
+ }
680
+ }
681
+ });
682
+ child.stdin?.end();
683
+ }
684
+ /** One poll for one session. Never archives: reading a closed thread stays the agent's own `pair wait`. */
685
+ function tick(thread, watch, watches) {
686
+ if (watch.dispatching || Date.now() < watch.retryAt) {
687
+ return;
688
+ }
689
+ try {
690
+ if (readRegistration(watch.channel)?.thread !== thread) {
691
+ watches.delete(thread);
692
+ logListen(watch.channel, `stopped notifying ${thread}: it is no longer the registered session`);
693
+ return;
694
+ }
695
+ const pending = pendingFor(watch.channel, "codex")?.state.last.file;
696
+ if (pending === undefined) {
697
+ return;
698
+ }
699
+ if (pending !== watch.notified) {
700
+ let claimed;
701
+ try {
702
+ claimed = claim(claimPath(watch.channel, thread, pending));
703
+ }
704
+ catch (error) {
705
+ if (isMissing(error)) {
706
+ return; // another server released the claim since we looked: look again next tick
707
+ }
708
+ throw error;
709
+ }
710
+ if (claimed === "sent") {
711
+ // Sent by another MCP process on this session, or by an earlier run of this one.
712
+ Object.assign(watch, { notified: pending, notifiedAt: Date.now(), stallReported: false });
713
+ }
714
+ else if (claimed === "claimed") {
715
+ dispatch(thread, watch, pending);
716
+ }
717
+ return;
718
+ }
719
+ if (!watch.stallReported && Date.now() - watch.notifiedAt > STALL_MS) {
720
+ watch.stallReported = true;
721
+ logListen(watch.channel, `no reply to ${pending} after 10 minutes. The review may just be long, or the turn failed, was cancelled, or waits for an approval. Pair won't notify again; ask Codex to check pair.`);
722
+ }
723
+ }
724
+ catch (error) {
725
+ watch.retryAt = Date.now() + 60_000;
726
+ logListen(watch.channel, `poll failed, retrying in 60s: ${error instanceof Error ? error.message : String(error)}`);
727
+ }
728
+ }
729
+ const LISTEN_TOOL = {
730
+ name: "pair_listen",
731
+ description: "Have pair notify this Codex session when a pair message for codex arrives, so you can end your turn instead of polling. Run `pair register` in your shell first. Takes no arguments.",
732
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
733
+ };
734
+ function mcpResult(request, watches) {
735
+ switch (request.method) {
736
+ case "initialize": {
737
+ const { version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
738
+ return {
739
+ protocolVersion: request.params?.protocolVersion ?? "2025-06-18",
740
+ capabilities: { tools: {} },
741
+ serverInfo: { name: "pair", version },
742
+ };
743
+ }
744
+ case "ping":
745
+ return {};
746
+ case "tools/list":
747
+ return { tools: [LISTEN_TOOL] };
748
+ case "tools/call": {
749
+ if (request.params?.name !== LISTEN_TOOL.name) {
750
+ return { content: [{ type: "text", text: `unknown tool: ${String(request.params?.name)}` }], isError: true };
751
+ }
752
+ try {
753
+ return { content: [{ type: "text", text: listen(request.params._meta?.threadId, watches) }] };
754
+ }
755
+ catch (error) {
756
+ if (!(error instanceof PairError)) {
757
+ throw error;
758
+ }
759
+ return { content: [{ type: "text", text: `pair: ${error.message}` }], isError: true };
760
+ }
761
+ }
762
+ default:
763
+ return undefined;
764
+ }
765
+ }
766
+ /** stdio MCP server. Its watches live and die with this process, which Codex ends with the session. */
767
+ async function serveMcp() {
768
+ const watches = new Map();
769
+ const timer = setInterval(() => {
770
+ for (const [thread, watch] of watches) {
771
+ tick(thread, watch, watches);
772
+ }
773
+ }, POLL_MS);
774
+ const respond = (message) => {
775
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
776
+ };
777
+ for await (const line of createInterface({ input: process.stdin })) {
778
+ if (!line.trim()) {
779
+ continue;
780
+ }
781
+ let request;
782
+ try {
783
+ request = JSON.parse(line);
784
+ }
785
+ catch {
786
+ respond({ id: null, error: { code: -32700, message: "parse error" } });
787
+ continue;
788
+ }
789
+ if (request.id === undefined || request.id === null) {
790
+ continue; // a notification, such as notifications/initialized
791
+ }
792
+ try {
793
+ const result = mcpResult(request, watches);
794
+ respond(result === undefined ? { id: request.id, error: { code: -32601, message: `method not found: ${request.method}` } } : { id: request.id, result });
795
+ }
796
+ catch (error) {
797
+ // Report it to Codex instead of dying: a crashed server would silently stop every watch.
798
+ respond({ id: request.id, error: { code: -32603, message: error instanceof Error ? error.message : String(error) } });
799
+ }
800
+ }
801
+ clearInterval(timer); // stdin closed: the session is gone, so stop notifying it
802
+ }
370
803
  function describe(channel, author) {
371
804
  const thread = threadFor(channel, author);
372
805
  const state = stateOf(listMessages(thread));
@@ -393,6 +826,32 @@ function instructionState(path, expected, sectionOnly = false) {
393
826
  return `unreadable (${reason})`;
394
827
  }
395
828
  }
829
+ function notificationStatus(channel) {
830
+ let registration;
831
+ try {
832
+ const current = readRegistration(channel);
833
+ registration = current ? `session ${current.thread}, registered ${current.registeredAt}` : "no session registered";
834
+ }
835
+ catch (error) {
836
+ if (!(error instanceof PairError)) {
837
+ throw error;
838
+ }
839
+ registration = error.message;
840
+ }
841
+ let lastEvent;
842
+ try {
843
+ lastEvent = readChannelFile(join(stateDir(channel), LISTEN_LOG)).trimEnd().split("\n").at(-1);
844
+ }
845
+ catch (error) {
846
+ if (error instanceof PairError) {
847
+ lastEvent = error.message;
848
+ }
849
+ else if (!isMissing(error)) {
850
+ throw error;
851
+ }
852
+ }
853
+ return [`codex notifications: ${registration}`, ...(lastEvent ? [` last listener event: ${lastEvent}`] : [])];
854
+ }
396
855
  function status(channel, root) {
397
856
  const lanes = channelAccess(channel, () => AGENTS.map((agent) => describe(channel, agent)));
398
857
  const { name, version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
@@ -406,6 +865,7 @@ function status(channel, root) {
406
865
  "instructions on disk (does not verify what running agents loaded):",
407
866
  ` AGENTS.md (pair section): ${agents}`,
408
867
  ` .claude/skills/pair/SKILL.md: ${skill}`,
868
+ ...channelAccess(channel, () => notificationStatus(channel)),
409
869
  ...(agents === "missing" || agents === "stale" || skill === "missing" || skill === "stale"
410
870
  ? ["Run `pair init` to refresh missing or stale instructions, then reload agent sessions."] : []),
411
871
  ...(agents.startsWith("unreadable") || skill.startsWith("unreadable")
@@ -513,8 +973,167 @@ function init(channel, root) {
513
973
  `wrote: ${agentsPath} (pair section)`,
514
974
  "",
515
975
  sandboxConfig(channel),
976
+ "",
977
+ "To let pair wake Codex when a message arrives, instead of Codex polling, register pair's MCP server once:",
978
+ ` ${mcpAddCommand(root)}`,
516
979
  ].join("\n");
517
980
  }
981
+ /** Codex starts MCP servers with only HOME, PATH and a few more, so the command carries what `pair mcp` reads. */
982
+ function mcpAddCommand(root) {
983
+ const stateHome = process.env.XDG_STATE_HOME;
984
+ const env = [
985
+ ...(process.env.PAIR_HOME ? [`PAIR_HOME=${pairHome(root)}`] : []),
986
+ ...(stateHome && isAbsolute(stateHome) ? [`XDG_STATE_HOME=${resolve(stateHome)}`] : []),
987
+ `PAIR_CODEX=${findCodex(root) ?? "<absolute path of codex>"}`,
988
+ ];
989
+ const flags = env.map((entry) => `--env ${shellQuote(entry)}`).join(" ");
990
+ return `codex mcp add ${flags} pair -- ${shellQuote(join(PACKAGE_ROOT, "dist", "pair.js"))} mcp`;
991
+ }
992
+ /**
993
+ * `init` appends its section after exactly one added line ending. When the last section ends the file,
994
+ * drop it with that one line ending, so the user's text ends exactly as before, final newline or not.
995
+ */
996
+ function withoutAppendedSection(text) {
997
+ const end = text.lastIndexOf("<!-- pair:end -->");
998
+ if (end === -1 || text.slice(end + "<!-- pair:end -->".length).trim() !== "") {
999
+ return text;
1000
+ }
1001
+ const start = text.lastIndexOf("<!-- pair:start -->", end);
1002
+ return start === -1 ? text : text.slice(0, start).replace(/\r?\n$/, "");
1003
+ }
1004
+ /** Single-quoted for sh: the channel name comes from the worktree's name, which may hold `$(…)`. */
1005
+ function shellQuote(text) {
1006
+ return `'${text.replaceAll("'", `'\\''`)}'`;
1007
+ }
1008
+ /**
1009
+ * Undoes `init` in this worktree. Every version so far wrote the same two paths and section markers,
1010
+ * so this also cleans up after older versions. History and global setup are only reported.
1011
+ */
1012
+ function uninit(channel, root) {
1013
+ const removed = [];
1014
+ const notes = [];
1015
+ const skillDir = join(root, ".claude", "skills", "pair");
1016
+ const skillPath = join(skillDir, "SKILL.md");
1017
+ if (existsSync(skillPath)) {
1018
+ const skill = readFileSync(skillPath, "utf8");
1019
+ if (skill === claudeSkill() || RELEASED_SKILL_SHA256.includes(createHash("sha256").update(skill).digest("hex"))) {
1020
+ rmSync(skillPath);
1021
+ removed.push(skillPath);
1022
+ // Drop the directories init created, up to the first one that holds something else.
1023
+ for (const dir of [skillDir, dirname(skillDir), dirname(dirname(skillDir))]) {
1024
+ try {
1025
+ rmdirSync(dir);
1026
+ }
1027
+ catch {
1028
+ break; // not empty: someone else's files live there
1029
+ }
1030
+ }
1031
+ }
1032
+ else {
1033
+ notes.push(`kept: ${skillPath} (it isn't a file pair init wrote, so it may hold your changes; delete it yourself if not)`);
1034
+ }
1035
+ }
1036
+ const agentsPath = join(root, "AGENTS.md");
1037
+ const agents = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
1038
+ if (PAIR_SECTION.test(agents)) {
1039
+ const rest = withoutAppendedSection(agents).replace(PAIR_SECTIONS, "");
1040
+ if (rest.trim() === "") {
1041
+ rmSync(agentsPath);
1042
+ removed.push(`${agentsPath} (it held only the pair section)`);
1043
+ }
1044
+ else {
1045
+ writeFileSync(agentsPath, rest);
1046
+ removed.push(`the pair section of ${agentsPath}`);
1047
+ }
1048
+ }
1049
+ const registration = join(channel, SESSION_FILE);
1050
+ if (channelAccess(channel, () => lstatSync(registration, { throwIfNoEntry: false }))) {
1051
+ channelAccess(channel, () => rmSync(registration, { force: true }));
1052
+ removed.push("the codex session registration, so pair stops notifying it");
1053
+ }
1054
+ const history = [channel, stateDir(channel)].filter((dir) => existsSync(dir));
1055
+ if (history.length > 0) {
1056
+ notes.push(`kept: message history. To delete it: rm -rf ${history.map(shellQuote).join(" ")}`);
1057
+ }
1058
+ notes.push("If no repo uses pair anymore: run `codex mcp remove pair`, then `npm uninstall -g claude-codex-pair`, and remove the pair home from the sandbox settings `pair init` printed.");
1059
+ const summary = removed.length > 0 ? removed.map((item) => `removed: ${item}`) : [`nothing from pair init found in ${root}`];
1060
+ return [...summary, ...notes].join("\n");
1061
+ }
1062
+ const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\w{3} \w{3}\s+\d+ [\d:]+ \d{4})\s+(.*)$/; // lstart is always 5 fields
1063
+ // node itself runs the script, so a wrapper whose command line mentions it (`zsh -c "… pair.js wait"`) doesn't match
1064
+ const PAIR_PROCESS = /^(?:\S*\/)?node(?: -\S+)* \S*\/(?:bin\/pair|dist\/pair\.js) ((?:mcp|wait)\b.*)$/;
1065
+ const SHELL = /^(?:\S*\/)?(?:zsh|bash|sh|dash|fish)(?: |$)/;
1066
+ function listProcesses() {
1067
+ let out;
1068
+ try {
1069
+ out = execFileSync("ps", ["-axo", "pid=,ppid=,lstart=,command="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } });
1070
+ }
1071
+ catch (error) {
1072
+ throw new PairError(`can't list processes (${error instanceof Error ? error.message : String(error)}); agent sandboxes often block ps, so run pair ps in your own terminal`);
1073
+ }
1074
+ return out.split("\n").flatMap((line) => {
1075
+ const match = PS_LINE.exec(line);
1076
+ return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), started: match[3], command: match[4] }] : [];
1077
+ });
1078
+ }
1079
+ /** Working directories by pid: /proc on Linux, lsof elsewhere. Unknown ones are left out. */
1080
+ function workingDirs(pids) {
1081
+ const dirs = new Map();
1082
+ if (existsSync("/proc/self/cwd")) {
1083
+ for (const pid of pids) {
1084
+ try {
1085
+ dirs.set(pid, realpathSync(`/proc/${pid}/cwd`));
1086
+ }
1087
+ catch {
1088
+ // gone, or not ours
1089
+ }
1090
+ }
1091
+ return dirs;
1092
+ }
1093
+ let out = "";
1094
+ try {
1095
+ out = execFileSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
1096
+ }
1097
+ catch (error) {
1098
+ // lsof exits 1 when one of the pids is gone, but still prints the rest
1099
+ out = error instanceof Error && "stdout" in error ? String(error.stdout) : "";
1100
+ }
1101
+ let pid = 0;
1102
+ for (const line of out.split("\n")) {
1103
+ if (line.startsWith("p")) {
1104
+ pid = Number(line.slice(1));
1105
+ }
1106
+ else if (line.startsWith("n")) {
1107
+ dirs.set(pid, line.slice(1));
1108
+ }
1109
+ }
1110
+ return dirs;
1111
+ }
1112
+ /** Every running `pair mcp` and `pair wait` on this machine, with what started it. */
1113
+ function ps() {
1114
+ const all = listProcesses();
1115
+ const byPid = new Map(all.map((proc) => [proc.pid, proc]));
1116
+ const ours = all.flatMap((proc) => {
1117
+ const match = PAIR_PROCESS.exec(proc.command);
1118
+ return match ? [{ ...proc, args: match[1] }] : [];
1119
+ });
1120
+ if (ours.length === 0) {
1121
+ return "no pair mcp or pair wait is running";
1122
+ }
1123
+ const dirs = workingDirs(ours.map((proc) => proc.pid));
1124
+ return ours
1125
+ .flatMap((proc) => {
1126
+ let host = byPid.get(proc.ppid);
1127
+ while (host && SHELL.test(host.command)) {
1128
+ host = byPid.get(host.ppid); // an agent runs pair wait through a shell; show the agent
1129
+ }
1130
+ return [
1131
+ `pair ${proc.args} pid ${proc.pid} since ${proc.started} in ${dirs.get(proc.pid) ?? "?"}`,
1132
+ ` started by ${host ? `${host.pid}: ${host.command.slice(0, 160)}` : `${proc.ppid} (gone)`}`,
1133
+ ];
1134
+ })
1135
+ .join("\n");
1136
+ }
518
1137
  function readBody(source) {
519
1138
  if (!source) {
520
1139
  throw new PairError("send needs a message file, or - for stdin");
@@ -536,11 +1155,15 @@ function oneOf(value, allowed, what) {
536
1155
  }
537
1156
  const USAGE = `usage:
538
1157
  pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
539
- pair wait --as <claude|codex> [--timeout seconds] (prints "no message … yet" on timeout: run again)
1158
+ pair wait --as <claude|codex> [--timeout seconds] (prints "no message … yet" on timeout)
540
1159
  pair status
541
1160
  pair history [thread-id] (list finished threads, or show one)
542
1161
  pair clean --keep N (delete all but the N newest finished threads)
543
- pair init`;
1162
+ pair init
1163
+ pair uninit (remove what pair init wrote in this worktree; keeps history)
1164
+ pair register | unregister (in Codex's shell: let pair_listen notify this session)
1165
+ pair mcp (stdio MCP server for Codex, providing pair_listen)
1166
+ pair ps (list running pair mcp and pair wait processes)`;
544
1167
  async function main(argv) {
545
1168
  const { values, positionals } = parseArgs({
546
1169
  args: argv,
@@ -566,8 +1189,9 @@ async function main(argv) {
566
1189
  const me = agent();
567
1190
  const timeout = positiveNumber(values.timeout ?? String(DEFAULT_WAIT_SECONDS), "--timeout");
568
1191
  if (!(await wait(channel, me, timeout))) {
569
- // Exit 0: a timeout is routine, and agent UIs show any non-zero exit as a failure.
570
- console.log(`pair: no message for ${me} yet. Run \`pair wait --as ${me}\` again.`);
1192
+ // Exit 0: a timeout is routine, and agent UIs show any non-zero exit as a failure. No "run it
1193
+ // again" here: whether to wait again differs by agent (Codex must not poll), so the prompt says.
1194
+ console.log(`pair: no message for ${me} yet.`);
571
1195
  }
572
1196
  return 0;
573
1197
  }
@@ -583,6 +1207,21 @@ async function main(argv) {
583
1207
  case "init":
584
1208
  console.log(init(channel, root));
585
1209
  return 0;
1210
+ case "uninit":
1211
+ console.log(uninit(channel, root));
1212
+ return 0;
1213
+ case "register":
1214
+ console.log(channelAccess(channel, () => register(channel, root)));
1215
+ return 0;
1216
+ case "unregister":
1217
+ console.log(channelAccess(channel, () => unregister(channel)));
1218
+ return 0;
1219
+ case "mcp":
1220
+ await serveMcp();
1221
+ return 0;
1222
+ case "ps":
1223
+ console.log(ps());
1224
+ return 0;
586
1225
  default:
587
1226
  console.error(USAGE);
588
1227
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code and Codex review each other's work over a file-system chat channel",
5
5
  "keywords": [
6
6
  "claude-code",
package/prompts/pair.md CHANGED
@@ -13,13 +13,17 @@ model turns:
13
13
  - If you are Claude Code in an interactive session: run `pair wait --as {{AGENT}} --timeout 3600` as
14
14
  a background command (`run_in_background: true`) and end your turn. You'll be resumed when it exits.
15
15
  Then read its output.
16
- - If you are Codex: start `pair wait --as {{AGENT}} --timeout 3600` once and keep it running. Check
17
- on it as your tools allow, keeping each check within any wait limit in your instructions (for
18
- example, 55 seconds). A limit on checks doesn't limit how long the command runs, unless your
19
- instructions also limit command lifetime. Don't start another `pair wait` until the running one
20
- exits, and re-run it only when it times out (see below). If a check is interrupted, the command
21
- may still be running: keep checking that session, or stop it and confirm it exited before starting
22
- a new one.
16
+ - If you are Codex: run `pair register` in your shell, call the `pair_listen` tool, then end your
17
+ turn. Never keep a `pair wait` running or poll for messages: each check costs a model call. Do this
18
+ before your first wait in a session, and again after the session restarts or resumes; repeating it
19
+ is harmless. When a message arrives, pair queues a short note starting with `pair:` into this
20
+ session. Then run `pair wait --as {{AGENT}} --timeout 5`, act on what it prints, send your reply if
21
+ one is due, and end your turn again. If it prints `pair: no message for`, there's nothing to do:
22
+ end your turn.
23
+ If you don't have `pair_listen`, or `pair register` or `pair_listen` fails, run
24
+ `pair wait --as {{AGENT}} --timeout 5` once and act on any message. Then tell the user that pair
25
+ can't wake you, quoting the error if there was one, and that `pair init` prints the setup. End your
26
+ turn. When the user asks you to check pair, run `pair wait --as {{AGENT}} --timeout 5` once again.
23
27
  - Otherwise, including headless `claude -p` or when background commands are unavailable: run
24
28
  `pair wait --as {{AGENT}}` in the foreground with a shell timeout above 600 seconds. It gives up
25
29
  after 540 seconds by default.
@@ -33,7 +37,8 @@ report new messages or failures normally.
33
37
  finished, such as an approval you missed, run `pair history`, then `pair history <id>`.
34
38
 
35
39
  Read the first line of `pair wait`'s output. If it starts with `pair: no message for`, the wait timed
36
- out: start it again. That isn't an error. If it starts with `== pair:`, a message was delivered. Act on
40
+ out: start it again, unless you are Codex (see above). That isn't an error. If it starts with
41
+ `== pair:`, a message was delivered. Act on
37
42
  it, even if its body quotes timeout text. If `pair wait` ends any other way (a non-zero exit, or
38
43
  cancelled by you or the user), don't restart it. Tell the user what happened.
39
44
 
@@ -83,5 +88,10 @@ EOF
83
88
 
84
89
  Use `--verdict approve` when no high- or medium-severity finding is still open.
85
90
  If `send review` prints ESCALATED, stop the loop and ask the user how to proceed.
91
+ When the user tells you to stop reviewing, stop waiting. If you ran `pair register`, also run
92
+ `pair unregister` so pair stops notifying you. A `pair:` note that pair queued before that can still
93
+ arrive, and so can one sent within a minute or two after Codex closes. If a note arrives after the
94
+ user told you to stop, and they haven't asked you to review or check pair since, don't act on it: say
95
+ that a pair message is waiting and end your turn.
86
96
  Severity levels: `high` (bug, data loss, security), `medium` (likely bug, missing edge case),
87
97
  `low` (style, naming).