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/DESIGN.md +76 -3
- package/README.md +140 -3
- package/dist/auto.js +612 -0
- package/dist/pair.js +214 -27
- package/package.json +1 -1
- package/prompts/pair.md +21 -0
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
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
|