pair-mode 0.3.3 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-mode",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Hold every proposed edit, annotate the diff line by line, and hand the notes back to the agent.",
5
5
  "author": {
6
6
  "name": "ofrusch",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-mode",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Hold every proposed edit, annotate the diff line by line, and hand the notes back to the agent.",
5
5
  "author": {
6
6
  "name": "ofrusch",
@@ -8,13 +8,7 @@
8
8
  },
9
9
  "homepage": "https://github.com/oFrusch/pair-mode#readme",
10
10
  "license": "MIT",
11
- "keywords": [
12
- "code-review",
13
- "diff",
14
- "hooks",
15
- "pair-programming",
16
- "terminal"
17
- ],
11
+ "keywords": ["code-review", "diff", "hooks", "pair-programming", "terminal"],
18
12
  "hooks": "./hooks/codex.json",
19
13
  "skills": "./skills/",
20
14
  "interface": {
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
- <img src="https://raw.githubusercontent.com/oFrusch/pair-mode/main/docs/images/duck.png" alt="The pair-mode duck" width="120" align="right">
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/oFrusch/pair-mode/main/assets/duck-appalled-banner-v2.gif" alt="A duck reacting to a diff full of deletions">
3
+ </p>
2
4
 
3
- # pair mode
5
+ <h1 align="center">pair mode</h1>
4
6
 
5
- #### For when your agent needs a rubber ducky.
7
+ <p align="center"><b>For when your agent needs a rubber ducky.</b></p>
6
8
 
7
9
  `pair-mode` is a tool that allows you to sit somewhere between being an approval monkey for your agent running in manual mode and just totally vibe coding in auto mode.
8
10
 
Binary file
Binary file
Binary file
package/assets/duck.png CHANGED
Binary file
Binary file
@@ -9169,13 +9169,14 @@ function detect(preference, adapters = {}) {
9169
9169
 
9170
9170
  // src/transports/pane/pane.ts
9171
9171
  var NAME_BYTES2 = 6;
9172
+ var OWNER_ONLY_FILE = 384;
9172
9173
  function splitCommand(value) {
9173
9174
  return value.trim().split(/\s+/).filter((part) => part !== "");
9174
9175
  }
9175
9176
  function tempFile(prefix, suffix, content) {
9176
9177
  const name = `${prefix}${randomBytes3(NAME_BYTES2).toString("hex")}${suffix}`;
9177
9178
  const path = join8(tmpdir2(), name);
9178
- writeFileSync5(path, content, "utf-8");
9179
+ writeFileSync5(path, content, { encoding: "utf-8", mode: OWNER_ONLY_FILE, flag: "wx" });
9179
9180
  return path;
9180
9181
  }
9181
9182
  function removeTreeQuietly(path) {
package/dist/cli.js CHANGED
@@ -8579,7 +8579,14 @@ function connect(socketPath) {
8579
8579
  });
8580
8580
  }
8581
8581
  function emptyHandlers() {
8582
- return { review: [], cancel: [], change: [], bufferedReviews: [], bufferedCancels: [] };
8582
+ return {
8583
+ review: [],
8584
+ cancel: [],
8585
+ change: [],
8586
+ close: [],
8587
+ bufferedReviews: [],
8588
+ bufferedCancels: []
8589
+ };
8583
8590
  }
8584
8591
  function addReviewHandler(handlers, handler) {
8585
8592
  handlers.review.push(handler);
@@ -8592,6 +8599,7 @@ function addCancelHandler(handlers, handler) {
8592
8599
  async function attach(socketPath, client, handlers, onState) {
8593
8600
  const socket = await connect(socketPath);
8594
8601
  const readLines = createLineReader();
8602
+ socket.on("error", () => socket.destroy());
8595
8603
  socket.on("data", (chunk) => {
8596
8604
  readLines(chunk).forEach((line2) => {
8597
8605
  const message = decodeLine(line2);
@@ -8650,6 +8658,9 @@ async function ownerHost(options) {
8650
8658
  onChange(handler) {
8651
8659
  handlers.change.push(handler);
8652
8660
  },
8661
+ // The owner's socket only closes when the server it owns closes, so a handler here would never fire.
8662
+ onClose() {
8663
+ },
8653
8664
  async close() {
8654
8665
  socket.destroy();
8655
8666
  await server.close();
@@ -8663,6 +8674,11 @@ async function viewerHost(options) {
8663
8674
  remote = state;
8664
8675
  handlers.change.forEach((handler) => handler());
8665
8676
  });
8677
+ socket.on("close", () => {
8678
+ remote = null;
8679
+ handlers.change.forEach((handler) => handler());
8680
+ handlers.close.forEach((handler) => handler());
8681
+ });
8666
8682
  return {
8667
8683
  socketPath: options.socketPath,
8668
8684
  owns: false,
@@ -8670,10 +8686,14 @@ async function viewerHost(options) {
8670
8686
  return { clients: remote?.clientCount ?? 0, waiting: remote?.waitingDepth ?? 0 };
8671
8687
  },
8672
8688
  refreshCounts() {
8673
- socket.write(encode({ type: "status" }));
8689
+ if (!socket.destroyed) {
8690
+ socket.write(encode({ type: "status" }));
8691
+ }
8674
8692
  },
8675
8693
  verdict(id, questions) {
8676
- socket.write(encode({ type: "verdict", id, questions }));
8694
+ if (!socket.destroyed) {
8695
+ socket.write(encode({ type: "verdict", id, questions }));
8696
+ }
8677
8697
  },
8678
8698
  onReview(handler) {
8679
8699
  addReviewHandler(handlers, handler);
@@ -8684,6 +8704,9 @@ async function viewerHost(options) {
8684
8704
  onChange(handler) {
8685
8705
  handlers.change.push(handler);
8686
8706
  },
8707
+ onClose(handler) {
8708
+ handlers.close.push(handler);
8709
+ },
8687
8710
  close() {
8688
8711
  socket.destroy();
8689
8712
  return Promise.resolve();
@@ -9825,6 +9848,13 @@ function detect(preference, adapters = {}) {
9825
9848
  import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6, copyFileSync } from "node:fs";
9826
9849
  import { basename as basename5, dirname as dirname7, join as join12, sep as sep2 } from "node:path";
9827
9850
  var HOOK_TIMEOUT_SECONDS = 1800;
9851
+ function hookCommand(installRoot2, file) {
9852
+ const path = join12(installRoot2, "dist", file);
9853
+ if (/^[A-Za-z0-9_./-]+$/.test(path)) {
9854
+ return path;
9855
+ }
9856
+ return `'${path.replace(/'/g, "'\\''")}'`;
9857
+ }
9828
9858
  function isHookEntry(value) {
9829
9859
  if (!isRecord(value)) {
9830
9860
  return false;
@@ -9915,7 +9945,7 @@ function hasCommand(groups, command) {
9915
9945
  }
9916
9946
  function matchesOurCommand(command) {
9917
9947
  const suffix = sep2 + join12("dist", basename5(command));
9918
- return (entry) => entry.command === command || entry.command.endsWith(suffix);
9948
+ return (entry) => entry.command === command || entry.command.endsWith(suffix) || entry.command.endsWith(suffix + "'");
9919
9949
  }
9920
9950
  function upsertHookGroup(groups, matcher, command, timeout) {
9921
9951
  const matches = matchesOurCommand(command);
@@ -9984,7 +10014,7 @@ function claudeCodeSettingsPath(homeDir) {
9984
10014
  }
9985
10015
  function registerClaudeCode(homeDir, installRoot2) {
9986
10016
  const path = claudeCodeSettingsPath(homeDir);
9987
- const command = join12(installRoot2, "dist", "claude-code.js");
10017
+ const command = hookCommand(installRoot2, "claude-code.js");
9988
10018
  return registerPreToolUseHook(path, "Write|Edit|MultiEdit", command, HOOK_TIMEOUT_SECONDS);
9989
10019
  }
9990
10020
  function codexHooksPath(homeDir) {
@@ -9992,7 +10022,7 @@ function codexHooksPath(homeDir) {
9992
10022
  }
9993
10023
  function registerCodex(homeDir, installRoot2) {
9994
10024
  const path = codexHooksPath(homeDir);
9995
- const command = join12(installRoot2, "dist", "codex.js");
10025
+ const command = hookCommand(installRoot2, "codex.js");
9996
10026
  return registerPreToolUseHook(path, "apply_patch|Edit|Write", command, HOOK_TIMEOUT_SECONDS);
9997
10027
  }
9998
10028
  function findMultiEditMatchers(homeDir) {
@@ -10231,20 +10261,22 @@ function checkCommandOnPath(resolves) {
10231
10261
  };
10232
10262
  }
10233
10263
  function checkClis(home, root) {
10234
- const claudeCommand = join14(root, "dist", "claude-code.js");
10235
- const codexCommand = join14(root, "dist", "codex.js");
10264
+ const claudeCommand = hookCommand(root, "claude-code.js");
10265
+ const codexCommand = hookCommand(root, "codex.js");
10266
+ const claudeTarget = join14(root, "dist", "claude-code.js");
10267
+ const codexTarget = join14(root, "dist", "codex.js");
10236
10268
  const opencodeTarget = join14(root, "dist", "opencode.js");
10237
10269
  const piTarget = join14(root, "dist", "pi.js");
10238
10270
  const specs = [
10239
10271
  {
10240
10272
  cli: "claude-code",
10241
10273
  registered: isPreToolUseRegistered(claudeCodeSettingsPath(home), claudeCommand),
10242
- targetExists: existsSync10(claudeCommand)
10274
+ targetExists: existsSync10(claudeTarget)
10243
10275
  },
10244
10276
  {
10245
10277
  cli: "codex",
10246
10278
  registered: isPreToolUseRegistered(codexHooksPath(home), codexCommand),
10247
- targetExists: existsSync10(codexCommand)
10279
+ targetExists: existsSync10(codexTarget)
10248
10280
  },
10249
10281
  {
10250
10282
  cli: "opencode",
@@ -12585,6 +12617,7 @@ function splitInput(chunk) {
12585
12617
 
12586
12618
  // src/tui/notes/notes.ts
12587
12619
  import { writeFileSync as writeFileSync7 } from "node:fs";
12620
+ var OWNER_ONLY_FILE2 = 384;
12588
12621
  function rangeOf(selection) {
12589
12622
  const reversed = selection.anchorRow > selection.headRow || selection.anchorRow === selection.headRow && selection.anchorColumn > selection.headColumn;
12590
12623
  if (!reversed) {
@@ -12655,7 +12688,10 @@ function toQuestions(notes) {
12655
12688
  }
12656
12689
  function writeResult(path, notes) {
12657
12690
  try {
12658
- writeFileSync7(path, JSON.stringify({ questions: toQuestions(notes) }, null, 2), "utf-8");
12691
+ writeFileSync7(path, JSON.stringify({ questions: toQuestions(notes) }, null, 2), {
12692
+ encoding: "utf-8",
12693
+ mode: OWNER_ONLY_FILE2
12694
+ });
12659
12695
  } catch {
12660
12696
  return;
12661
12697
  }
@@ -13231,15 +13267,29 @@ async function runWatch(options, config) {
13231
13267
  nudge();
13232
13268
  });
13233
13269
  host.onCancel((id) => {
13234
- cancelled.add(id);
13235
- aborts.get(id)?.abort();
13236
- nudge();
13270
+ const queuedIndex = pending.findIndex((review) => review.id === id);
13271
+ if (queuedIndex !== -1) {
13272
+ pending.splice(queuedIndex, 1);
13273
+ nudge();
13274
+ return;
13275
+ }
13276
+ if (aborts.has(id)) {
13277
+ cancelled.add(id);
13278
+ aborts.get(id)?.abort();
13279
+ nudge();
13280
+ }
13237
13281
  });
13238
13282
  host.onChange(() => {
13239
13283
  if (!busy && !quitting) {
13240
13284
  paintIdle(io, status(), truecolor);
13241
13285
  }
13242
13286
  });
13287
+ host.onClose(() => {
13288
+ quitting = true;
13289
+ aborts.forEach((abort) => abort.abort());
13290
+ errors = [...errors, new Error("the session owner exited")];
13291
+ nudge();
13292
+ });
13243
13293
  while (!quitting) {
13244
13294
  const review = pending.shift();
13245
13295
  if (review === void 0) {
@@ -14437,12 +14487,17 @@ function post(payload) {
14437
14487
  .then((response) => {
14438
14488
  // A refused verdict means another client already answered, so the notes typed here can never land.
14439
14489
  if (!response.ok) {
14440
- clearReview();
14490
+ if (review !== null && review.id === payload.id) {
14491
+ clearReview();
14492
+ }
14441
14493
  warn("this review was already answered elsewhere - your notes were not sent");
14442
14494
  return;
14443
14495
  }
14444
14496
 
14445
- clearReview();
14497
+ // Only clear if the server answered this review, not one that arrived via SSE meanwhile.
14498
+ if (review !== null && review.id === payload.id) {
14499
+ clearReview();
14500
+ }
14446
14501
  })
14447
14502
  .catch(() => {
14448
14503
  sendButton.disabled = notes.length === 0;
@@ -14457,10 +14512,16 @@ approveButton.addEventListener("click", () => post({ id: review.id, notes: [] })
14457
14512
  const events = new EventSource("/r/" + token + "/events");
14458
14513
 
14459
14514
  events.addEventListener("review", (event) => {
14460
- review = JSON.parse(event.data);
14461
- notes = [];
14462
- expanded = new Set();
14463
- hidePopup();
14515
+ const incoming = JSON.parse(event.data);
14516
+
14517
+ // Only reset if it is a different review.
14518
+ if (review === null || review.id !== incoming.id) {
14519
+ notes = [];
14520
+ expanded = new Set();
14521
+ hidePopup();
14522
+ }
14523
+
14524
+ review = incoming;
14464
14525
  render();
14465
14526
  });
14466
14527
 
@@ -14811,12 +14872,12 @@ async function toWebReview(review, config) {
14811
14872
 
14812
14873
  // src/web/watch.ts
14813
14874
  var OWNER_ONLY_DIR3 = 448;
14814
- var OWNER_ONLY_FILE2 = 384;
14875
+ var OWNER_ONLY_FILE3 = 384;
14815
14876
  function publishUrl(path, url) {
14816
14877
  mkdirSync7(dirname8(path), { recursive: true, mode: OWNER_ONLY_DIR3 });
14817
14878
  writeFileSync8(path, JSON.stringify({ url, pid: process.pid }) + "\n", {
14818
14879
  encoding: "utf-8",
14819
- mode: OWNER_ONLY_FILE2
14880
+ mode: OWNER_ONLY_FILE3
14820
14881
  });
14821
14882
  }
14822
14883
  function linkPid(path) {