claude-codex-pair 0.4.0 → 0.5.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/dist/pair.js CHANGED
@@ -8,6 +8,7 @@ import { createInterface } from "node:readline";
8
8
  import { setTimeout as sleep } from "node:timers/promises";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { parseArgs } from "node:util";
11
+ import { autoCall, autoSocketPath, autoStatus, runGuardian, startAuto, stopAuto, withAutoStopped } from "./auto.js";
11
12
  const AGENTS = ["claude", "codex"];
12
13
  const MESSAGE_TYPES = ["request", "review", "response"];
13
14
  const VERDICTS = ["approve", "changes"];
@@ -111,7 +112,7 @@ function currentThreadDir(thread) {
111
112
  const entries = readdirOrEmpty(thread.lane);
112
113
  const legacy = entries.find((entry) => entry.isFile() && MESSAGE_FILE.test(entry.name));
113
114
  if (legacy) {
114
- 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.`);
115
+ 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 stop the agents and run pair reset --as ${thread.author} to back it up and clear the lane.`);
115
116
  }
116
117
  const latest = entries
117
118
  .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
@@ -296,7 +297,7 @@ function send(channel, me, type, body, verdict) {
296
297
  const state = stateOf(messages);
297
298
  if (type === "request") {
298
299
  if (state.kind === "awaiting-review" || state.kind === "awaiting-response") {
299
- throw new PairError(`a review thread to ${thread.reviewer} is already open (${state.kind}); finish it first`);
300
+ throw new PairError(`a review thread to ${thread.reviewer} is already open (${state.kind}); finish it first, or stop the agents and manually run pair reset --as ${me} to back up and clear this lane`);
300
301
  }
301
302
  if (state.kind === "closed") {
302
303
  archive(channel, state.last);
@@ -319,6 +320,9 @@ function sendReview(channel, me, body, verdict) {
319
320
  if (state.kind !== "awaiting-review") {
320
321
  throw new PairError(`cannot send review: nothing from ${thread.author} is waiting for review (thread is ${state.kind})`);
321
322
  }
323
+ if (process.env.PAIR_AUTO_ROLE === "reviewer" && state.last.file !== process.env.PAIR_AUTO_MESSAGE) {
324
+ throw new PairError("managed reviewer cannot reply to a different message");
325
+ }
322
326
  const round = state.last.round;
323
327
  const status = verdict === "approve" ? "approved" : round >= maxRounds() ? "escalated" : "open";
324
328
  const review = { from: me, type: "review", round, verdict, status };
@@ -329,6 +333,13 @@ function escalationNotice() {
329
333
  return `ESCALATED: round cap (${maxRounds()}) reached with open findings. STOP and ask the user how to proceed.`;
330
334
  }
331
335
  function pendingFor(channel, me) {
336
+ if (process.env.PAIR_AUTO_ROLE === "reviewer") {
337
+ const thread = threadFor(channel, partnerOf(me));
338
+ const messages = listMessages(thread);
339
+ const state = stateOf(messages);
340
+ return state.kind === "awaiting-review" && state.last.file === process.env.PAIR_AUTO_MESSAGE
341
+ ? { thread, state, earlier: messages.slice(0, -1) } : undefined;
342
+ }
332
343
  const asAuthor = threadFor(channel, me);
333
344
  const authorMessages = listMessages(asAuthor);
334
345
  const authorState = stateOf(authorMessages);
@@ -892,24 +903,31 @@ function status(channel, root) {
892
903
  * Returns a note for after the command's output: agents read `pair wait`'s first line, with stderr merged.
893
904
  */
894
905
  function refreshInstructions(root) {
895
- const skillPath = join(root, ".claude", "skills", "pair", "SKILL.md");
896
- const agentsPath = join(root, "AGENTS.md");
897
- const refreshed = [];
898
906
  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
+ return refreshedNote(refreshStale(root));
907
908
  }
908
909
  catch (error) {
909
910
  // A read-only sandbox: message delivery must still work, so report and go on.
910
911
  const reason = error instanceof Error ? error.message : String(error);
911
912
  return `pair: couldn't refresh instructions (${reason}); run \`pair init\` in your own terminal`;
912
913
  }
914
+ }
915
+ /** The stale instruction files it rewrote. A write failure throws. */
916
+ function refreshStale(root) {
917
+ const skillPath = join(root, ".claude", "skills", "pair", "SKILL.md");
918
+ const agentsPath = join(root, "AGENTS.md");
919
+ const refreshed = [];
920
+ if (instructionState(skillPath, claudeSkill()) === "stale") {
921
+ writeFileSync(skillPath, claudeSkill());
922
+ refreshed.push(skillPath);
923
+ }
924
+ if (instructionState(agentsPath, codexSection(), true) === "stale") {
925
+ writeFileSync(agentsPath, readFileSync(agentsPath, "utf8").replace(PAIR_SECTION, () => codexSection()));
926
+ refreshed.push(`${agentsPath} (pair section)`);
927
+ }
928
+ return refreshed;
929
+ }
930
+ function refreshedNote(refreshed) {
913
931
  return refreshed.length > 0
914
932
  ? `pair: refreshed ${refreshed.join(", ")} to pair ${PACKAGE.version}; reload agent sessions to load them`
915
933
  : undefined;
@@ -964,6 +982,21 @@ async function updateNotice() {
964
982
  }
965
983
  /** Installs the latest pair, then lets the new version refresh this worktree's instructions. */
966
984
  function upgrade(root) {
985
+ // npm's own answer, from the registry it installs from; if it fails, the install reports why.
986
+ const view = spawnSync("npm", ["view", PACKAGE.name, "version"], { encoding: "utf8" });
987
+ const latest = view.status === 0 ? view.stdout.trim() : undefined;
988
+ if (latest && !isNewer(latest, PACKAGE.version)) {
989
+ let note;
990
+ try {
991
+ note = refreshedNote(refreshStale(root)); // refreshing is all that is left to do, so a failure fails upgrade
992
+ }
993
+ catch (error) {
994
+ const reason = error instanceof Error ? error.message : String(error);
995
+ throw new PairError(`already up to date, but couldn't refresh instructions (${reason})`);
996
+ }
997
+ console.log(`already up to date: ${PACKAGE.name} ${PACKAGE.version}${note ? `\n${note}` : ""}`);
998
+ return 0;
999
+ }
967
1000
  const install = spawnSync("npm", ["install", "--global", `${PACKAGE.name}@latest`], { stdio: "inherit" });
968
1001
  if (install.error) {
969
1002
  throw new PairError(`couldn't run npm: ${install.error.message}`);
@@ -982,12 +1015,14 @@ function upgrade(root) {
982
1015
  throw new PairError("installed, but `npm root --global` failed; run `pair init` yourself");
983
1016
  }
984
1017
  const installed = join(globalRoot.stdout.trim(), PACKAGE.name, "dist", "pair.js");
985
- const init = spawnSync(process.execPath, [installed, "init"], { cwd: root, stdio: "inherit" });
1018
+ const init = spawnSync(process.execPath, [installed, "init"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
986
1019
  if (init.error || init.status !== 0) {
987
1020
  throw new PairError(`installed, but the new \`pair init\` failed${init.error ? `: ${init.error.message}` : ""}; run it yourself`);
988
1021
  }
1022
+ // Only what it did to the files: the sandbox and `codex mcp add` setup it also prints is already done.
1023
+ console.log(init.stdout.split("\n").filter((line) => /^(?:wrote|kept): /.test(line)).join("\n"));
989
1024
  }
990
- console.log("Restart Claude Code and Codex sessions to load the new version. Other worktrees refresh on their next pair send or wait.");
1025
+ console.log(`upgraded: ${PACKAGE.name} ${PACKAGE.version} → ${latest ?? "latest"}. Restart Claude Code and Codex sessions to load it; other worktrees refresh on their next pair send or wait.`);
991
1026
  return 0;
992
1027
  }
993
1028
  /** Finished threads, most recently finished first. Entries that aren't archived threads are ignored. */
@@ -1064,6 +1099,55 @@ function clean(channel, keepValue) {
1064
1099
  const keep = parseKeep(keepValue, "--keep");
1065
1100
  return `removed ${prune(channel, keep)} finished thread(s), kept up to ${keep}`;
1066
1101
  }
1102
+ /** Back up whole lanes so reset also repairs legacy, corrupt, and partially written requests. */
1103
+ function reset(channel, root, author, dryRun) {
1104
+ const lanes = (author ? [author] : AGENTS)
1105
+ .flatMap((agent) => {
1106
+ const lane = threadFor(channel, agent).lane;
1107
+ const stat = lstatSync(lane, { throwIfNoEntry: false });
1108
+ if (!stat || (stat.isDirectory() && readdirSync(lane).length === 0))
1109
+ return [];
1110
+ let label = `${basename(lane)}: ${stat.isSymbolicLink() ? "symlink" : "unparseable"}`;
1111
+ if (stat.isDirectory()) {
1112
+ try {
1113
+ label = describe(channel, agent);
1114
+ }
1115
+ catch { /* backup must still work for malformed requests */ }
1116
+ }
1117
+ return [{ lane, label }];
1118
+ });
1119
+ if (lanes.length === 0)
1120
+ return `nothing to reset in ${root}`;
1121
+ if (dryRun)
1122
+ return [
1123
+ `would reset in ${root}:`,
1124
+ ...lanes.map(({ label }) => ` ${label}`),
1125
+ `would preserve their contents under ${join(channel, "resets")}; no files changed`,
1126
+ ].join("\n");
1127
+ const resetRoot = join(channel, "resets");
1128
+ const stat = lstatSync(resetRoot, { throwIfNoEntry: false });
1129
+ if (stat && !stat.isDirectory())
1130
+ throw new PairError(`refusing to write reset backups through a non-directory or symlink: ${resetRoot}`);
1131
+ const backup = join(resetRoot, newThreadId());
1132
+ mkdirSync(backup, { recursive: true });
1133
+ const moved = [];
1134
+ try {
1135
+ writeFileSync(join(backup, "reset.json"), `${JSON.stringify({ resetAt: new Date().toISOString(), worktree: root, plannedLanes: lanes.map(({ lane }) => basename(lane)) }, null, 2)}\n`);
1136
+ for (const { lane } of lanes) {
1137
+ renameSync(lane, join(backup, basename(lane)));
1138
+ moved.push(basename(lane));
1139
+ }
1140
+ }
1141
+ catch (error) {
1142
+ const failure = channelError(channel, error);
1143
+ throw new PairError(`reset failed${moved.length ? ` after moving ${moved.join(", ")}` : ""}; any moved requests are preserved at ${backup}: ${failure instanceof Error ? failure.message : String(failure)}`);
1144
+ }
1145
+ return [
1146
+ ...lanes.map(({ label }) => `reset: ${label}`),
1147
+ `saved pending requests: ${backup}`,
1148
+ "Archived history and source files are unchanged. Restart manual agents before sending a fresh request.",
1149
+ ].join("\n");
1150
+ }
1067
1151
  function renderPrompt(agent) {
1068
1152
  const template = readFileSync(join(PACKAGE_ROOT, "prompts", "pair.md"), "utf8");
1069
1153
  return template.replaceAll("{{AGENT}}", agent).replaceAll("{{PARTNER}}", partnerOf(agent));
@@ -1109,6 +1193,7 @@ function init(channel, root) {
1109
1193
  "",
1110
1194
  sandboxConfig(channel),
1111
1195
  "",
1196
+ ...(process.platform !== "win32" ? [`Auto mode: pair start --as claude adds sandbox.network.allowUnixSockets for ${autoSocketPath(stateDir(channel))} to that Claude session only. No persistent socket setting is needed.`, ""] : []),
1112
1197
  "To let pair wake Codex when a message arrives, instead of Codex polling, register pair's MCP server once:",
1113
1198
  ` ${mcpAddCommand(root)}`,
1114
1199
  ].join("\n");
@@ -1196,7 +1281,7 @@ function uninit(channel, root) {
1196
1281
  }
1197
1282
  const PS_LINE = /^\s*(\d+)\s+(\d+)\s+(\w{3} \w{3}\s+\d+ [\d:]+ \d{4})\s+(.*)$/; // lstart is always 5 fields
1198
1283
  // node itself runs the script, so a wrapper whose command line mentions it (`zsh -c "… pair.js wait"`) doesn't match
1199
- const PAIR_PROCESS = /^(?:\S*\/)?node(?: -\S+)* \S*\/(?:bin\/pair|dist\/pair\.js) ((?:mcp|wait)\b.*)$/;
1284
+ const PAIR_PROCESS = /^(?:\S*\/)?node(?: -\S+)* \S*\/(?:bin\/pair|dist\/pair\.js) ((?:mcp|wait|start|_auto-guardian)\b.*)$/;
1200
1285
  const SHELL = /^(?:\S*\/)?(?:zsh|bash|sh|dash|fish)(?: |$)/;
1201
1286
  function listProcesses() {
1202
1287
  let out;
@@ -1298,8 +1383,8 @@ function codexLine(root) {
1298
1383
  return error instanceof Error ? error.message : String(error);
1299
1384
  }
1300
1385
  }
1301
- /** Every running `pair mcp` and `pair wait` on this machine, grouped by worktree. */
1302
- function ps(verbose) {
1386
+ /** Pair listeners, waits, auto launchers and guardians on this machine, grouped by worktree. */
1387
+ async function ps(verbose) {
1303
1388
  const all = listProcesses();
1304
1389
  const byPid = new Map(all.map((proc) => [proc.pid, proc]));
1305
1390
  const ours = all.flatMap((proc) => {
@@ -1311,10 +1396,12 @@ function ps(verbose) {
1311
1396
  while (host && SHELL.test(host.command)) {
1312
1397
  host = byPid.get(host.ppid); // an agent runs pair wait through a shell; show the agent
1313
1398
  }
1314
- return [{ ...proc, kind: match[1].startsWith("mcp") ? "mcp" : "wait", host }];
1399
+ const command = match[1].split(" ")[0];
1400
+ const kind = command === "_auto-guardian" ? "guardian" : command;
1401
+ return [{ ...proc, kind, host }];
1315
1402
  });
1316
1403
  if (ours.length === 0) {
1317
- return "no pair mcp or pair wait is running";
1404
+ return "no pair mcp, wait, start or guardian is running";
1318
1405
  }
1319
1406
  const dirs = workingDirs(ours.map((proc) => proc.pid));
1320
1407
  const groups = new Map();
@@ -1328,14 +1415,17 @@ function ps(verbose) {
1328
1415
  groups.set(key, group);
1329
1416
  }
1330
1417
  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 }]) => {
1418
+ return (await Promise.all(blocks
1419
+ .map(async ([path, { root, procs }]) => {
1333
1420
  const name = root ? basename(root) : path !== "?" && !existsSync(path) ? "(deleted)" : "(no worktree)";
1334
1421
  const lines = [`${name} ${shortPath(path)}`];
1335
1422
  const codex = root ? codexLine(root) : undefined;
1336
1423
  if (codex) {
1337
1424
  lines.push(` codex ${codex}`);
1338
1425
  }
1426
+ if (root && procs.some((proc) => proc.kind === "start" || proc.kind === "guardian")) {
1427
+ lines.push(...(await autoStatus(stateDir(channelDir(root)))).map((line) => ` ${line}`));
1428
+ }
1339
1429
  procs.sort((a, b) => a.kind.localeCompare(b.kind) || a.pid - b.pid);
1340
1430
  for (const proc of procs) {
1341
1431
  const host = proc.host ? hostName(proc.host.command) : `⚠ parent gone (${proc.ppid})`;
@@ -1345,7 +1435,7 @@ function ps(verbose) {
1345
1435
  }
1346
1436
  }
1347
1437
  return lines.join("\n");
1348
- })
1438
+ })))
1349
1439
  .join("\n\n");
1350
1440
  }
1351
1441
  function readBody(source) {
@@ -1368,43 +1458,125 @@ function oneOf(value, allowed, what) {
1368
1458
  return value;
1369
1459
  }
1370
1460
  const USAGE = `usage:
1461
+ pair start --as <claude|codex> [--reviewer-model MODEL] [--reviewer-effort LEVEL]
1462
+ [--claude /path] [--codex /path] [--timeout seconds] [--grace seconds]
1463
+ pair stop (stop this worktree's auto session and its managed agents)
1371
1464
  pair send <request|review|response> --as <claude|codex> [--verdict approve|changes] <file|->
1372
1465
  pair wait --as <claude|codex> [--timeout seconds] (prints "no message … yet" on timeout)
1373
1466
  pair status
1374
1467
  pair history [thread-id] (list finished threads, or show one)
1375
1468
  pair clean --keep N (delete all but the N newest finished threads)
1469
+ pair reset [--as <claude|codex>] [--dry-run] (back up pending lanes; --as selects their author, default: both)
1376
1470
  pair init
1377
1471
  pair upgrade (install the latest pair from npm, then refresh this worktree)
1378
1472
  pair uninit (remove what pair init wrote in this worktree; keeps history)
1379
1473
  pair register | unregister (in Codex's shell: let pair_listen notify this session)
1380
1474
  pair mcp (stdio MCP server for Codex, providing pair_listen)
1381
- pair ps [-v] (running pair mcp and pair wait processes, by worktree; -v: full commands)`;
1475
+ pair ps [-v] (running pair processes, including auto owners, by worktree; -v: full commands)`;
1382
1476
  async function main(argv) {
1383
1477
  const { values, positionals } = parseArgs({
1384
1478
  args: argv,
1385
1479
  allowPositionals: true,
1386
- options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" }, verbose: { type: "boolean", short: "v" } },
1480
+ options: { as: { type: "string" }, verdict: { type: "string" }, timeout: { type: "string" }, keep: { type: "string" }, verbose: { type: "boolean", short: "v" }, claude: { type: "string" }, codex: { type: "string" }, grace: { type: "string" }, help: { type: "boolean", short: "h" }, "reviewer-model": { type: "string" }, "reviewer-effort": { type: "string" }, "dry-run": { type: "boolean" } },
1387
1481
  });
1388
1482
  const [command, ...rest] = positionals;
1483
+ if (values.help || command === "help") {
1484
+ console.log(USAGE);
1485
+ return 0;
1486
+ }
1487
+ if (values["dry-run"] && command !== "reset")
1488
+ throw new PairError("--dry-run is only supported by pair reset");
1389
1489
  const root = worktreeRoot();
1390
1490
  const channel = channelDir(root);
1391
1491
  const agent = () => oneOf(values.as ?? process.env.PAIR_AGENT, AGENTS, "--as (or PAIR_AGENT)");
1392
1492
  const refreshNote = REFRESHING_COMMANDS.includes(command) ? refreshInstructions(root) : undefined;
1493
+ const managedReview = async (me) => {
1494
+ if (process.env.PAIR_AUTO_ROLE !== "author" || !process.env.PAIR_AUTO_RUN)
1495
+ return false;
1496
+ const thread = threadFor(channel, me);
1497
+ const state = channelAccess(channel, () => stateOf(listMessages(thread)));
1498
+ if (state.kind !== "awaiting-review")
1499
+ return false;
1500
+ try {
1501
+ await autoCall(stateDir(channel), { action: "review", run: process.env.PAIR_AUTO_RUN, file: state.last.file }, 24 * 60 * 60 * 1000);
1502
+ }
1503
+ catch (error) {
1504
+ if (hasCode(error, "EPERM") || hasCode(error, "EACCES")) {
1505
+ throw new PairError(`managed review cannot connect to ${autoSocketPath(stateDir(channel))}. Claude needs sandbox.network.allowUnixSockets for this exact socket. pair start --as claude supplies it through --settings; check managed sandbox policy if access is still denied. The request remains on disk.`);
1506
+ }
1507
+ throw new PairError(`managed review failed: ${error instanceof Error ? error.message : String(error)}. The request remains on disk.`);
1508
+ }
1509
+ return true;
1510
+ };
1393
1511
  try {
1394
1512
  switch (command) {
1513
+ case "_auto-guardian":
1514
+ await runGuardian();
1515
+ return 0;
1516
+ case "start": {
1517
+ const me = agent();
1518
+ try {
1519
+ if (process.env.CODEX_THREAD_ID || process.env.CLAUDECODE)
1520
+ throw new PairError("run pair start in your own terminal, outside Claude or Codex");
1521
+ if (me === "claude" && readRegistration(channel)) {
1522
+ throw new PairError("a manual Codex reviewer is registered here; stop it and run pair unregister before starting auto mode");
1523
+ }
1524
+ const timeout = positiveNumber(values.timeout ?? "900", "--timeout");
1525
+ const grace = positiveNumber(values.grace ?? "2", "--grace");
1526
+ if (timeout > 86400 || grace > 30)
1527
+ throw new PairError("--timeout must be at most 86400 seconds and --grace at most 30 seconds");
1528
+ return await startAuto({
1529
+ author: me, root, channel, stateDir: stateDir(channel), entry: fileURLToPath(import.meta.url),
1530
+ claude: values.claude, codex: values.codex,
1531
+ reviewerModel: values["reviewer-model"], reviewerEffort: values["reviewer-effort"],
1532
+ timeoutMs: timeout * 1000,
1533
+ graceMs: grace * 1000,
1534
+ target: () => {
1535
+ const state = stateOf(listMessages(threadFor(channel, me)));
1536
+ return state.kind === "awaiting-review" ? { file: state.last.file, thread: basename(dirname(state.last.file)) } : undefined;
1537
+ },
1538
+ reviewed: (file) => {
1539
+ const messages = listMessages(threadFor(channel, me));
1540
+ const index = messages.findIndex((message) => message.file === file);
1541
+ return index >= 0 && messages[index + 1]?.type === "review";
1542
+ },
1543
+ });
1544
+ }
1545
+ catch (error) {
1546
+ throw new PairError(error instanceof Error ? error.message : String(error));
1547
+ }
1548
+ }
1549
+ case "stop":
1550
+ try {
1551
+ console.log(await stopAuto(stateDir(channel)));
1552
+ }
1553
+ catch (error) {
1554
+ throw new PairError(error instanceof Error ? error.message : String(error));
1555
+ }
1556
+ return 0;
1395
1557
  case "send": {
1396
1558
  autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1397
1559
  const type = oneOf(rest[0], MESSAGE_TYPES, "message type");
1398
1560
  const verdict = values.verdict === undefined ? undefined : oneOf(values.verdict, VERDICTS, "--verdict");
1399
1561
  const me = agent();
1562
+ if (process.env.PAIR_AUTO_ROLE && me !== process.env.PAIR_AGENT)
1563
+ throw new PairError("auto mode agent identity does not match --as");
1564
+ if (process.env.PAIR_AUTO_ROLE === "reviewer" && (me !== process.env.PAIR_AGENT || type !== "review")) {
1565
+ throw new PairError("a managed reviewer may only send its own review verdict");
1566
+ }
1400
1567
  const body = readBody(rest[1]);
1401
1568
  console.log(`sent: ${channelAccess(channel, () => send(channel, me, type, body, verdict))}`);
1569
+ if (type !== "review" && await managedReview(me))
1570
+ await wait(channel, me, 1);
1402
1571
  return 0;
1403
1572
  }
1404
1573
  case "wait": {
1405
1574
  autoKeep(); // reject a bad PAIR_KEEP before touching any thread
1406
1575
  const me = agent();
1576
+ if (process.env.PAIR_AUTO_ROLE && me !== process.env.PAIR_AGENT)
1577
+ throw new PairError("auto mode agent identity does not match --as");
1407
1578
  const timeout = positiveNumber(values.timeout ?? String(DEFAULT_WAIT_SECONDS), "--timeout");
1579
+ await managedReview(me); // explicit retry after a failed managed round
1408
1580
  if (!(await wait(channel, me, timeout))) {
1409
1581
  // Exit 0: a timeout is routine, and agent UIs show any non-zero exit as a failure. No "run it
1410
1582
  // again" here: whether to wait again differs by agent (Codex must not poll), so the prompt says.
@@ -1413,7 +1585,7 @@ async function main(argv) {
1413
1585
  return 0;
1414
1586
  }
1415
1587
  case "status":
1416
- console.log([status(channel, root), ...(await updateNotice())].join("\n"));
1588
+ console.log([status(channel, root), ...(await autoStatus(stateDir(channel))), ...(await updateNotice())].join("\n"));
1417
1589
  return 0;
1418
1590
  case "history":
1419
1591
  console.log(channelAccess(channel, () => history(channel, rest[0])));
@@ -1421,6 +1593,19 @@ async function main(argv) {
1421
1593
  case "clean":
1422
1594
  console.log(channelAccess(channel, () => clean(channel, values.keep)));
1423
1595
  return 0;
1596
+ case "reset": {
1597
+ if (rest.length)
1598
+ throw new PairError("reset accepts no positional arguments; use --as claude|codex to select an author lane");
1599
+ const author = values.as === undefined ? undefined : oneOf(values.as, AGENTS, "--as");
1600
+ const operation = () => channelAccess(channel, () => reset(channel, root, author, values["dry-run"] ?? false));
1601
+ try {
1602
+ console.log(values["dry-run"] ? operation() : await withAutoStopped(stateDir(channel), operation));
1603
+ }
1604
+ catch (error) {
1605
+ throw new PairError(error instanceof Error ? error.message : String(error));
1606
+ }
1607
+ return 0;
1608
+ }
1424
1609
  case "init":
1425
1610
  console.log([init(channel, root), ...(await updateNotice())].join("\n"));
1426
1611
  return 0;
@@ -1430,6 +1615,8 @@ async function main(argv) {
1430
1615
  console.log(uninit(channel, root));
1431
1616
  return 0;
1432
1617
  case "register":
1618
+ if (process.env.PAIR_AUTO_ROLE)
1619
+ throw new PairError("auto mode delivers reviews inline; session registration is unnecessary");
1433
1620
  console.log(channelAccess(channel, () => register(channel, root)));
1434
1621
  return 0;
1435
1622
  case "unregister":
@@ -1439,7 +1626,7 @@ async function main(argv) {
1439
1626
  await serveMcp();
1440
1627
  return 0;
1441
1628
  case "ps":
1442
- console.log(ps(values.verbose ?? false));
1629
+ console.log(await ps(values.verbose ?? false));
1443
1630
  return 0;
1444
1631
  default:
1445
1632
  console.error(USAGE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-codex-pair",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
package/prompts/pair.md CHANGED
@@ -5,6 +5,27 @@ These instructions are for `{{AGENT}}` only. If you are not `{{AGENT}}`, ignore
5
5
  You are `{{AGENT}}`. Your partner `{{PARTNER}}` runs in another terminal on the same repo.
6
6
  You talk only through the `pair` CLI. Always pass `--as {{AGENT}}`.
7
7
 
8
+ ## Auto mode
9
+
10
+ At session start and after a clear or compaction, check `PAIR_AUTO_ROLE` in your shell before
11
+ choosing the pair workflow below.
12
+
13
+ In auto mode the launcher sets `PAIR_BIN` to its own CLI. Use `"$PAIR_BIN"` in place of `pair`
14
+ in all command examples, so a login shell cannot select an older installation from PATH.
15
+
16
+ If `PAIR_AUTO_ROLE=author`, pair owns the reviewer. After finishing a task, send your request as
17
+ usual. `pair send request` and `pair send response` return the review inline: read it and continue
18
+ the fix/reject loop until approval or escalation. In interactive Claude Code, run these commands
19
+ and managed retry waits with Bash's `run_in_background: true`, end your turn, and read their output
20
+ when the completion notification arrives. Reviews can exceed the foreground Bash timeout. This
21
+ background command is active review work, not an idle wait loop. Do not register, call `pair_listen`,
22
+ or run idle waits. If a managed review fails, `pair wait --as {{AGENT}}` explicitly retries the round.
23
+
24
+ If `PAIR_AUTO_ROLE=reviewer`, handle exactly one pending review round. Read it with
25
+ `pair wait --as {{AGENT}} --timeout 1`, review, send your verdict, and exit. Do not modify source,
26
+ register, call `pair_listen`, request your own review, or wait again after sending the verdict.
27
+ These auto-mode rules replace the waiting instructions below.
28
+
8
29
  ## How to wait
9
30
 
10
31
  `pair wait` returns as soon as a message for you arrives. Wait in whichever way costs the fewest