claude-codex-pair 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -74,7 +74,19 @@ active threads.
74
74
  freshness to the channel and lane states. Freshness compares the marked pair section in
75
75
  `AGENTS.md` and the generated Claude skill against the same templates used by `init`. It is
76
76
  read-only and describes files on disk, not instructions already loaded into a running session.
77
- The check runs only on `status`, not during polling or message delivery.
77
+ The comparison ignores the `<!-- pair x.y.z -->` stamp that records which version wrote a file.
78
+
79
+ `send`, `wait`, `history`, `clean`, `register` and `unregister` run the same check once at startup,
80
+ not on each poll, and rewrite stale files. They skip files stamped by a newer pair, so worktrees
81
+ shared by two versions don't flip back and forth. A write failure is a warning on stderr, so a
82
+ read-only sandbox still delivers messages. `status` stays read-only, and `mcp` never refreshes:
83
+ it runs unsandboxed and only reads. `status` and `init` also report a newer npm version. They check
84
+ at most once a day, cache the answer under the state directory, and stay quiet on failure.
85
+ `init` also keeps files a newer pair wrote; deleting them is the way to downgrade. The refresh note goes
86
+ to stderr after the command's own output, so `pair wait`'s first line is still the delivery or
87
+ timeout line agents read. `pair upgrade` installs with npm, then runs the `init` of the package it
88
+ just installed (found with `npm root --global`), not the first `pair` on PATH, because both this
89
+ process and an older copy on PATH would write the old templates.
78
90
 
79
91
  Channel operations report `EACCES` and `EPERM` with the affected path and the sandbox
80
92
  configuration printed by `init`. Either ordinary permissions or sandbox restrictions can
@@ -168,5 +180,5 @@ pair mcp
168
180
  - Two worktrees each woke only their own session.
169
181
  - With Codex as the author, a `changes` review and the final approval each woke it once, and its `pair wait` archived the approved thread.
170
182
  - Approval policy `never` makes `pair_listen` fail, and Codex reports it and stops without polling.
171
- - The MCP server is a child of the app-server daemon, not of the TUI. After the last TUI on a session quit, it ran 1–2 more minutes, until the daemon unloaded the session. A message in that gap still wakes the session with no TUI attached. Desktop and IDE sessions, `--no-daemon`, and `--remote` are untested.
183
+ - The MCP server is a child of the app-server daemon, not of the TUI. After the last TUI on a session quit, it ran 1–2 more minutes, until the daemon unloaded the session. A message in that gap still wakes the session with no TUI attached. The ChatGPT desktop app runs its own app-server, which starts its own `pair mcp`; waking its sessions worked, with `PAIR_CODEX` pointing at the separately installed CLI. IDE sessions, `--no-daemon`, and `--remote` are untested.
172
184
  - Completion-driven waiting is verified only for interactive Claude Code sessions, with waits of up to a few minutes. A full hour is untested. Headless `claude -p` ends background commands after its final result, so the prompt falls back to a foreground wait. Codex was tried with one long `pair wait` that it kept checking. Its built-in instructions cap each check at about 60 seconds, so an idle Codex made a model call every minute, enough to run a workspace out of credits. A finished background command doesn't resume Codex (measured with CLI 0.162.1), which is why Codex uses `pair mcp` instead.
package/README.md CHANGED
@@ -101,9 +101,12 @@ pending. The note never contains message text. Codex reads the message itself wi
101
101
  - two worktrees each woke only their own Codex;
102
102
  - with Codex as the author, a `changes` review and the final approval each woke it once, and its
103
103
  `pair wait` archived the approved thread;
104
- - the `never` approval policy failed.
104
+ - the `never` approval policy failed;
105
+ - with Codex CLI 0.163.0, a session idle for about 2 hours was still woken 0.9s after the request
106
+ and replied 9s after it.
105
107
 
106
- Desktop and IDE sessions are untested.
108
+ Codex in the ChatGPT desktop app works too: the app starts its own `pair mcp`, and in daily use
109
+ with Codex as the author, each review woke it and it replied. IDE sessions are untested.
107
110
  - `pair status` shows the registered session and the listener's last event. The full log is
108
111
  `~/.local/state/pair/<repo>-<hash>/codex.listen.log` (under `$XDG_STATE_HOME` if it is absolute).
109
112
  The server writes only there, never in the channel, which agents can write. At 256 KB it becomes
@@ -155,7 +158,7 @@ lines. Pair needs no extra sandbox config, because every channel lives under `~/
155
158
 
156
159
  ## Commands
157
160
 
158
- The agents run `send` and `wait` themselves. You'll mostly use `init`, `status`, `history` and `clean`.
161
+ The agents run `send` and `wait` themselves. You'll mostly use `init`, `status`, `history`, `clean` and `upgrade`.
159
162
 
160
163
  ```
161
164
  pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
@@ -164,10 +167,11 @@ pair status
164
167
  pair history [thread-id] # list finished threads (latest first), or print one (any unique part of its id)
165
168
  pair clean --keep N # delete all but the N newest finished threads
166
169
  pair init
170
+ pair upgrade # install the latest pair from npm, then refresh this worktree's instructions
167
171
  pair uninit # undo pair init in this worktree (keeps history; see "Uninstall")
168
172
  pair register | unregister # run by Codex in its shell: let pair_listen notify this session
169
173
  pair mcp # stdio MCP server for Codex (see "Let pair wake Codex")
170
- pair ps # list running pair mcp and pair wait processes, where they run, and what started them
174
+ pair ps [-v] # running pair mcp and pair wait processes by worktree, with each worktree's Codex registration (-v: full commands)
171
175
  ```
172
176
 
173
177
  `pair status` shows the channel, worktree root, installed package version, and each lane's turn.
@@ -186,11 +190,26 @@ codex notifications: session 01a13922-c08f-79e3-a41f-6780876fd9c5, registered 20
186
190
  last listener event: 2026-10-14T08:05:12.345Z notified 01a13922-… about /Users/you/.pair/…/001-request.md
187
191
  ```
188
192
 
189
- Instruction states are `current`, `stale`, `missing`, or `unreadable`. The check compares only
193
+ Instruction states are `current`, `stale`, `newer`, `missing`, or `unreadable`. The check compares only
190
194
  the marked pair section in `AGENTS.md`, so your other instructions don't affect it. Status
191
195
  doesn't change files. Run `pair init` to refresh missing or stale instructions, then reload
192
196
  agent sessions; fix access to unreadable files first. Current files on disk don't guarantee
193
- that a running agent has loaded them.
197
+ that a running agent has loaded them. `newer` means a newer pair wrote them: run `pair upgrade`.
198
+
199
+ `pair status` and `pair init` also print a line when npm has a newer version. They check at most
200
+ once a day and cache the answer in `~/.local/state/pair/latest.json`. The check stays quiet when
201
+ offline or sandboxed.
202
+
203
+ ## Upgrading
204
+
205
+ Run `pair upgrade` in your own terminal, not through an agent: agent sandboxes block the network and
206
+ global installs. It runs `npm install -g claude-codex-pair@latest`, then the new `pair init` in the
207
+ current worktree if pair is set up there. Other worktrees refresh their instructions on the next
208
+ `pair send`, `wait`, `history` or `clean`. pair owns these files, so edits inside them are
209
+ overwritten. pair, including `pair init`, never rewrites instructions that a newer pair wrote, so
210
+ worktrees shared by different versions don't flip back and forth. To downgrade, delete them first. Restart Claude Code and Codex afterwards. Running
211
+ sessions keep the instructions they loaded at start, and Codex keeps its old `pair mcp` until
212
+ it restarts.
194
213
 
195
214
  If a channel read or write fails with `EACCES` or `EPERM`, the command names the path and prints
196
215
  sandbox configuration snippets using your actual channel directory. Check ordinary filesystem
@@ -203,6 +222,7 @@ so a cleanup problem doesn't prevent delivering a review or starting the next th
203
222
  | `PAIR_AGENT` | (none) | Default for `--as` |
204
223
  | `PAIR_MAX_ROUNDS` | `3` | Number of review rounds before escalation |
205
224
  | `PAIR_KEEP` | `100` | Finished threads to keep, by finish time. Older ones are deleted whenever a thread finishes. `all` keeps everything, `0` keeps none |
225
+ | `PAIR_NO_UPDATE_CHECK` | (none) | Any value turns off the daily npm version check |
206
226
 
207
227
  ## Uninstall
208
228
 
package/dist/pair.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { execFile, execFileSync } from "node:child_process";
2
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { accessSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
@@ -24,6 +24,12 @@ const PAIR_SECTIONS = /<!-- pair:start -->[\s\S]*?<!-- pair:end -->(?:\r?\n)?/g;
24
24
  // SKILL.md as released versions wrote it (0.1.0 and 0.1.1), so `uninit` can tell it from an edited one.
25
25
  const RELEASED_SKILL_SHA256 = ["e9afb64c53d5e90f935bcb66406601a31a91b37758467cf867713e245f8af288"];
26
26
  const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
27
+ const PACKAGE = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
28
+ // Which pair wrote an instruction file, so an older pair never rolls back what a newer one wrote.
29
+ const STAMP = /<!-- pair (\S+) -->\n/;
30
+ // Commands that refresh stale instructions. Not status (a read-only diagnosis) or mcp (unsandboxed, so it only reads).
31
+ const REFRESHING_COMMANDS = ["send", "wait", "history", "clean", "register", "unregister"];
32
+ const UPDATE_CHECK_MS = 24 * 60 * 60 * 1000;
27
33
  const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // any UUID version; Codex uses v7
28
34
  const SESSION_FILE = "codex.session"; // the Codex session that pair_listen may notify, per channel
29
35
  const LISTEN_LOG = "codex.listen.log";
@@ -117,9 +123,10 @@ function currentThreadDir(thread) {
117
123
  function newThreadId() {
118
124
  return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
119
125
  }
120
- function gitPath(flag) {
126
+ function gitPath(flag, cwd) {
121
127
  try {
122
128
  return execFileSync("git", ["rev-parse", "--path-format=absolute", flag], {
129
+ cwd,
123
130
  encoding: "utf8",
124
131
  stdio: ["ignore", "pipe", "ignore"],
125
132
  }).trim();
@@ -505,9 +512,12 @@ function rotateLog(path) {
505
512
  * any check and the write that follows, so the unsandboxed server only ever reads channels.
506
513
  */
507
514
  function stateDir(channel) {
515
+ return join(stateHome(), basename(channel));
516
+ }
517
+ function stateHome() {
508
518
  const configured = process.env.XDG_STATE_HOME;
509
519
  const root = configured && isAbsolute(configured) ? configured : join(homedir(), ".local", "state");
510
- return join(resolve(root), "pair", basename(channel));
520
+ return join(resolve(root), "pair");
511
521
  }
512
522
  function logListen(channel, text) {
513
523
  try {
@@ -816,7 +826,11 @@ function instructionState(path, expected, sectionOnly = false) {
816
826
  if (actual === undefined) {
817
827
  return "missing"; // AGENTS.md exists but has no complete pair section
818
828
  }
819
- return actual === expected ? "current" : "stale";
829
+ if (unstamped(actual) === unstamped(expected)) {
830
+ return "current";
831
+ }
832
+ const writer = STAMP.exec(actual)?.[1];
833
+ return writer && isNewer(writer, PACKAGE.version) ? `newer (pair ${writer})` : "stale";
820
834
  }
821
835
  catch (error) {
822
836
  if (isMissing(error)) {
@@ -854,13 +868,12 @@ function notificationStatus(channel) {
854
868
  }
855
869
  function status(channel, root) {
856
870
  const lanes = channelAccess(channel, () => AGENTS.map((agent) => describe(channel, agent)));
857
- const { name, version } = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
858
871
  const agents = instructionState(join(root, "AGENTS.md"), codexSection(), true);
859
872
  const skill = instructionState(join(root, ".claude", "skills", "pair", "SKILL.md"), claudeSkill());
860
873
  return [
861
874
  `channel: ${channel}`,
862
875
  `worktree: ${root}`,
863
- `version: ${name} ${version}`,
876
+ `version: ${PACKAGE.name} ${PACKAGE.version}`,
864
877
  ...lanes,
865
878
  "instructions on disk (does not verify what running agents loaded):",
866
879
  ` AGENTS.md (pair section): ${agents}`,
@@ -870,8 +883,113 @@ function status(channel, root) {
870
883
  ? ["Run `pair init` to refresh missing or stale instructions, then reload agent sessions."] : []),
871
884
  ...(agents.startsWith("unreadable") || skill.startsWith("unreadable")
872
885
  ? ["Check permissions on the unreadable instruction paths before refreshing them."] : []),
886
+ ...(agents.startsWith("newer") || skill.startsWith("newer")
887
+ ? ["A newer pair wrote these instructions: run `pair upgrade` in your own terminal."] : []),
873
888
  ].join("\n");
874
889
  }
890
+ /**
891
+ * Rewrites instructions an older pair wrote; leaves missing, unreadable and newer ones for `pair status` to report.
892
+ * Returns a note for after the command's output: agents read `pair wait`'s first line, with stderr merged.
893
+ */
894
+ function refreshInstructions(root) {
895
+ const skillPath = join(root, ".claude", "skills", "pair", "SKILL.md");
896
+ const agentsPath = join(root, "AGENTS.md");
897
+ const refreshed = [];
898
+ try {
899
+ if (instructionState(skillPath, claudeSkill()) === "stale") {
900
+ writeFileSync(skillPath, claudeSkill());
901
+ refreshed.push(skillPath);
902
+ }
903
+ if (instructionState(agentsPath, codexSection(), true) === "stale") {
904
+ writeFileSync(agentsPath, readFileSync(agentsPath, "utf8").replace(PAIR_SECTION, () => codexSection()));
905
+ refreshed.push(`${agentsPath} (pair section)`);
906
+ }
907
+ }
908
+ catch (error) {
909
+ // A read-only sandbox: message delivery must still work, so report and go on.
910
+ const reason = error instanceof Error ? error.message : String(error);
911
+ return `pair: couldn't refresh instructions (${reason}); run \`pair init\` in your own terminal`;
912
+ }
913
+ return refreshed.length > 0
914
+ ? `pair: refreshed ${refreshed.join(", ")} to pair ${PACKAGE.version}; reload agent sessions to load them`
915
+ : undefined;
916
+ }
917
+ function unstamped(text) {
918
+ return text.replace(STAMP, "");
919
+ }
920
+ function isNewer(version, than) {
921
+ return version.localeCompare(than, "en", { numeric: true }) > 0;
922
+ }
923
+ /** The latest version on npm, checked at most once a day; undefined when opted out or the check fails. */
924
+ async function latestVersion() {
925
+ if (process.env.PAIR_NO_UPDATE_CHECK) {
926
+ return undefined;
927
+ }
928
+ const cache = join(stateHome(), "latest.json");
929
+ try {
930
+ const cached = JSON.parse(readFileSync(cache, "utf8"));
931
+ if (Date.now() - cached.checkedAt < UPDATE_CHECK_MS && typeof cached.version === "string") {
932
+ return cached.version;
933
+ }
934
+ }
935
+ catch {
936
+ // no cache yet, or a corrupt one: check again
937
+ }
938
+ let version;
939
+ try {
940
+ // ponytail: the public registry, not npm's configured one; read `npm config get registry` if mirrors matter
941
+ const response = await fetch(`https://registry.npmjs.org/${PACKAGE.name}/latest`, { signal: AbortSignal.timeout(2000) });
942
+ version = (await response.json()).version;
943
+ }
944
+ catch {
945
+ return undefined; // offline or sandboxed: the notice is best-effort
946
+ }
947
+ if (typeof version !== "string") {
948
+ return undefined;
949
+ }
950
+ try {
951
+ mkdirSync(dirname(cache), { recursive: true });
952
+ writeFileSync(cache, JSON.stringify({ checkedAt: Date.now(), version }));
953
+ }
954
+ catch {
955
+ // a read-only state dir only means checking again next time
956
+ }
957
+ return version;
958
+ }
959
+ async function updateNotice() {
960
+ const latest = await latestVersion();
961
+ return latest && isNewer(latest, PACKAGE.version)
962
+ ? [`update available: ${PACKAGE.name} ${PACKAGE.version} → ${latest}. Run \`pair upgrade\` in your own terminal.`]
963
+ : [];
964
+ }
965
+ /** Installs the latest pair, then lets the new version refresh this worktree's instructions. */
966
+ function upgrade(root) {
967
+ const install = spawnSync("npm", ["install", "--global", `${PACKAGE.name}@latest`], { stdio: "inherit" });
968
+ if (install.error) {
969
+ throw new PairError(`couldn't run npm: ${install.error.message}`);
970
+ }
971
+ if (install.status !== 0) {
972
+ return install.status ?? 1;
973
+ }
974
+ const states = [
975
+ instructionState(join(root, ".claude", "skills", "pair", "SKILL.md"), claudeSkill()),
976
+ instructionState(join(root, "AGENTS.md"), codexSection(), true),
977
+ ];
978
+ if (states.some((state) => state !== "missing")) {
979
+ // The package just installed, not whatever `pair` comes first on PATH, which may be an older copy.
980
+ const globalRoot = spawnSync("npm", ["root", "--global"], { encoding: "utf8" });
981
+ if (globalRoot.error || globalRoot.status !== 0) {
982
+ throw new PairError("installed, but `npm root --global` failed; run `pair init` yourself");
983
+ }
984
+ const installed = join(globalRoot.stdout.trim(), PACKAGE.name, "dist", "pair.js");
985
+ const init = spawnSync(process.execPath, [installed, "init"], { cwd: root, stdio: "inherit" });
986
+ if (init.error || init.status !== 0) {
987
+ throw new PairError(`installed, but the new \`pair init\` failed${init.error ? `: ${init.error.message}` : ""}; run it yourself`);
988
+ }
989
+ }
990
+ console.log("Restart Claude Code and Codex sessions to load the new version. Other worktrees refresh on their next pair send or wait.");
991
+ return 0;
992
+ }
875
993
  /** Finished threads, most recently finished first. Entries that aren't archived threads are ignored. */
876
994
  function listArchived(channel) {
877
995
  const archiveDir = join(channel, "archive");
@@ -952,25 +1070,42 @@ function renderPrompt(agent) {
952
1070
  }
953
1071
  function claudeSkill() {
954
1072
  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`;
955
- return header + renderPrompt("claude");
1073
+ return `${header}<!-- pair ${PACKAGE.version} -->\n${renderPrompt("claude")}`;
956
1074
  }
957
1075
  function codexSection() {
958
- return `<!-- pair:start -->\n${renderPrompt("codex").trimEnd()}\n<!-- pair:end -->\n`;
1076
+ return `<!-- pair:start -->\n<!-- pair ${PACKAGE.version} -->\n${renderPrompt("codex").trimEnd()}\n<!-- pair:end -->\n`;
959
1077
  }
960
1078
  function init(channel, root) {
961
1079
  channelAccess(channel, () => mkdirSync(channel, { recursive: true }));
1080
+ // Current files keep their stamp too: a newer pair may have written the same text.
1081
+ const kept = (state) => state === "current" ? " (current)" : state.startsWith("newer") ? " (a newer pair wrote it; run `pair upgrade`, or delete it to downgrade)" : undefined;
1082
+ const done = [];
962
1083
  const skillDir = join(root, ".claude", "skills", "pair");
963
- mkdirSync(skillDir, { recursive: true });
964
- writeFileSync(join(skillDir, "SKILL.md"), claudeSkill());
1084
+ const skillPath = join(skillDir, "SKILL.md");
1085
+ const skillKept = kept(instructionState(skillPath, claudeSkill()));
1086
+ if (skillKept) {
1087
+ done.push(`kept: ${skillPath}${skillKept}`);
1088
+ }
1089
+ else {
1090
+ mkdirSync(skillDir, { recursive: true });
1091
+ writeFileSync(skillPath, claudeSkill());
1092
+ done.push(`wrote: ${skillPath}`);
1093
+ }
965
1094
  const agentsPath = join(root, "AGENTS.md");
966
1095
  const section = codexSection();
967
- const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
968
- const updated = PAIR_SECTION.test(existing) ? existing.replace(PAIR_SECTION, () => section) : `${existing}${existing ? "\n" : ""}${section}`;
969
- writeFileSync(agentsPath, updated);
1096
+ const agentsKept = kept(instructionState(agentsPath, section, true));
1097
+ if (agentsKept) {
1098
+ done.push(`kept: ${agentsPath} (pair section)${agentsKept}`);
1099
+ }
1100
+ else {
1101
+ const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
1102
+ const updated = PAIR_SECTION.test(existing) ? existing.replace(PAIR_SECTION, () => section) : `${existing}${existing ? "\n" : ""}${section}`;
1103
+ writeFileSync(agentsPath, updated);
1104
+ done.push(`wrote: ${agentsPath} (pair section)`);
1105
+ }
970
1106
  return [
971
1107
  `channel: ${channel}`,
972
- `wrote: ${join(skillDir, "SKILL.md")}`,
973
- `wrote: ${agentsPath} (pair section)`,
1108
+ ...done,
974
1109
  "",
975
1110
  sandboxConfig(channel),
976
1111
  "",
@@ -1016,7 +1151,7 @@ function uninit(channel, root) {
1016
1151
  const skillPath = join(skillDir, "SKILL.md");
1017
1152
  if (existsSync(skillPath)) {
1018
1153
  const skill = readFileSync(skillPath, "utf8");
1019
- if (skill === claudeSkill() || RELEASED_SKILL_SHA256.includes(createHash("sha256").update(skill).digest("hex"))) {
1154
+ if (unstamped(skill) === unstamped(claudeSkill()) || RELEASED_SKILL_SHA256.includes(createHash("sha256").update(skill).digest("hex"))) {
1020
1155
  rmSync(skillPath);
1021
1156
  removed.push(skillPath);
1022
1157
  // Drop the directories init created, up to the first one that holds something else.
@@ -1092,7 +1227,7 @@ function workingDirs(pids) {
1092
1227
  }
1093
1228
  let out = "";
1094
1229
  try {
1095
- out = execFileSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
1230
+ out = execFileSync("lsof", ["-w", "-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1096
1231
  }
1097
1232
  catch (error) {
1098
1233
  // lsof exits 1 when one of the pids is gone, but still prints the rest
@@ -1109,30 +1244,109 @@ function workingDirs(pids) {
1109
1244
  }
1110
1245
  return dirs;
1111
1246
  }
1112
- /** Every running `pair mcp` and `pair wait` on this machine, with what started it. */
1113
- function ps() {
1247
+ /** A short name for what started a pair process; `pair ps -v` shows the full command. */
1248
+ function hostName(command) {
1249
+ if (command.includes("/ChatGPT.app/")) {
1250
+ return "ChatGPT app";
1251
+ }
1252
+ const daemon = /\/app-server-daemon\/releases\/(\d[\w.]*?)-/.exec(command);
1253
+ if (daemon) {
1254
+ return `Codex CLI ${daemon[1]}`;
1255
+ }
1256
+ const [argv0 = "", ...args] = command.split(" ");
1257
+ const name = basename(argv0);
1258
+ if (name === "claude") {
1259
+ const worktree = /--worktree (\S+)/.exec(command)?.[1];
1260
+ const session = /--resume ([0-9a-f]{8})/.exec(command)?.[1];
1261
+ const hint = worktree ? `worktree ${worktree}` : session ? `resume ${session}` : args.includes("--resume") ? "resume" : "";
1262
+ return hint ? `Claude Code (${hint})` : "Claude Code";
1263
+ }
1264
+ return name === "codex" ? "Codex CLI" : name;
1265
+ }
1266
+ function age(started) {
1267
+ const minutes = Math.max(0, Math.floor((Date.now() - new Date(started).getTime()) / 60_000));
1268
+ if (minutes < 60) {
1269
+ return `${minutes}m`;
1270
+ }
1271
+ return minutes < 24 * 60 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / (24 * 60))}d`;
1272
+ }
1273
+ /** `~` for home, and only the last four parts of a long path. */
1274
+ function shortPath(path) {
1275
+ const home = homedir();
1276
+ const tilde = path === home || path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
1277
+ const parts = tilde.split(sep);
1278
+ return tilde.length > 40 && parts.length > 5 ? [parts[0], "…", ...parts.slice(-4)].join(sep) : tilde;
1279
+ }
1280
+ /**
1281
+ * The worktree's registered Codex session and when pair last woke it, or undefined. It reads with this shell's
1282
+ * PAIR_HOME and XDG_STATE_HOME, which may not be the agents', so it only reports what it finds, never an absence.
1283
+ */
1284
+ function codexLine(root) {
1285
+ const channel = channelDir(root);
1286
+ try {
1287
+ const registration = readRegistration(channel);
1288
+ if (!registration) {
1289
+ return undefined;
1290
+ }
1291
+ const mine = ` notified ${registration.thread} about `; // not a note to an earlier session
1292
+ const log = join(stateDir(channel), LISTEN_LOG);
1293
+ const notified = existsSync(log) ? readChannelFile(log).trimEnd().split("\n").findLast((line) => line.includes(mine)) : undefined;
1294
+ const id = registration.thread.slice(0, 8);
1295
+ return notified ? `registered ${id} · last note ${age(notified.split(" ")[0])} ago` : `registered ${id}`;
1296
+ }
1297
+ catch (error) {
1298
+ return error instanceof Error ? error.message : String(error);
1299
+ }
1300
+ }
1301
+ /** Every running `pair mcp` and `pair wait` on this machine, grouped by worktree. */
1302
+ function ps(verbose) {
1114
1303
  const all = listProcesses();
1115
1304
  const byPid = new Map(all.map((proc) => [proc.pid, proc]));
1116
1305
  const ours = all.flatMap((proc) => {
1117
1306
  const match = PAIR_PROCESS.exec(proc.command);
1118
- return match ? [{ ...proc, args: match[1] }] : [];
1307
+ if (!match) {
1308
+ return [];
1309
+ }
1310
+ let host = byPid.get(proc.ppid);
1311
+ while (host && SHELL.test(host.command)) {
1312
+ host = byPid.get(host.ppid); // an agent runs pair wait through a shell; show the agent
1313
+ }
1314
+ return [{ ...proc, kind: match[1].startsWith("mcp") ? "mcp" : "wait", host }];
1119
1315
  });
1120
1316
  if (ours.length === 0) {
1121
1317
  return "no pair mcp or pair wait is running";
1122
1318
  }
1123
1319
  const dirs = workingDirs(ours.map((proc) => proc.pid));
1124
- return ours
1125
- .flatMap((proc) => {
1126
- let host = byPid.get(proc.ppid);
1127
- while (host && SHELL.test(host.command)) {
1128
- host = byPid.get(host.ppid); // an agent runs pair wait through a shell; show the agent
1320
+ const groups = new Map();
1321
+ for (const proc of ours) {
1322
+ const dir = dirs.get(proc.pid) ?? "?";
1323
+ const top = dir === "?" ? undefined : gitPath("--show-toplevel", dir);
1324
+ const root = top ? realpathOrSelf(top) : undefined;
1325
+ const key = root ?? dir;
1326
+ const group = groups.get(key) ?? { root, procs: [] };
1327
+ group.procs.push(proc);
1328
+ groups.set(key, group);
1329
+ }
1330
+ const blocks = [...groups].sort(([a, x], [b, y]) => Number(!x.root) - Number(!y.root) || a.localeCompare(b));
1331
+ return blocks
1332
+ .map(([path, { root, procs }]) => {
1333
+ const name = root ? basename(root) : path !== "?" && !existsSync(path) ? "(deleted)" : "(no worktree)";
1334
+ const lines = [`${name} ${shortPath(path)}`];
1335
+ const codex = root ? codexLine(root) : undefined;
1336
+ if (codex) {
1337
+ lines.push(` codex ${codex}`);
1129
1338
  }
1130
- return [
1131
- `pair ${proc.args} pid ${proc.pid} since ${proc.started} in ${dirs.get(proc.pid) ?? "?"}`,
1132
- ` started by ${host ? `${host.pid}: ${host.command.slice(0, 160)}` : `${proc.ppid} (gone)`}`,
1133
- ];
1339
+ procs.sort((a, b) => a.kind.localeCompare(b.kind) || a.pid - b.pid);
1340
+ for (const proc of procs) {
1341
+ const host = proc.host ? hostName(proc.host.command) : `⚠ parent gone (${proc.ppid})`;
1342
+ lines.push(` ${proc.kind.padEnd(6)} ${String(proc.pid).padEnd(6)} ${age(proc.started).padEnd(4)} ${host}`);
1343
+ if (verbose && proc.host) {
1344
+ lines.push(` started by ${proc.host.pid}: ${proc.host.command}`);
1345
+ }
1346
+ }
1347
+ return lines.join("\n");
1134
1348
  })
1135
- .join("\n");
1349
+ .join("\n\n");
1136
1350
  }
1137
1351
  function readBody(source) {
1138
1352
  if (!source) {
@@ -1160,71 +1374,82 @@ const USAGE = `usage:
1160
1374
  pair history [thread-id] (list finished threads, or show one)
1161
1375
  pair clean --keep N (delete all but the N newest finished threads)
1162
1376
  pair init
1377
+ pair upgrade (install the latest pair from npm, then refresh this worktree)
1163
1378
  pair uninit (remove what pair init wrote in this worktree; keeps history)
1164
1379
  pair register | unregister (in Codex's shell: let pair_listen notify this session)
1165
1380
  pair mcp (stdio MCP server for Codex, providing pair_listen)
1166
- pair ps (list running pair mcp and pair wait processes)`;
1381
+ pair ps [-v] (running pair mcp and pair wait processes, by worktree; -v: full commands)`;
1167
1382
  async function main(argv) {
1168
1383
  const { values, positionals } = parseArgs({
1169
1384
  args: argv,
1170
1385
  allowPositionals: true,
1171
- options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" } },
1386
+ options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" }, verbose: { type: "boolean", short: "v" } },
1172
1387
  });
1173
1388
  const [command, ...rest] = positionals;
1174
1389
  const root = worktreeRoot();
1175
1390
  const channel = channelDir(root);
1176
1391
  const agent = () => oneOf(values.as ?? process.env.PAIR_AGENT, AGENTS, "--as (or PAIR_AGENT)");
1177
- switch (command) {
1178
- case "send": {
1179
- autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1180
- const type = oneOf(rest[0], MESSAGE_TYPES, "message type");
1181
- const verdict = values.verdict === undefined ? undefined : oneOf(values.verdict, VERDICTS, "--verdict");
1182
- const me = agent();
1183
- const body = readBody(rest[1]);
1184
- console.log(`sent: ${channelAccess(channel, () => send(channel, me, type, body, verdict))}`);
1185
- return 0;
1186
- }
1187
- case "wait": {
1188
- autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1189
- const me = agent();
1190
- const timeout = positiveNumber(values.timeout ?? String(DEFAULT_WAIT_SECONDS), "--timeout");
1191
- if (!(await wait(channel, me, timeout))) {
1192
- // Exit 0: a timeout is routine, and agent UIs show any non-zero exit as a failure. No "run it
1193
- // again" here: whether to wait again differs by agent (Codex must not poll), so the prompt says.
1194
- console.log(`pair: no message for ${me} yet.`);
1392
+ const refreshNote = REFRESHING_COMMANDS.includes(command) ? refreshInstructions(root) : undefined;
1393
+ try {
1394
+ switch (command) {
1395
+ case "send": {
1396
+ autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1397
+ const type = oneOf(rest[0], MESSAGE_TYPES, "message type");
1398
+ const verdict = values.verdict === undefined ? undefined : oneOf(values.verdict, VERDICTS, "--verdict");
1399
+ const me = agent();
1400
+ const body = readBody(rest[1]);
1401
+ console.log(`sent: ${channelAccess(channel, () => send(channel, me, type, body, verdict))}`);
1402
+ return 0;
1195
1403
  }
1196
- return 0;
1197
- }
1198
- case "status":
1199
- console.log(status(channel, root));
1200
- return 0;
1201
- case "history":
1202
- console.log(channelAccess(channel, () => history(channel, rest[0])));
1203
- return 0;
1204
- case "clean":
1205
- console.log(channelAccess(channel, () => clean(channel, values.keep)));
1206
- return 0;
1207
- case "init":
1208
- console.log(init(channel, root));
1209
- return 0;
1210
- case "uninit":
1211
- console.log(uninit(channel, root));
1212
- return 0;
1213
- case "register":
1214
- console.log(channelAccess(channel, () => register(channel, root)));
1215
- return 0;
1216
- case "unregister":
1217
- console.log(channelAccess(channel, () => unregister(channel)));
1218
- return 0;
1219
- case "mcp":
1220
- await serveMcp();
1221
- return 0;
1222
- case "ps":
1223
- console.log(ps());
1224
- return 0;
1225
- default:
1226
- console.error(USAGE);
1227
- return 1;
1404
+ case "wait": {
1405
+ autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1406
+ const me = agent();
1407
+ const timeout = positiveNumber(values.timeout ?? String(DEFAULT_WAIT_SECONDS), "--timeout");
1408
+ if (!(await wait(channel, me, timeout))) {
1409
+ // Exit 0: a timeout is routine, and agent UIs show any non-zero exit as a failure. No "run it
1410
+ // again" here: whether to wait again differs by agent (Codex must not poll), so the prompt says.
1411
+ console.log(`pair: no message for ${me} yet.`);
1412
+ }
1413
+ return 0;
1414
+ }
1415
+ case "status":
1416
+ console.log([status(channel, root), ...(await updateNotice())].join("\n"));
1417
+ return 0;
1418
+ case "history":
1419
+ console.log(channelAccess(channel, () => history(channel, rest[0])));
1420
+ return 0;
1421
+ case "clean":
1422
+ console.log(channelAccess(channel, () => clean(channel, values.keep)));
1423
+ return 0;
1424
+ case "init":
1425
+ console.log([init(channel, root), ...(await updateNotice())].join("\n"));
1426
+ return 0;
1427
+ case "upgrade":
1428
+ return upgrade(root);
1429
+ case "uninit":
1430
+ console.log(uninit(channel, root));
1431
+ return 0;
1432
+ case "register":
1433
+ console.log(channelAccess(channel, () => register(channel, root)));
1434
+ return 0;
1435
+ case "unregister":
1436
+ console.log(channelAccess(channel, () => unregister(channel)));
1437
+ return 0;
1438
+ case "mcp":
1439
+ await serveMcp();
1440
+ return 0;
1441
+ case "ps":
1442
+ console.log(ps(values.verbose ?? false));
1443
+ return 0;
1444
+ default:
1445
+ console.error(USAGE);
1446
+ return 1;
1447
+ }
1448
+ }
1449
+ finally {
1450
+ if (refreshNote) {
1451
+ console.error(refreshNote);
1452
+ }
1228
1453
  }
1229
1454
  }
1230
1455
  main(process.argv.slice(2)).then((code) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Claude Code and Codex review each other's work over a file-system chat channel",
5
5
  "keywords": [
6
6
  "claude-code",