claude-codex-pair 0.2.0 → 0.3.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 CHANGED
@@ -168,5 +168,5 @@ pair mcp
168
168
  - Two worktrees each woke only their own session.
169
169
  - With Codex as the author, a `changes` review and the final approval each woke it once, and its `pair wait` archived the approved thread.
170
170
  - Approval policy `never` makes `pair_listen` fail, and Codex reports it and stops without polling.
171
- - The MCP server is a child of the app-server daemon, not of the TUI. After the last TUI on a session quit, it ran 1–2 more minutes, until the daemon unloaded the session. A message in that gap still wakes the session with no TUI attached. Desktop and IDE sessions, `--no-daemon`, and `--remote` are untested.
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. 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
172
  - Completion-driven waiting is verified only for interactive Claude Code sessions, with waits of up to a few minutes. A full hour is untested. Headless `claude -p` ends background commands after its final result, so the prompt falls back to a foreground wait. Codex was tried with one long `pair wait` that it kept checking. Its built-in instructions cap each check at about 60 seconds, so an idle Codex made a model call every minute, enough to run a workspace out of credits. A finished background command doesn't resume Codex (measured with CLI 0.162.1), which is why Codex uses `pair mcp` instead.
package/README.md CHANGED
@@ -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
@@ -167,6 +170,7 @@ pair init
167
170
  pair uninit # undo pair init in this worktree (keeps history; see "Uninstall")
168
171
  pair register | unregister # run by Codex in its shell: let pair_listen notify this session
169
172
  pair mcp # stdio MCP server for Codex (see "Let pair wake Codex")
173
+ pair ps [-v] # running pair mcp and pair wait processes by worktree, with each worktree's Codex registration (-v: full commands)
170
174
  ```
171
175
 
172
176
  `pair status` shows the channel, worktree root, installed package version, and each lane's turn.
@@ -175,7 +179,7 @@ It also checks the generated instructions on disk:
175
179
  ```
176
180
  channel: /Users/you/.pair/your-repo-12345678
177
181
  worktree: /Users/you/your-repo
178
- version: claude-codex-pair 0.1.0
182
+ version: claude-codex-pair 0.2.0
179
183
  claude-to-codex: idle
180
184
  codex-to-claude: idle
181
185
  instructions on disk (does not verify what running agents loaded):
package/dist/pair.js CHANGED
@@ -117,9 +117,10 @@ function currentThreadDir(thread) {
117
117
  function newThreadId() {
118
118
  return `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
119
119
  }
120
- function gitPath(flag) {
120
+ function gitPath(flag, cwd) {
121
121
  try {
122
122
  return execFileSync("git", ["rev-parse", "--path-format=absolute", flag], {
123
+ cwd,
123
124
  encoding: "utf8",
124
125
  stdio: ["ignore", "pipe", "ignore"],
125
126
  }).trim();
@@ -1059,6 +1060,160 @@ function uninit(channel, root) {
1059
1060
  const summary = removed.length > 0 ? removed.map((item) => `removed: ${item}`) : [`nothing from pair init found in ${root}`];
1060
1061
  return [...summary, ...notes].join("\n");
1061
1062
  }
1063
+ const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\w{3} \w{3}\s+\d+ [\d:]+ \d{4})\s+(.*)$/; // lstart is always 5 fields
1064
+ // node itself runs the script, so a wrapper whose command line mentions it (`zsh -c "… pair.js wait"`) doesn't match
1065
+ const PAIR_PROCESS = /^(?:\S*\/)?node(?: -\S+)* \S*\/(?:bin\/pair|dist\/pair\.js) ((?:mcp|wait)\b.*)$/;
1066
+ const SHELL = /^(?:\S*\/)?(?:zsh|bash|sh|dash|fish)(?: |$)/;
1067
+ function listProcesses() {
1068
+ let out;
1069
+ try {
1070
+ out = execFileSync("ps", ["-axo", "pid=,ppid=,lstart=,command="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } });
1071
+ }
1072
+ catch (error) {
1073
+ throw new PairError(`can't list processes (${error instanceof Error ? error.message : String(error)}); agent sandboxes often block ps, so run pair ps in your own terminal`);
1074
+ }
1075
+ return out.split("\n").flatMap((line) => {
1076
+ const match = PS_LINE.exec(line);
1077
+ return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), started: match[3], command: match[4] }] : [];
1078
+ });
1079
+ }
1080
+ /** Working directories by pid: /proc on Linux, lsof elsewhere. Unknown ones are left out. */
1081
+ function workingDirs(pids) {
1082
+ const dirs = new Map();
1083
+ if (existsSync("/proc/self/cwd")) {
1084
+ for (const pid of pids) {
1085
+ try {
1086
+ dirs.set(pid, realpathSync(`/proc/${pid}/cwd`));
1087
+ }
1088
+ catch {
1089
+ // gone, or not ours
1090
+ }
1091
+ }
1092
+ return dirs;
1093
+ }
1094
+ let out = "";
1095
+ try {
1096
+ out = execFileSync("lsof", ["-w", "-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1097
+ }
1098
+ catch (error) {
1099
+ // lsof exits 1 when one of the pids is gone, but still prints the rest
1100
+ out = error instanceof Error && "stdout" in error ? String(error.stdout) : "";
1101
+ }
1102
+ let pid = 0;
1103
+ for (const line of out.split("\n")) {
1104
+ if (line.startsWith("p")) {
1105
+ pid = Number(line.slice(1));
1106
+ }
1107
+ else if (line.startsWith("n")) {
1108
+ dirs.set(pid, line.slice(1));
1109
+ }
1110
+ }
1111
+ return dirs;
1112
+ }
1113
+ /** A short name for what started a pair process; `pair ps -v` shows the full command. */
1114
+ function hostName(command) {
1115
+ if (command.includes("/ChatGPT.app/")) {
1116
+ return "ChatGPT app";
1117
+ }
1118
+ const daemon = /\/app-server-daemon\/releases\/(\d[\w.]*?)-/.exec(command);
1119
+ if (daemon) {
1120
+ return `Codex CLI ${daemon[1]}`;
1121
+ }
1122
+ const [argv0 = "", ...args] = command.split(" ");
1123
+ const name = basename(argv0);
1124
+ if (name === "claude") {
1125
+ const worktree = /--worktree (\S+)/.exec(command)?.[1];
1126
+ const session = /--resume ([0-9a-f]{8})/.exec(command)?.[1];
1127
+ const hint = worktree ? `worktree ${worktree}` : session ? `resume ${session}` : args.includes("--resume") ? "resume" : "";
1128
+ return hint ? `Claude Code (${hint})` : "Claude Code";
1129
+ }
1130
+ return name === "codex" ? "Codex CLI" : name;
1131
+ }
1132
+ function age(started) {
1133
+ const minutes = Math.max(0, Math.floor((Date.now() - new Date(started).getTime()) / 60_000));
1134
+ if (minutes < 60) {
1135
+ return `${minutes}m`;
1136
+ }
1137
+ return minutes < 24 * 60 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / (24 * 60))}d`;
1138
+ }
1139
+ /** `~` for home, and only the last four parts of a long path. */
1140
+ function shortPath(path) {
1141
+ const home = homedir();
1142
+ const tilde = path === home || path.startsWith(`${home}${sep}`) ? `~${path.slice(home.length)}` : path;
1143
+ const parts = tilde.split(sep);
1144
+ return tilde.length > 40 && parts.length > 5 ? [parts[0], "…", ...parts.slice(-4)].join(sep) : tilde;
1145
+ }
1146
+ /**
1147
+ * The worktree's registered Codex session and when pair last woke it, or undefined. It reads with this shell's
1148
+ * PAIR_HOME and XDG_STATE_HOME, which may not be the agents', so it only reports what it finds, never an absence.
1149
+ */
1150
+ function codexLine(root) {
1151
+ const channel = channelDir(root);
1152
+ try {
1153
+ const registration = readRegistration(channel);
1154
+ if (!registration) {
1155
+ return undefined;
1156
+ }
1157
+ const mine = ` notified ${registration.thread} about `; // not a note to an earlier session
1158
+ const log = join(stateDir(channel), LISTEN_LOG);
1159
+ const notified = existsSync(log) ? readChannelFile(log).trimEnd().split("\n").findLast((line) => line.includes(mine)) : undefined;
1160
+ const id = registration.thread.slice(0, 8);
1161
+ return notified ? `registered ${id} · last note ${age(notified.split(" ")[0])} ago` : `registered ${id}`;
1162
+ }
1163
+ catch (error) {
1164
+ return error instanceof Error ? error.message : String(error);
1165
+ }
1166
+ }
1167
+ /** Every running `pair mcp` and `pair wait` on this machine, grouped by worktree. */
1168
+ function ps(verbose) {
1169
+ const all = listProcesses();
1170
+ const byPid = new Map(all.map((proc) => [proc.pid, proc]));
1171
+ const ours = all.flatMap((proc) => {
1172
+ const match = PAIR_PROCESS.exec(proc.command);
1173
+ if (!match) {
1174
+ return [];
1175
+ }
1176
+ let host = byPid.get(proc.ppid);
1177
+ while (host && SHELL.test(host.command)) {
1178
+ host = byPid.get(host.ppid); // an agent runs pair wait through a shell; show the agent
1179
+ }
1180
+ return [{ ...proc, kind: match[1].startsWith("mcp") ? "mcp" : "wait", host }];
1181
+ });
1182
+ if (ours.length === 0) {
1183
+ return "no pair mcp or pair wait is running";
1184
+ }
1185
+ const dirs = workingDirs(ours.map((proc) => proc.pid));
1186
+ const groups = new Map();
1187
+ for (const proc of ours) {
1188
+ const dir = dirs.get(proc.pid) ?? "?";
1189
+ const top = dir === "?" ? undefined : gitPath("--show-toplevel", dir);
1190
+ const root = top ? realpathOrSelf(top) : undefined;
1191
+ const key = root ?? dir;
1192
+ const group = groups.get(key) ?? { root, procs: [] };
1193
+ group.procs.push(proc);
1194
+ groups.set(key, group);
1195
+ }
1196
+ const blocks = [...groups].sort(([a, x], [b, y]) => Number(!x.root) - Number(!y.root) || a.localeCompare(b));
1197
+ return blocks
1198
+ .map(([path, { root, procs }]) => {
1199
+ const name = root ? basename(root) : path !== "?" && !existsSync(path) ? "(deleted)" : "(no worktree)";
1200
+ const lines = [`${name} ${shortPath(path)}`];
1201
+ const codex = root ? codexLine(root) : undefined;
1202
+ if (codex) {
1203
+ lines.push(` codex ${codex}`);
1204
+ }
1205
+ procs.sort((a, b) => a.kind.localeCompare(b.kind) || a.pid - b.pid);
1206
+ for (const proc of procs) {
1207
+ const host = proc.host ? hostName(proc.host.command) : `⚠ parent gone (${proc.ppid})`;
1208
+ lines.push(` ${proc.kind.padEnd(6)} ${String(proc.pid).padEnd(6)} ${age(proc.started).padEnd(4)} ${host}`);
1209
+ if (verbose && proc.host) {
1210
+ lines.push(` started by ${proc.host.pid}: ${proc.host.command}`);
1211
+ }
1212
+ }
1213
+ return lines.join("\n");
1214
+ })
1215
+ .join("\n\n");
1216
+ }
1062
1217
  function readBody(source) {
1063
1218
  if (!source) {
1064
1219
  throw new PairError("send needs a message file, or - for stdin");
@@ -1087,12 +1242,13 @@ const USAGE = `usage:
1087
1242
  pair init
1088
1243
  pair uninit (remove what pair init wrote in this worktree; keeps history)
1089
1244
  pair register | unregister (in Codex's shell: let pair_listen notify this session)
1090
- pair mcp (stdio MCP server for Codex, providing pair_listen)`;
1245
+ pair mcp (stdio MCP server for Codex, providing pair_listen)
1246
+ pair ps [-v] (running pair mcp and pair wait processes, by worktree; -v: full commands)`;
1091
1247
  async function main(argv) {
1092
1248
  const { values, positionals } = parseArgs({
1093
1249
  args: argv,
1094
1250
  allowPositionals: true,
1095
- options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" } },
1251
+ options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" }, verbose: { type: "boolean", short: "v" } },
1096
1252
  });
1097
1253
  const [command, ...rest] = positionals;
1098
1254
  const root = worktreeRoot();
@@ -1143,6 +1299,9 @@ async function main(argv) {
1143
1299
  case "mcp":
1144
1300
  await serveMcp();
1145
1301
  return 0;
1302
+ case "ps":
1303
+ console.log(ps(values.verbose ?? false));
1304
+ return 0;
1146
1305
  default:
1147
1306
  console.error(USAGE);
1148
1307
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",