claude-codex-pair 0.0.0-stage → 0.1.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 +105 -0
- package/LICENSE +24 -0
- package/README.md +135 -2
- package/dist/pair.js +601 -0
- package/package.json +39 -4
- package/prompts/pair.md +87 -0
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,136 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cc-pair
|
|
2
2
|
|
|
3
|
-
|
|
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. Restart any sessions
|
|
65
|
+
that were already open so they load the new instructions and sandbox settings.
|
|
66
|
+
|
|
67
|
+
## Use
|
|
68
|
+
|
|
69
|
+
1. In the reviewer's terminal, say **"be my reviewer"**. The agent loops on `pair wait`.
|
|
70
|
+
2. In the author's terminal, finish a task and say **"ask codex for a review"** (or "ask claude").
|
|
71
|
+
3. Watch them go back and forth. The author verifies each finding before fixing it, and can reject
|
|
72
|
+
one with a reason.
|
|
73
|
+
4. The thread ends when the reviewer approves. If findings are still open after 3 rounds, the thread
|
|
74
|
+
is **escalated** and both agents stop and ask you.
|
|
75
|
+
|
|
76
|
+
## Commands
|
|
77
|
+
|
|
78
|
+
The agents run `send` and `wait` themselves. You'll mostly use `init`, `status`, `history` and `clean`.
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
|
|
82
|
+
pair wait --as <claude|codex> [--timeout seconds] # prints "no message … yet" on timeout: run again
|
|
83
|
+
pair status
|
|
84
|
+
pair history [thread-id] # list finished threads (latest first), or print one (any unique part of its id)
|
|
85
|
+
pair clean --keep N # delete all but the N newest finished threads
|
|
86
|
+
pair init
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`pair status` shows the channel, worktree root, installed package version, and each lane's turn.
|
|
90
|
+
It also checks the generated instructions on disk:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
channel: /Users/you/.pair/your-repo-12345678
|
|
94
|
+
worktree: /Users/you/your-repo
|
|
95
|
+
version: claude-codex-pair 0.1.0
|
|
96
|
+
claude-to-codex: idle
|
|
97
|
+
codex-to-claude: idle
|
|
98
|
+
instructions on disk (does not verify what running agents loaded):
|
|
99
|
+
AGENTS.md (pair section): current
|
|
100
|
+
.claude/skills/pair/SKILL.md: current
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Instruction states are `current`, `stale`, `missing`, or `unreadable`. The check compares only
|
|
104
|
+
the marked pair section in `AGENTS.md`, so your other instructions don't affect it. Status
|
|
105
|
+
doesn't change files. Run `pair init` to refresh missing or stale instructions, then reload
|
|
106
|
+
agent sessions; fix access to unreadable files first. Current files on disk don't guarantee
|
|
107
|
+
that a running agent has loaded them.
|
|
108
|
+
|
|
109
|
+
If a channel read or write fails with `EACCES` or `EPERM`, the command names the path and prints
|
|
110
|
+
sandbox configuration snippets using your actual channel directory. Check ordinary filesystem
|
|
111
|
+
permissions as well as sandbox settings. Automatic archive cleanup failures remain warnings,
|
|
112
|
+
so a cleanup problem doesn't prevent delivering a review or starting the next thread.
|
|
113
|
+
|
|
114
|
+
| Env var | Default | Meaning |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| `PAIR_HOME` | `~/.pair` | Root directory for channels |
|
|
117
|
+
| `PAIR_AGENT` | (none) | Default for `--as` |
|
|
118
|
+
| `PAIR_MAX_ROUNDS` | `3` | Number of review rounds before escalation |
|
|
119
|
+
| `PAIR_KEEP` | `100` | Finished threads to keep, by finish time. Older ones are deleted whenever a thread finishes. `all` keeps everything, `0` keeps none |
|
|
120
|
+
|
|
121
|
+
## Example review
|
|
122
|
+
|
|
123
|
+
These are messages from a real run, shortened. The bug in round 1 was planted on purpose.
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
== pair: review from codex · claude-to-codex · round 1 · verdict changes ==
|
|
127
|
+
### F1 [medium] src/paginate.js:8: Partial last pages are omitted from the page count
|
|
128
|
+
`totalPages(5, 2)` returns 2 ... Use `Math.ceil(count / pageSize)`.
|
|
129
|
+
### F2 [medium] src/paginate.js:2: Invalid pagination inputs produce unrelated items
|
|
130
|
+
`paginate([1, 2, 3, 4, 5], 1, -2)` returns `[1, 2, 3]` ...
|
|
131
|
+
|
|
132
|
+
== pair: review from codex · claude-to-codex · round 2 · verdict approve ==
|
|
133
|
+
F1 and F2 are fixed. I re-read src/paginate.js and ran 41 assertions ... All passed.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
}
|
package/prompts/pair.md
ADDED
|
@@ -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).
|