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 +70 -3
- package/README.md +92 -4
- package/dist/pair.js +650 -11
- package/package.json +1 -1
- package/prompts/pair.md +18 -8
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
|
|
14
|
-
| Pickup | The 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
|
-
-
|
|
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"**.
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
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:
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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).
|