claude-codex-pair 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,105 @@
1
+ # cc-pair design
2
+
3
+ Claude Code and Codex review each other's work. Each runs as a live session in its own terminal,
4
+ and they talk through a shared directory that works like a chat channel.
5
+
6
+ ## Decisions
7
+
8
+ | Topic | Choice |
9
+ |---|---|
10
+ | Topology | Two live sessions. No headless spawning. |
11
+ | Roles | Symmetric. Either agent can author or review. |
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"). |
15
+ | Request | The author's summary plus a list of files. The reviewer reads the files from the shared repo. |
16
+ | Review | Numbered findings with severity, and a verdict of `approve` or `changes`. Multiple rounds. |
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. |
18
+ | Concurrency | At most one open thread per direction. |
19
+ | Storage | `~/.pair/<repo>-<hash>/` (or `$PAIR_HOME`). The hash comes from `git rev-parse --show-toplevel`, so each worktree gets its own channel. A reviewer reads the author's files, so both agents must run in the same worktree, and parallel pairs in different worktrees stay isolated. |
20
+ | Format | Markdown files with flat YAML frontmatter that the CLI writes. Findings go in the body. |
21
+
22
+ ## Layout
23
+
24
+ ```
25
+ ~/.pair/<repo>-<hash>/
26
+ claude-to-codex/ # lane where claude is the author
27
+ <iso-ts>-<rand>/ # one directory per thread; a path is never reused
28
+ 001-request.md
29
+ 002-review.md # round 1, verdict changes
30
+ 003-response.md # round 2
31
+ 004-review.md # round 2, verdict approve, status approved
32
+ codex-to-claude/
33
+ archive/<iso-ts>-<rand>-<lane>-<approved|escalated>/
34
+ ```
35
+
36
+ Each message has this frontmatter, written by the CLI: `from`, `type`, `round`, `verdict` (reviews only), and `status` (`open`/`approved`/`escalated`).
37
+ The CLI writes to a dotfile first and then renames it, so readers never see a partial message.
38
+ When the CLI reads a message, it checks every field and fails with `corrupt message <path>: …` if anything is invalid. It doesn't guess.
39
+
40
+ ## Protocol
41
+
42
+ The thread state comes from the last message, so there is no separate state file:
43
+
44
+ | Last message | State | Whose turn |
45
+ |---|---|---|
46
+ | none | idle | author may `send request` |
47
+ | request / response | awaiting-review | reviewer: `send review --verdict …` |
48
+ | review, status open | awaiting-response | author: `send response` |
49
+ | review, approved/escalated | closed | author reads it with `wait`, then it's archived |
50
+
51
+ `pair wait --as X` returns the first message where it is X's turn, in either thread. It
52
+ prints the message and a `next:` line telling the agent what to do. Calling `wait` again before
53
+ acting returns the same message, because X still owes a reply. If the thread already has earlier
54
+ messages, `wait` also lists their files. An agent that lost its context, for example after a
55
+ restart, can then read the history instead of seeing only "F1 fixed".
56
+
57
+ Finished threads go to `archive/`. Whenever a thread finishes, `pair` deletes all but the `PAIR_KEEP`
58
+ most recently finished ones (100 by default; `all` keeps everything; `0` keeps none). "Most recently
59
+ finished" means by when the thread's last message landed, not when it started, so a thread that just
60
+ finished always survives when `PAIR_KEEP` is at least 1. An invalid `PAIR_KEEP` makes `send` and `wait`
61
+ fail before they touch any thread. If automatic cleanup itself fails (for example, an undeletable
62
+ directory), `pair` prints a warning and still delivers the result or writes the request.
63
+ `pair history` lists finished threads, most recently finished first, and
64
+ `pair history <id>` prints one transcript. `pair clean --keep N` deletes all but the N newest right
65
+ away. The flag is required, so a bare `clean` deletes nothing. Neither kind of cleanup touches
66
+ active threads.
67
+
68
+ `pair status` adds the canonical worktree root, installed package version, and instruction
69
+ freshness to the channel and lane states. Freshness compares the marked pair section in
70
+ `AGENTS.md` and the generated Claude skill against the same templates used by `init`. It is
71
+ read-only and describes files on disk, not instructions already loaded into a running session.
72
+ The check runs only on `status`, not during polling or message delivery.
73
+
74
+ Channel operations report `EACCES` and `EPERM` with the affected path and the sandbox
75
+ configuration printed by `init`. Either ordinary permissions or sandbox restrictions can
76
+ cause these failures. Input message files, instruction files, and installed assets are handled
77
+ separately. Automatic pruning still warns and continues; its diagnostics follow the delivered
78
+ message so `wait` keeps its protocol header first even when stdout and stderr are merged.
79
+
80
+ ## Usage
81
+
82
+ ```
83
+ npm install && npm link # npm install builds via prepare; npm link provides `pair`
84
+ cd <repo> && pair init # installs the skill and AGENTS.md section, prints sandbox config
85
+ ```
86
+
87
+ Then add the printed paths to both sandboxes: Codex `writable_roots`, Claude
88
+ `sandbox.filesystem.allowWrite`. In the reviewer terminal say "be my reviewer". In the author
89
+ terminal, finish a task and say "ask for review".
90
+
91
+ ```
92
+ pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
93
+ pair wait --as <agent> [--timeout s]
94
+ pair status
95
+ pair history [thread-id]
96
+ pair clean --keep N
97
+ pair init
98
+ ```
99
+
100
+ ## Known limits
101
+
102
+ - 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
+ - Polling, not fs events. A 1s delay is fine for chat between agents.
104
+ - 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.
package/LICENSE ADDED
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # cc-pair
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Claude Code and Codex review each other's work. Each runs as a live session in its own terminal,
4
+ and they talk through a shared directory on disk.
5
+
6
+ ```
7
+ terminal A: claude terminal B: codex ("be my reviewer")
8
+ finish task
9
+ pair send request ──▶ ~/.pair/<repo>-<hash>/claude-to-codex/<thread>/001-request.md
10
+ pair wait ⏳ pair wait ◀── picks it up
11
+ 002-review.md ◀── pair send review --verdict changes
12
+ verify findings, fix
13
+ pair send response ──▶ 003-response.md
14
+ pair wait ⏳ pair wait ◀── re-checks fixes
15
+ 004-review.md ◀── pair send review --verdict approve
16
+ done ✓
17
+ ```
18
+
19
+ Roles are symmetric: Codex can ask Claude for a review the same way.
20
+
21
+ ## Install
22
+
23
+ ```sh
24
+ npm install -g claude-codex-pair
25
+ ```
26
+
27
+ Requires Node 22 or later. This installs the `pair` command.
28
+
29
+ To install from source, clone the repo, then run `npm install && npm link` inside it.
30
+
31
+ If you installed from source before the package was renamed to `claude-codex-pair`, run
32
+ `npm uninstall -g cc-pair` first. Otherwise the install fails with `EEXIST` on `bin/pair`.
33
+
34
+ ## Setup (once per repo)
35
+
36
+ ```sh
37
+ cd your-repo
38
+ pair init
39
+ ```
40
+
41
+ `init` writes a Claude Code skill (`.claude/skills/pair/SKILL.md`) and a section in `AGENTS.md`
42
+ for Codex. Both explain the protocol to the agent.
43
+
44
+ Claude Code 2.1.277 and later can also read `AGENTS.md`. The pair section there starts by telling
45
+ any agent other than Codex to ignore it, so Claude keeps its own role. If you ran `pair init` with
46
+ an earlier version of this package, run it again to refresh both files.
47
+
48
+ Both agents' sandboxes must be able to write to `~/.pair`, or `$PAIR_HOME` if set. `pair init`
49
+ prints the configuration snippets with your actual path; add them to the corresponding configs.
50
+ For the default directory, they look like this:
51
+
52
+ ```toml
53
+ # ~/.codex/config.toml
54
+ [sandbox_workspace_write]
55
+ writable_roots = ["/Users/you/.pair"]
56
+ ```
57
+
58
+ ```jsonc
59
+ // ~/.claude/settings.json
60
+ "sandbox": { "filesystem": { "allowWrite": ["~/.pair"] } }
61
+ ```
62
+
63
+ After setup, start Claude Code and Codex in separate terminals in the same worktree of this repo. Each
64
+ worktree has its own channel, so an agent started in another checkout won't see the messages
65
+ (see [Worktrees](#worktrees)). Restart any sessions that were already open so they load the new
66
+ instructions and sandbox settings.
67
+
68
+ ## Use
69
+
70
+ 1. In the reviewer's terminal, say **"be my reviewer"**. The agent loops on `pair wait`.
71
+ 2. In the author's terminal, finish a task and say **"ask codex for a review"** (or "ask claude").
72
+ 3. Watch them go back and forth. The author verifies each finding before fixing it, and can reject
73
+ one with a reason.
74
+ 4. The thread ends when the reviewer approves. If findings are still open after 3 rounds, the thread
75
+ is **escalated** and both agents stop and ask you.
76
+
77
+ ## Worktrees
78
+
79
+ The channel comes from the worktree root, so each git worktree gets its own. You can run one pair
80
+ per worktree in parallel without them seeing each other's messages.
81
+
82
+ ```sh
83
+ git worktree add ../your-repo-feature -b feature/x
84
+ cd ../your-repo-feature
85
+ pair init # needed unless AGENTS.md and .claude/skills/pair/ are committed on this branch
86
+ pair status # worktree: /Users/you/your-repo-feature
87
+ ```
88
+
89
+ Then start Claude Code and Codex in `../your-repo-feature`, not in the main checkout. If one agent
90
+ never receives the other's messages, ask each agent to run `pair status` and compare the `channel:`
91
+ lines. Pair needs no extra sandbox config, because every channel lives under `~/.pair` (or `$PAIR_HOME`).
92
+
93
+ `git worktree remove` leaves the worktree's channel in `~/.pair`. To delete it too, run
94
+ `pair status` in the worktree first to get its `channel:` path, then remove that directory.
95
+
96
+ ## Commands
97
+
98
+ The agents run `send` and `wait` themselves. You'll mostly use `init`, `status`, `history` and `clean`.
99
+
100
+ ```
101
+ 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
103
+ pair status
104
+ pair history [thread-id] # list finished threads (latest first), or print one (any unique part of its id)
105
+ pair clean --keep N # delete all but the N newest finished threads
106
+ pair init
107
+ ```
108
+
109
+ `pair status` shows the channel, worktree root, installed package version, and each lane's turn.
110
+ It also checks the generated instructions on disk:
111
+
112
+ ```
113
+ channel: /Users/you/.pair/your-repo-12345678
114
+ worktree: /Users/you/your-repo
115
+ version: claude-codex-pair 0.1.0
116
+ claude-to-codex: idle
117
+ codex-to-claude: idle
118
+ instructions on disk (does not verify what running agents loaded):
119
+ AGENTS.md (pair section): current
120
+ .claude/skills/pair/SKILL.md: current
121
+ ```
122
+
123
+ Instruction states are `current`, `stale`, `missing`, or `unreadable`. The check compares only
124
+ the marked pair section in `AGENTS.md`, so your other instructions don't affect it. Status
125
+ doesn't change files. Run `pair init` to refresh missing or stale instructions, then reload
126
+ agent sessions; fix access to unreadable files first. Current files on disk don't guarantee
127
+ that a running agent has loaded them.
128
+
129
+ If a channel read or write fails with `EACCES` or `EPERM`, the command names the path and prints
130
+ sandbox configuration snippets using your actual channel directory. Check ordinary filesystem
131
+ permissions as well as sandbox settings. Automatic archive cleanup failures remain warnings,
132
+ so a cleanup problem doesn't prevent delivering a review or starting the next thread.
133
+
134
+ | Env var | Default | Meaning |
135
+ |---|---|---|
136
+ | `PAIR_HOME` | `~/.pair` | Root directory for channels |
137
+ | `PAIR_AGENT` | (none) | Default for `--as` |
138
+ | `PAIR_MAX_ROUNDS` | `3` | Number of review rounds before escalation |
139
+ | `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
+
141
+ ## Example review
142
+
143
+ These are messages from a real run, shortened. The bug in round 1 was planted on purpose.
144
+
145
+ ```
146
+ == pair: review from codex · claude-to-codex · round 1 · verdict changes ==
147
+ ### F1 [medium] src/paginate.js:8: Partial last pages are omitted from the page count
148
+ `totalPages(5, 2)` returns 2 ... Use `Math.ceil(count / pageSize)`.
149
+ ### F2 [medium] src/paginate.js:2: Invalid pagination inputs produce unrelated items
150
+ `paginate([1, 2, 3, 4, 5], 1, -2)` returns `[1, 2, 3]` ...
151
+
152
+ == pair: review from codex · claude-to-codex · round 2 · verdict approve ==
153
+ F1 and F2 are fixed. I re-read src/paginate.js and ran 41 assertions ... All passed.
154
+ ```
155
+
156
+ See [DESIGN.md](DESIGN.md) for the protocol, storage layout, and known limits.
package/dist/pair.js ADDED
@@ -0,0 +1,601 @@
1
+ #!/usr/bin/env node
2
+ import { execFileSync } from "node:child_process";
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";
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parseArgs } from "node:util";
10
+ const AGENTS = ["claude", "codex"];
11
+ const MESSAGE_TYPES = ["request", "review", "response"];
12
+ const VERDICTS = ["approve", "changes"];
13
+ const THREAD_STATUSES = ["open", "approved", "escalated"];
14
+ const DEFAULT_MAX_ROUNDS = 3;
15
+ const DEFAULT_KEEP = 100; // finished threads kept by auto-pruning
16
+ const DEFAULT_WAIT_SECONDS = 540; // stays under Claude Code's 10-minute Bash cap
17
+ const POLL_MS = 1000;
18
+ const MESSAGE_FILE = /^(\d{3})-(request|review|response)\.md$/;
19
+ // archive/<thread id>-<author>-to-<reviewer>-<outcome>; the id starts with a timestamp
20
+ const ARCHIVED_THREAD = /^(.+)-(claude|codex)-to-(claude|codex)-(approved|escalated)$/;
21
+ const PAIR_SECTION = /<!-- pair:start -->[\s\S]*?<!-- pair:end -->\n?/;
22
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
23
+ class PairError extends Error {
24
+ }
25
+ function isMissing(error) {
26
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
27
+ }
28
+ function sandboxConfig(channel) {
29
+ const pairHome = JSON.stringify(dirname(channel));
30
+ return [
31
+ "Both agents need read and write access to the channel. Sandbox configuration:",
32
+ "",
33
+ " ~/.codex/config.toml",
34
+ " [sandbox_workspace_write]",
35
+ ` writable_roots = [${pairHome}]`,
36
+ "",
37
+ " ~/.claude/settings.json",
38
+ ` "sandbox": { "filesystem": { "allowWrite": [${pairHome}] } }`,
39
+ ].join("\n");
40
+ }
41
+ /** Only call this for channel operations; input files and installed assets have other remedies. */
42
+ function channelError(channel, error) {
43
+ if (error instanceof Error && "code" in error && (error.code === "EACCES" || error.code === "EPERM")) {
44
+ const path = "path" in error ? String(error.path) : channel;
45
+ return new PairError(`cannot access channel path ${path} (${error.code}). Check filesystem permissions and sandbox restrictions.\n${sandboxConfig(channel)}`);
46
+ }
47
+ return error;
48
+ }
49
+ function channelAccess(channel, operation) {
50
+ try {
51
+ return operation();
52
+ }
53
+ catch (error) {
54
+ throw channelError(channel, error);
55
+ }
56
+ }
57
+ function positiveNumber(value, what) {
58
+ const parsed = Number(value);
59
+ if (!Number.isFinite(parsed) || parsed <= 0) {
60
+ throw new PairError(`${what} must be a positive number (got "${value}")`);
61
+ }
62
+ return parsed;
63
+ }
64
+ function maxRounds() {
65
+ return positiveNumber(process.env.PAIR_MAX_ROUNDS ?? String(DEFAULT_MAX_ROUNDS), "PAIR_MAX_ROUNDS");
66
+ }
67
+ function partnerOf(agent) {
68
+ return agent === "claude" ? "codex" : "claude";
69
+ }
70
+ function threadFor(channel, author) {
71
+ const reviewer = partnerOf(author);
72
+ return { lane: join(channel, `${author}-to-${reviewer}`), author, reviewer };
73
+ }
74
+ function readdirOrEmpty(path) {
75
+ try {
76
+ return readdirSync(path, { withFileTypes: true });
77
+ }
78
+ catch (error) {
79
+ // No lane dir yet means no thread has been started in it.
80
+ if (isMissing(error)) {
81
+ return [];
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+ /** Each thread gets a fresh directory, so a stale archive can never move a newer thread. */
87
+ function currentThreadDir(thread) {
88
+ const entries = readdirOrEmpty(thread.lane);
89
+ const legacy = entries.find((entry) => entry.isFile() && MESSAGE_FILE.test(entry.name));
90
+ if (legacy) {
91
+ throw new PairError(`${thread.lane} still uses the old flat layout (${legacy.name}). Move its NNN-*.md files into a new subdirectory of it, or delete them if that thread is finished.`);
92
+ }
93
+ const latest = entries
94
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
95
+ .map((entry) => entry.name)
96
+ .sort()
97
+ .at(-1);
98
+ return latest === undefined ? undefined : join(thread.lane, latest);
99
+ }
100
+ function newThreadId() {
101
+ return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
102
+ }
103
+ function gitPath(flag) {
104
+ try {
105
+ return execFileSync("git", ["rev-parse", "--path-format=absolute", flag], {
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ }).trim();
109
+ }
110
+ catch {
111
+ // Not inside a git repo; callers fall back to the current directory.
112
+ return undefined;
113
+ }
114
+ }
115
+ function worktreeRoot() {
116
+ return realpathSync(gitPath("--show-toplevel") ?? process.cwd());
117
+ }
118
+ function channelDir(root) {
119
+ // One channel per worktree: a reviewer reads the author's files, so both must share a checkout.
120
+ // realpath so /tmp/x and /private/tmp/x (or any symlinked checkout) share one channel.
121
+ 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}`);
123
+ }
124
+ function parseFrontmatter(text) {
125
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
126
+ if (!match) {
127
+ return { fields: {}, body: text };
128
+ }
129
+ const fields = {};
130
+ for (const line of match[1].split("\n")) {
131
+ const colon = line.indexOf(":");
132
+ if (colon > 0) {
133
+ fields[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
134
+ }
135
+ }
136
+ return { fields, body: text.slice(match[0].length) };
137
+ }
138
+ /** Parses a stored message and rejects anything the CLI would not have written. */
139
+ function readMessage(path, thread) {
140
+ const { fields, body } = parseFrontmatter(readFileSync(path, "utf8"));
141
+ const corrupt = `corrupt message ${path}:`;
142
+ const type = oneOf(fields.type, MESSAGE_TYPES, `${corrupt} type`);
143
+ if (!basename(path).endsWith(`-${type}.md`)) {
144
+ throw new PairError(`${corrupt} type "${type}" does not match the file name`);
145
+ }
146
+ const from = oneOf(fields.from, [type === "review" ? thread.reviewer : thread.author], `${corrupt} from`);
147
+ const round = Number(fields.round);
148
+ if (!Number.isInteger(round) || round < 1) {
149
+ throw new PairError(`${corrupt} round must be a positive integer (got "${fields.round}")`);
150
+ }
151
+ const status = oneOf(fields.status, THREAD_STATUSES, `${corrupt} status`);
152
+ const verdict = type === "review" ? oneOf(fields.verdict, VERDICTS, `${corrupt} verdict`) : undefined;
153
+ const consistent = verdict === "approve" ? status === "approved" : verdict === "changes" ? status !== "approved" : status === "open";
154
+ if (!consistent) {
155
+ throw new PairError(`${corrupt} status "${status}" does not fit verdict "${verdict ?? "none"}"`);
156
+ }
157
+ return { file: path, from, type, round, verdict, status, body };
158
+ }
159
+ function readThread(dir, thread) {
160
+ return readdirSync(dir)
161
+ .filter((name) => MESSAGE_FILE.test(name))
162
+ .sort()
163
+ .map((name) => readMessage(join(dir, name), thread));
164
+ }
165
+ function listMessages(thread) {
166
+ const dir = currentThreadDir(thread);
167
+ if (!dir) {
168
+ return [];
169
+ }
170
+ try {
171
+ return readThread(dir, thread);
172
+ }
173
+ catch (error) {
174
+ // The author's wait archived this thread mid-read, so it is closed and gone.
175
+ if (isMissing(error)) {
176
+ return [];
177
+ }
178
+ throw error;
179
+ }
180
+ }
181
+ function stateOf(messages) {
182
+ const last = messages.at(-1);
183
+ if (!last) {
184
+ return { kind: "idle" };
185
+ }
186
+ if (last.type !== "review") {
187
+ return { kind: "awaiting-review", last };
188
+ }
189
+ return last.status === "open" ? { kind: "awaiting-response", last } : { kind: "closed", last };
190
+ }
191
+ /** Digits only: Number("") is 0, so an empty value must never reach the conversion. */
192
+ function parseKeep(value, what) {
193
+ if (!/^\d+$/.test(value)) {
194
+ throw new PairError(`${what} must be a whole number of finished threads to keep (got "${value}")`);
195
+ }
196
+ return Number(value);
197
+ }
198
+ /** How many finished threads auto-pruning keeps. Undefined means keep them all. */
199
+ function autoKeep() {
200
+ const value = process.env.PAIR_KEEP;
201
+ if (value === undefined) {
202
+ return DEFAULT_KEEP;
203
+ }
204
+ return value === "all" ? undefined : parseKeep(value, "PAIR_KEEP");
205
+ }
206
+ /** Deletes all but the `keep` newest finished threads. Returns how many were removed. */
207
+ function prune(channel, keep) {
208
+ const doomed = listArchived(channel).slice(keep);
209
+ for (const entry of doomed) {
210
+ rmSync(entry.dir, { recursive: true, force: true });
211
+ }
212
+ return doomed.length;
213
+ }
214
+ function archive(channel, last) {
215
+ const dir = dirname(last.file);
216
+ const archiveDir = join(channel, "archive");
217
+ mkdirSync(archiveDir, { recursive: true });
218
+ try {
219
+ renameSync(dir, join(archiveDir, `${basename(dir)}-${basename(dirname(dir))}-${last.status}`));
220
+ }
221
+ catch (error) {
222
+ // Another wait for the same agent already archived this exact thread.
223
+ if (!isMissing(error)) {
224
+ throw error;
225
+ }
226
+ }
227
+ const keep = autoKeep();
228
+ if (keep === undefined) {
229
+ return;
230
+ }
231
+ try {
232
+ prune(channel, keep);
233
+ }
234
+ catch (error) {
235
+ // Cleanup is housekeeping. It must never block delivering a result or starting a thread.
236
+ const failure = channelError(channel, error);
237
+ const reason = failure instanceof Error ? failure.message : String(failure);
238
+ console.error(`pair: warning: automatic cleanup failed: ${reason}\nFix it, then run \`pair clean --keep ${keep}\`.`);
239
+ }
240
+ }
241
+ function writeMessage(dir, seq, message, body) {
242
+ mkdirSync(dir, { recursive: true });
243
+ const name = `${String(seq).padStart(3, "0")}-${message.type}.md`;
244
+ const lines = [
245
+ "---",
246
+ `from: ${message.from}`,
247
+ `type: ${message.type}`,
248
+ `round: ${message.round}`,
249
+ ...(message.verdict ? [`verdict: ${message.verdict}`] : []),
250
+ `status: ${message.status}`,
251
+ "---",
252
+ body.trimEnd(),
253
+ "",
254
+ ];
255
+ const finalPath = join(dir, name);
256
+ const tmpPath = join(dir, `.${name}.tmp`);
257
+ writeFileSync(tmpPath, lines.join("\n"));
258
+ renameSync(tmpPath, finalPath); // atomic: readers never see a half-written message
259
+ return finalPath;
260
+ }
261
+ function send(channel, me, type, body, verdict) {
262
+ if (type === "review") {
263
+ return sendReview(channel, me, body, verdict);
264
+ }
265
+ const thread = threadFor(channel, me);
266
+ const messages = listMessages(thread);
267
+ const state = stateOf(messages);
268
+ if (type === "request") {
269
+ if (state.kind === "awaiting-review" || state.kind === "awaiting-response") {
270
+ throw new PairError(`a review thread to ${thread.reviewer} is already open (${state.kind}); finish it first`);
271
+ }
272
+ if (state.kind === "closed") {
273
+ archive(channel, state.last);
274
+ }
275
+ return writeMessage(join(thread.lane, newThreadId()), 1, { from: me, type, round: 1, status: "open" }, body);
276
+ }
277
+ if (state.kind !== "awaiting-response") {
278
+ throw new PairError(`cannot send response: no open review from ${thread.reviewer} (thread is ${state.kind})`);
279
+ }
280
+ const round = state.last.round + 1;
281
+ return writeMessage(dirname(state.last.file), messages.length + 1, { from: me, type, round, status: "open" }, body);
282
+ }
283
+ function sendReview(channel, me, body, verdict) {
284
+ if (!verdict) {
285
+ throw new PairError(`review needs --verdict ${VERDICTS.join("|")}`);
286
+ }
287
+ const thread = threadFor(channel, partnerOf(me));
288
+ const messages = listMessages(thread);
289
+ const state = stateOf(messages);
290
+ if (state.kind !== "awaiting-review") {
291
+ throw new PairError(`cannot send review: nothing from ${thread.author} is waiting for review (thread is ${state.kind})`);
292
+ }
293
+ const round = state.last.round;
294
+ const status = verdict === "approve" ? "approved" : round >= maxRounds() ? "escalated" : "open";
295
+ const review = { from: me, type: "review", round, verdict, status };
296
+ const path = writeMessage(dirname(state.last.file), messages.length + 1, review, body);
297
+ return status === "escalated" ? `${path}\n${escalationNotice()}` : path;
298
+ }
299
+ function escalationNotice() {
300
+ return `ESCALATED: round cap (${maxRounds()}) reached with open findings. STOP and ask the user how to proceed.`;
301
+ }
302
+ function pendingFor(channel, me) {
303
+ const asAuthor = threadFor(channel, me);
304
+ const authorMessages = listMessages(asAuthor);
305
+ const authorState = stateOf(authorMessages);
306
+ if (authorState.kind === "awaiting-response" || authorState.kind === "closed") {
307
+ return { thread: asAuthor, state: authorState, earlier: authorMessages.slice(0, -1) };
308
+ }
309
+ const asReviewer = threadFor(channel, partnerOf(me));
310
+ const reviewerMessages = listMessages(asReviewer);
311
+ const reviewerState = stateOf(reviewerMessages);
312
+ if (reviewerState.kind === "awaiting-review") {
313
+ return { thread: asReviewer, state: reviewerState, earlier: reviewerMessages.slice(0, -1) };
314
+ }
315
+ return undefined;
316
+ }
317
+ function nextStep(me, state) {
318
+ switch (state.kind) {
319
+ case "awaiting-review":
320
+ return `review it, then: pair send review --verdict approve|changes --as ${me} <file|->`;
321
+ case "awaiting-response":
322
+ return `verify each finding yourself, fix or reject it, then: pair send response --as ${me} <file|->`;
323
+ case "closed":
324
+ return state.last.status === "approved"
325
+ ? "thread approved and closed. Nothing to send."
326
+ : escalationNotice();
327
+ }
328
+ }
329
+ /** Points an agent that lost its context (e.g. after a restart) at the rest of the thread. */
330
+ function historyLine({ state, earlier }) {
331
+ // A closed thread is archived as soon as it's read and needs no further action.
332
+ if (state.kind === "closed" || earlier.length === 0) {
333
+ return undefined;
334
+ }
335
+ const names = earlier.map((message) => basename(message.file)).join(" ");
336
+ return `== earlier in this thread (read them first if they're not in your context): ${dirname(state.last.file)}/ ${names} ==`;
337
+ }
338
+ function messageHeader(message, laneName) {
339
+ const verdict = message.verdict ? ` · verdict ${message.verdict}` : "";
340
+ return `== pair: ${message.type} from ${message.from} · ${laneName} · round ${message.round}${verdict} ==`;
341
+ }
342
+ function formatMessage(me, pending) {
343
+ const { thread, state } = pending;
344
+ const { last } = state;
345
+ const header = messageHeader(last, basename(thread.lane));
346
+ const history = historyLine(pending);
347
+ return [header, ...(history ? [history] : []), last.body.trimEnd(), `== next: ${nextStep(me, state)} ==`].join("\n");
348
+ }
349
+ /**
350
+ * Prints the message, then archives a closed thread. Archiving last keeps the `== pair:` header on
351
+ * the first line even when cleanup warns on stderr, since agents read stdout and stderr merged.
352
+ */
353
+ async function wait(channel, me, timeoutSeconds) {
354
+ const deadline = Date.now() + timeoutSeconds * 1000;
355
+ while (true) {
356
+ const pending = channelAccess(channel, () => pendingFor(channel, me));
357
+ if (pending) {
358
+ console.log(formatMessage(me, pending));
359
+ if (pending.state.kind === "closed") {
360
+ channelAccess(channel, () => archive(channel, pending.state.last));
361
+ }
362
+ return true;
363
+ }
364
+ if (Date.now() >= deadline) {
365
+ return false;
366
+ }
367
+ await sleep(POLL_MS);
368
+ }
369
+ }
370
+ function describe(channel, author) {
371
+ const thread = threadFor(channel, author);
372
+ const state = stateOf(listMessages(thread));
373
+ if (state.kind === "idle") {
374
+ return `${basename(thread.lane)}: idle`;
375
+ }
376
+ const turn = state.kind === "awaiting-review" ? thread.reviewer : thread.author;
377
+ return `${basename(thread.lane)}: ${state.kind} (round ${state.last.round}, ${turn}'s turn)`;
378
+ }
379
+ function instructionState(path, expected, sectionOnly = false) {
380
+ try {
381
+ const text = readFileSync(path, "utf8");
382
+ const actual = sectionOnly ? PAIR_SECTION.exec(text)?.[0] : text;
383
+ if (actual === undefined) {
384
+ return "missing"; // AGENTS.md exists but has no complete pair section
385
+ }
386
+ return actual === expected ? "current" : "stale";
387
+ }
388
+ catch (error) {
389
+ if (isMissing(error)) {
390
+ return "missing";
391
+ }
392
+ const reason = error instanceof Error && "code" in error ? String(error.code) : String(error);
393
+ return `unreadable (${reason})`;
394
+ }
395
+ }
396
+ function status(channel, root) {
397
+ const lanes = channelAccess(channel, () => AGENTS.map((agent) => describe(channel, agent)));
398
+ const { name, version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
399
+ const agents = instructionState(join(root, "AGENTS.md"), codexSection(), true);
400
+ const skill = instructionState(join(root, ".claude", "skills", "pair", "SKILL.md"), claudeSkill());
401
+ return [
402
+ `channel: ${channel}`,
403
+ `worktree: ${root}`,
404
+ `version: ${name} ${version}`,
405
+ ...lanes,
406
+ "instructions on disk (does not verify what running agents loaded):",
407
+ ` AGENTS.md (pair section): ${agents}`,
408
+ ` .claude/skills/pair/SKILL.md: ${skill}`,
409
+ ...(agents === "missing" || agents === "stale" || skill === "missing" || skill === "stale"
410
+ ? ["Run `pair init` to refresh missing or stale instructions, then reload agent sessions."] : []),
411
+ ...(agents.startsWith("unreadable") || skill.startsWith("unreadable")
412
+ ? ["Check permissions on the unreadable instruction paths before refreshing them."] : []),
413
+ ].join("\n");
414
+ }
415
+ /** Finished threads, most recently finished first. Entries that aren't archived threads are ignored. */
416
+ function listArchived(channel) {
417
+ const archiveDir = join(channel, "archive");
418
+ return readdirOrEmpty(archiveDir)
419
+ .filter((entry) => entry.isDirectory())
420
+ .flatMap((entry) => {
421
+ const match = ARCHIVED_THREAD.exec(entry.name);
422
+ if (!match) {
423
+ return [];
424
+ }
425
+ const [, id, author, reviewer, outcome] = match;
426
+ const thread = threadFor(channel, author);
427
+ if (thread.reviewer !== reviewer) {
428
+ return [];
429
+ }
430
+ const dir = join(archiveDir, entry.name);
431
+ const stats = statSync(dir, { throwIfNoEntry: false });
432
+ if (!stats) {
433
+ return []; // removed by a concurrent clean
434
+ }
435
+ const laneName = `${author}-to-${reviewer}`;
436
+ return [{ id, finishedAt: stats.mtimeMs, dir, laneName, outcome: outcome, thread }];
437
+ })
438
+ .sort((a, b) => b.finishedAt - a.finishedAt || b.id.localeCompare(a.id));
439
+ }
440
+ /** Undefined when `pair clean` removed the thread after the archive was listed. */
441
+ function readArchived(entry) {
442
+ try {
443
+ return readThread(entry.dir, entry.thread);
444
+ }
445
+ catch (error) {
446
+ if (isMissing(error)) {
447
+ return undefined;
448
+ }
449
+ throw error;
450
+ }
451
+ }
452
+ function requestSummary(messages) {
453
+ const first = messages[0]?.body.split("\n").map((line) => line.trim()).find((line) => line && !line.startsWith("#")) ?? "";
454
+ return first.length > 72 ? `${first.slice(0, 71)}…` : first;
455
+ }
456
+ function history(channel, query) {
457
+ const archived = listArchived(channel);
458
+ if (query === undefined) {
459
+ if (archived.length === 0) {
460
+ return "no finished threads yet";
461
+ }
462
+ return archived
463
+ .flatMap((entry) => {
464
+ const messages = readArchived(entry);
465
+ return messages ? [`${entry.id} ${entry.laneName} ${entry.outcome} ${messages.length} messages ${requestSummary(messages)}`] : [];
466
+ })
467
+ .join("\n");
468
+ }
469
+ const matches = archived.filter((entry) => entry.id.includes(query));
470
+ if (matches.length !== 1) {
471
+ const found = matches.length === 0 ? "no finished thread matches" : `${matches.length} finished threads match`;
472
+ throw new PairError(`${found} "${query}"${matches.map((entry) => `\n ${entry.id}`).join("")}`);
473
+ }
474
+ const [entry] = matches;
475
+ const messages = readArchived(entry);
476
+ if (!messages) {
477
+ throw new PairError(`finished thread ${entry.id} was removed while reading it`);
478
+ }
479
+ const transcript = messages.map((message) => `${messageHeader(message, entry.laneName)}\n${message.body.trimEnd()}`);
480
+ return [`== thread ${entry.id} · ${entry.laneName} · ${entry.outcome} ==`, ...transcript].join("\n\n");
481
+ }
482
+ function clean(channel, keepValue) {
483
+ if (keepValue === undefined) {
484
+ throw new PairError("clean needs --keep N, the number of newest finished threads to keep");
485
+ }
486
+ const keep = parseKeep(keepValue, "--keep");
487
+ return `removed ${prune(channel, keep)} finished thread(s), kept up to ${keep}`;
488
+ }
489
+ function renderPrompt(agent) {
490
+ const template = readFileSync(join(PACKAGE_ROOT, "prompts", "pair.md"), "utf8");
491
+ return template.replaceAll("{{AGENT}}", agent).replaceAll("{{PARTNER}}", partnerOf(agent));
492
+ }
493
+ function claudeSkill() {
494
+ const header = `---\nname: pair\ndescription: Request a code review from codex after finishing a task, or act as codex's reviewer ("be my reviewer", "listen"), via the pair CLI.\n---\n\n`;
495
+ return header + renderPrompt("claude");
496
+ }
497
+ function codexSection() {
498
+ return `<!-- pair:start -->\n${renderPrompt("codex").trimEnd()}\n<!-- pair:end -->\n`;
499
+ }
500
+ function init(channel, root) {
501
+ channelAccess(channel, () => mkdirSync(channel, { recursive: true }));
502
+ const skillDir = join(root, ".claude", "skills", "pair");
503
+ mkdirSync(skillDir, { recursive: true });
504
+ writeFileSync(join(skillDir, "SKILL.md"), claudeSkill());
505
+ const agentsPath = join(root, "AGENTS.md");
506
+ const section = codexSection();
507
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
508
+ const updated = PAIR_SECTION.test(existing) ? existing.replace(PAIR_SECTION, () => section) : `${existing}${existing ? "\n" : ""}${section}`;
509
+ writeFileSync(agentsPath, updated);
510
+ return [
511
+ `channel: ${channel}`,
512
+ `wrote: ${join(skillDir, "SKILL.md")}`,
513
+ `wrote: ${agentsPath} (pair section)`,
514
+ "",
515
+ sandboxConfig(channel),
516
+ ].join("\n");
517
+ }
518
+ function readBody(source) {
519
+ if (!source) {
520
+ throw new PairError("send needs a message file, or - for stdin");
521
+ }
522
+ if (source !== "-" && !existsSync(source)) {
523
+ throw new PairError(`message file not found: ${source}`);
524
+ }
525
+ const body = readFileSync(source === "-" ? 0 : source, "utf8");
526
+ if (!body.trim()) {
527
+ throw new PairError("message body is empty");
528
+ }
529
+ return body;
530
+ }
531
+ function oneOf(value, allowed, what) {
532
+ if (!value || !allowed.includes(value)) {
533
+ throw new PairError(`${what} must be one of: ${allowed.join(", ")}${value ? ` (got "${value}")` : ""}`);
534
+ }
535
+ return value;
536
+ }
537
+ const USAGE = `usage:
538
+ 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)
540
+ pair status
541
+ pair history [thread-id] (list finished threads, or show one)
542
+ pair clean --keep N (delete all but the N newest finished threads)
543
+ pair init`;
544
+ async function main(argv) {
545
+ const { values, positionals } = parseArgs({
546
+ args: argv,
547
+ allowPositionals: true,
548
+ options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" } },
549
+ });
550
+ const [command, ...rest] = positionals;
551
+ const root = worktreeRoot();
552
+ const channel = channelDir(root);
553
+ const agent = () => oneOf(values.as ?? process.env.PAIR_AGENT, AGENTS, "--as (or PAIR_AGENT)");
554
+ switch (command) {
555
+ case "send": {
556
+ autoKeep(); // reject a bad PAIR_KEEP before touching any thread
557
+ const type = oneOf(rest[0], MESSAGE_TYPES, "message type");
558
+ const verdict = values.verdict === undefined ? undefined : oneOf(values.verdict, VERDICTS, "--verdict");
559
+ const me = agent();
560
+ const body = readBody(rest[1]);
561
+ console.log(`sent: ${channelAccess(channel, () => send(channel, me, type, body, verdict))}`);
562
+ return 0;
563
+ }
564
+ case "wait": {
565
+ autoKeep(); // reject a bad PAIR_KEEP before touching any thread
566
+ const me = agent();
567
+ const timeout = positiveNumber(values.timeout ?? String(DEFAULT_WAIT_SECONDS), "--timeout");
568
+ 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.`);
571
+ }
572
+ return 0;
573
+ }
574
+ case "status":
575
+ console.log(status(channel, root));
576
+ return 0;
577
+ case "history":
578
+ console.log(channelAccess(channel, () => history(channel, rest[0])));
579
+ return 0;
580
+ case "clean":
581
+ console.log(channelAccess(channel, () => clean(channel, values.keep)));
582
+ return 0;
583
+ case "init":
584
+ console.log(init(channel, root));
585
+ return 0;
586
+ default:
587
+ console.error(USAGE);
588
+ return 1;
589
+ }
590
+ }
591
+ main(process.argv.slice(2)).then((code) => {
592
+ process.exitCode = code;
593
+ }, (error) => {
594
+ const isUsageError = error instanceof PairError || (error instanceof Error && "code" in error && String(error.code).startsWith("ERR_PARSE_ARGS"));
595
+ if (isUsageError) {
596
+ console.error(`pair: ${error.message}`);
597
+ process.exitCode = 1;
598
+ return;
599
+ }
600
+ throw error;
601
+ });
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Claude Code and Codex review each other's work over a file-system chat channel",
5
+ "keywords": [
6
+ "claude-code",
7
+ "codex",
8
+ "code-review",
9
+ "ai-agents",
10
+ "cli"
11
+ ],
12
+ "license": "Unlicense",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/leonlee/cc-pair.git"
16
+ },
17
+ "homepage": "https://github.com/leonlee/cc-pair#readme",
18
+ "bugs": "https://github.com/leonlee/cc-pair/issues",
19
+ "type": "module",
20
+ "bin": {
21
+ "pair": "dist/pair.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "prompts",
26
+ "DESIGN.md"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "prepare": "tsc",
31
+ "prepublishOnly": "npm test",
32
+ "test": "tsc && node --test"
33
+ },
34
+ "engines": {
35
+ "node": ">=22"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22.0.0",
39
+ "typescript": "^5.6.0"
40
+ }
41
+ }
@@ -0,0 +1,87 @@
1
+ # pair: review loop with {{PARTNER}}
2
+
3
+ These instructions are for `{{AGENT}}` only. If you are not `{{AGENT}}`, ignore this section.
4
+
5
+ You are `{{AGENT}}`. Your partner `{{PARTNER}}` runs in another terminal on the same repo.
6
+ You talk only through the `pair` CLI. Always pass `--as {{AGENT}}`.
7
+
8
+ ## How to wait
9
+
10
+ `pair wait` returns as soon as a message for you arrives. Wait in whichever way costs the fewest
11
+ model turns:
12
+
13
+ - If you are Claude Code in an interactive session: run `pair wait --as {{AGENT}} --timeout 3600` as
14
+ a background command (`run_in_background: true`) and end your turn. You'll be resumed when it exits.
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.
23
+ - Otherwise, including headless `claude -p` or when background commands are unavailable: run
24
+ `pair wait --as {{AGENT}}` in the foreground with a shell timeout above 600 seconds. It gives up
25
+ after 540 seconds by default.
26
+
27
+ During idle waits, don't send unsolicited status updates. If your runtime requires a progress
28
+ message, write exactly `waiting for {{PARTNER}}`, with nothing added. Answer direct user questions and
29
+ report new messages or failures normally.
30
+
31
+ `pair wait` prints only the newest message. If it lists earlier messages that aren't in your context
32
+ (for example, after a restart), read those files before acting. To see a thread that has already
33
+ finished, such as an approval you missed, run `pair history`, then `pair history <id>`.
34
+
35
+ 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
37
+ it, even if its body quotes timeout text. If `pair wait` ends any other way (a non-zero exit, or
38
+ cancelled by you or the user), don't restart it. Tell the user what happened.
39
+
40
+ ## Ask {{PARTNER}} for a review (you are the author)
41
+
42
+ When you finish a task:
43
+
44
+ 1. Send a request with what changed and why, what to focus on, and the files touched.
45
+ ```
46
+ pair send request --as {{AGENT}} - <<'EOF'
47
+ ## Summary
48
+ ...
49
+ ## Focus
50
+ ...
51
+ ## Files
52
+ - src/a.ts
53
+ EOF
54
+ ```
55
+ 2. Wait for the review (see How to wait).
56
+ 3. On a review with verdict `changes`, check every finding against the code yourself. Reviewers
57
+ are sometimes wrong. For each one, fix it, reject it with a concrete reason, or defer it. Then reply:
58
+ ```
59
+ pair send response --as {{AGENT}} - <<'EOF'
60
+ ### F1 fixed: <what changed>
61
+ ### F2 rejected: <why it is not a defect>
62
+ ### F3 deferred: <why, and where it is tracked>
63
+ EOF
64
+ ```
65
+ Then go back to step 2.
66
+ 4. Stop when `wait` reports the thread approved. If it reports ESCALATED, stop and ask the user.
67
+
68
+ ## Review for {{PARTNER}} (when the user says "be my reviewer" or "listen")
69
+
70
+ Loop: wait for a message (see How to wait), review, send your review, then wait again.
71
+
72
+ - On a request, read the listed files in the repo and review the change.
73
+ - On a response, re-check fixed findings and judge each rejection on its merits. Accept good
74
+ reasons. Re-raise only with a counter-argument. Don't add new nitpicks in later rounds.
75
+
76
+ ```
77
+ pair send review --as {{AGENT}} --verdict changes - <<'EOF'
78
+ ### F1 [high] src/a.ts:42: null deref in parse()
79
+ How it fails, and the suggested fix.
80
+ ### F2 [low] src/b.ts:7: ...
81
+ EOF
82
+ ```
83
+
84
+ Use `--verdict approve` when no high- or medium-severity finding is still open.
85
+ If `send review` prints ESCALATED, stop the loop and ask the user how to proceed.
86
+ Severity levels: `high` (bug, data loss, security), `medium` (likely bug, missing edge case),
87
+ `low` (style, naming).