claude-codex-pair 0.3.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,7 +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")
170
- pair ps # list running pair mcp and pair wait processes, where they run, and what started them
173
+ pair ps [-v] # running pair mcp and pair wait processes by worktree, with each worktree's Codex registration (-v: full commands)
171
174
  ```
172
175
 
173
176
  `pair status` shows the channel, worktree root, installed package version, and each lane's turn.
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();
@@ -1092,7 +1093,7 @@ function workingDirs(pids) {
1092
1093
  }
1093
1094
  let out = "";
1094
1095
  try {
1095
- out = execFileSync("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8" });
1096
+ out = execFileSync("lsof", ["-w", "-a", "-d", "cwd", "-Fpn", "-p", pids.join(",")], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
1096
1097
  }
1097
1098
  catch (error) {
1098
1099
  // lsof exits 1 when one of the pids is gone, but still prints the rest
@@ -1109,30 +1110,109 @@ function workingDirs(pids) {
1109
1110
  }
1110
1111
  return dirs;
1111
1112
  }
1112
- /** Every running `pair mcp` and `pair wait` on this machine, with what started it. */
1113
- function ps() {
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) {
1114
1169
  const all = listProcesses();
1115
1170
  const byPid = new Map(all.map((proc) => [proc.pid, proc]));
1116
1171
  const ours = all.flatMap((proc) => {
1117
1172
  const match = PAIR_PROCESS.exec(proc.command);
1118
- return match ? [{ ...proc, args: match[1] }] : [];
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 }];
1119
1181
  });
1120
1182
  if (ours.length === 0) {
1121
1183
  return "no pair mcp or pair wait is running";
1122
1184
  }
1123
1185
  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
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
+ }
1129
1212
  }
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
- ];
1213
+ return lines.join("\n");
1134
1214
  })
1135
- .join("\n");
1215
+ .join("\n\n");
1136
1216
  }
1137
1217
  function readBody(source) {
1138
1218
  if (!source) {
@@ -1163,12 +1243,12 @@ const USAGE = `usage:
1163
1243
  pair uninit (remove what pair init wrote in this worktree; keeps history)
1164
1244
  pair register | unregister (in Codex's shell: let pair_listen notify this session)
1165
1245
  pair mcp (stdio MCP server for Codex, providing pair_listen)
1166
- pair ps (list running pair mcp and pair wait processes)`;
1246
+ pair ps [-v] (running pair mcp and pair wait processes, by worktree; -v: full commands)`;
1167
1247
  async function main(argv) {
1168
1248
  const { values, positionals } = parseArgs({
1169
1249
  args: argv,
1170
1250
  allowPositionals: true,
1171
- 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" } },
1172
1252
  });
1173
1253
  const [command, ...rest] = positionals;
1174
1254
  const root = worktreeRoot();
@@ -1220,7 +1300,7 @@ async function main(argv) {
1220
1300
  await serveMcp();
1221
1301
  return 0;
1222
1302
  case "ps":
1223
- console.log(ps());
1303
+ console.log(ps(values.verbose ?? false));
1224
1304
  return 0;
1225
1305
  default:
1226
1306
  console.error(USAGE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.3.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",