pair-mode 0.3.0 → 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.
Files changed (53) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +2 -8
  3. package/README.md +71 -12
  4. package/assets/duck-appalled-banner-sprite.png +0 -0
  5. package/assets/duck-appalled-banner-v2-sprite.png +0 -0
  6. package/assets/duck-appalled-banner-v2.gif +0 -0
  7. package/assets/duck-appalled-sprite.png +0 -0
  8. package/assets/duck-appalled.gif +0 -0
  9. package/assets/duck-before-main.png +0 -0
  10. package/assets/duck.png +0 -0
  11. package/assets/favicon.png +0 -0
  12. package/assets/syntax/clojure.yaml +36 -0
  13. package/assets/syntax/cmake.yaml +41 -0
  14. package/assets/syntax/crystal.yaml +71 -0
  15. package/assets/syntax/csharp.yaml +51 -0
  16. package/assets/syntax/dart.yaml +45 -0
  17. package/assets/syntax/elm.yaml +38 -0
  18. package/assets/syntax/erb.yaml +42 -0
  19. package/assets/syntax/erlang.yaml +45 -0
  20. package/assets/syntax/fsharp.yaml +48 -0
  21. package/assets/syntax/graphql.yaml +47 -0
  22. package/assets/syntax/groovy.yaml +111 -0
  23. package/assets/syntax/haml.yaml +16 -0
  24. package/assets/syntax/haskell.yaml +52 -0
  25. package/assets/syntax/ini.yaml +23 -0
  26. package/assets/syntax/java.yaml +36 -0
  27. package/assets/syntax/julia.yaml +56 -0
  28. package/assets/syntax/kotlin.yaml +65 -0
  29. package/assets/syntax/makefile.yaml +37 -0
  30. package/assets/syntax/nginx.yaml +22 -0
  31. package/assets/syntax/nim.yaml +27 -0
  32. package/assets/syntax/nix.yaml +32 -0
  33. package/assets/syntax/objc.yaml +60 -0
  34. package/assets/syntax/ocaml.yaml +43 -0
  35. package/assets/syntax/perl.yaml +58 -0
  36. package/assets/syntax/php.yaml +60 -0
  37. package/assets/syntax/r.yaml +30 -0
  38. package/assets/syntax/scala.yaml +32 -0
  39. package/assets/syntax/svelte.yaml +27 -0
  40. package/assets/syntax/swift.yaml +102 -0
  41. package/assets/syntax/vue.yaml +63 -0
  42. package/assets/syntax/xml.yaml +37 -0
  43. package/assets/syntax/zig.yaml +52 -0
  44. package/dist/claude-code.js +463 -150
  45. package/dist/cli.js +1534 -459
  46. package/dist/codex.js +811 -394
  47. package/dist/opencode.js +455 -143
  48. package/dist/pair-tui.js +177 -27
  49. package/dist/pi.js +510 -156
  50. package/package.json +16 -17
  51. package/skills/toggle/SKILL.md +24 -0
  52. package/commands/pair.md +0 -18
  53. package/skills/pair/SKILL.md +0 -20
package/dist/cli.js CHANGED
@@ -7361,8 +7361,8 @@ var require_dist = __commonJS({
7361
7361
  });
7362
7362
 
7363
7363
  // src/cli/index.ts
7364
- import { readFileSync as readFileSync8 } from "node:fs";
7365
- import { join as join13, resolve as resolve2 } from "node:path";
7364
+ import { readFileSync as readFileSync10 } from "node:fs";
7365
+ import { join as join15, resolve as resolve2 } from "node:path";
7366
7366
 
7367
7367
  // src/cli/setup/setup.ts
7368
7368
  import { createInterface } from "node:readline/promises";
@@ -7886,18 +7886,65 @@ function describeEphemeralRoot(root) {
7886
7886
  }
7887
7887
 
7888
7888
  // src/cli/doctor/doctor.ts
7889
- import { existsSync as existsSync8, openSync as openSync2, closeSync as closeSync2, readFileSync as readFileSync5, statSync } from "node:fs";
7889
+ import { existsSync as existsSync10, openSync as openSync3, closeSync as closeSync3, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
7890
7890
  import { homedir as homedir4 } from "node:os";
7891
- import { join as join12 } from "node:path";
7891
+ import { basename as basename6, join as join14 } from "node:path";
7892
7892
 
7893
7893
  // src/core/state/state.ts
7894
7894
  import { createHash } from "node:crypto";
7895
7895
  import { homedir as homedir3 } from "node:os";
7896
- import { join as join5, dirname as dirname4, basename } from "node:path";
7897
- import { existsSync as existsSync4, realpathSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, unlinkSync } from "node:fs";
7896
+ import { join as join6, dirname as dirname4, basename } from "node:path";
7897
+ import { existsSync as existsSync4, realpathSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "node:fs";
7898
+
7899
+ // src/helpers/guards.ts
7900
+ function isString(value) {
7901
+ return typeof value === "string";
7902
+ }
7903
+ function isNullableString(value) {
7904
+ return value === null || isString(value);
7905
+ }
7906
+
7907
+ // src/helpers/removeQuietly.ts
7908
+ import { unlinkSync } from "node:fs";
7909
+ function removeQuietly(path) {
7910
+ try {
7911
+ unlinkSync(path);
7912
+ } catch {
7913
+ }
7914
+ }
7915
+
7916
+ // src/helpers/resultFilePath.ts
7917
+ import { randomBytes } from "node:crypto";
7918
+ import { tmpdir } from "node:os";
7919
+ import { join as join5 } from "node:path";
7920
+ var NAME_BYTES = 6;
7921
+ function resultFilePath() {
7922
+ const name = `pair-result-${randomBytes(NAME_BYTES).toString("hex")}.json`;
7923
+ return join5(tmpdir(), name);
7924
+ }
7925
+
7926
+ // src/helpers/spawn.ts
7927
+ import { spawnSync as spawnSync2 } from "node:child_process";
7928
+ var defaultSpawn = (command, args) => {
7929
+ const result = spawnSync2(command, args, { stdio: ["ignore", "ignore", "pipe"] });
7930
+ const stderr = result.stderr ? result.stderr.toString("utf-8") : result.error?.message ?? "";
7931
+ return { status: result.status, stderr };
7932
+ };
7933
+
7934
+ // src/helpers/splitLines.ts
7935
+ function splitLines(text) {
7936
+ const lines = text.split("\n");
7937
+ const last = lines.at(-1);
7938
+ if (last === "") {
7939
+ lines.pop();
7940
+ }
7941
+ return lines;
7942
+ }
7943
+
7944
+ // src/core/state/state.ts
7898
7945
  function stateDir() {
7899
- const base = process.env["XDG_STATE_HOME"] || join5(homedir3(), ".local", "state");
7900
- return join5(base, "pair-mode");
7946
+ const base = process.env["XDG_STATE_HOME"] || join6(homedir3(), ".local", "state");
7947
+ return join6(base, "pair-mode");
7901
7948
  }
7902
7949
  function realpathLenient(path) {
7903
7950
  try {
@@ -7907,7 +7954,7 @@ function realpathLenient(path) {
7907
7954
  if (parent === path) {
7908
7955
  return path;
7909
7956
  }
7910
- return join5(realpathLenient(parent), basename(path));
7957
+ return join6(realpathLenient(parent), basename(path));
7911
7958
  }
7912
7959
  }
7913
7960
  var DIGEST_LENGTH = 16;
@@ -7916,16 +7963,88 @@ function digestFor(directory) {
7916
7963
  return createHash("sha1").update(real).digest("hex").slice(0, DIGEST_LENGTH);
7917
7964
  }
7918
7965
  function flagPath(directory) {
7919
- return join5(stateDir(), `${digestFor(directory)}.on`);
7966
+ return join6(stateDir(), `${digestFor(directory)}.on`);
7920
7967
  }
7921
7968
  function sessionsDir() {
7922
- return join5(stateDir(), "sessions");
7969
+ return join6(stateDir(), "sessions");
7970
+ }
7971
+ var OWNER_ONLY_DIR = 448;
7972
+ var OWNER_ONLY_FILE = 384;
7973
+ function makeSessionsDir() {
7974
+ const path = sessionsDir();
7975
+ mkdirSync2(path, { recursive: true, mode: OWNER_ONLY_DIR });
7976
+ return path;
7923
7977
  }
7924
7978
  function sessionSocketPath(directory) {
7925
- return join5(sessionsDir(), `${digestFor(directory)}.sock`);
7979
+ return join6(sessionsDir(), `${digestFor(directory)}.sock`);
7926
7980
  }
7927
7981
  function sessionUrlPath(directory) {
7928
- return join5(sessionsDir(), `${digestFor(directory)}.url`);
7982
+ return join6(sessionsDir(), `${digestFor(directory)}.url`);
7983
+ }
7984
+ function findSessionSocket(filePath) {
7985
+ let current = dirname4(realpathLenient(filePath));
7986
+ while (true) {
7987
+ const candidate = sessionSocketPath(current);
7988
+ if (existsSync4(candidate)) {
7989
+ return candidate;
7990
+ }
7991
+ const parent = dirname4(current);
7992
+ if (parent === current) {
7993
+ return null;
7994
+ }
7995
+ current = parent;
7996
+ }
7997
+ }
7998
+ function resolveSocketPath(filePath, key) {
7999
+ if (key !== void 0) {
8000
+ const candidate = sessionKeySocketPath(key);
8001
+ if (existsSync4(candidate)) {
8002
+ return candidate;
8003
+ }
8004
+ }
8005
+ return findSessionSocket(filePath);
8006
+ }
8007
+ function sessionFlagState(key) {
8008
+ if (existsSync4(sessionKeyOptOutPath(key))) {
8009
+ return "off";
8010
+ }
8011
+ return existsSync4(sessionKeyFlagPath(key)) ? "on" : "unset";
8012
+ }
8013
+ function directoryEnabled(filePath) {
8014
+ let current = dirname4(realpathLenient(filePath));
8015
+ while (true) {
8016
+ if (existsSync4(flagPath(current))) {
8017
+ return true;
8018
+ }
8019
+ const parent = dirname4(current);
8020
+ if (parent === current) {
8021
+ return false;
8022
+ }
8023
+ current = parent;
8024
+ }
8025
+ }
8026
+ function isEnabled(filePath, key) {
8027
+ if (key !== void 0) {
8028
+ const state = sessionFlagState(key);
8029
+ if (state !== "unset") {
8030
+ return state === "on";
8031
+ }
8032
+ }
8033
+ return directoryEnabled(filePath);
8034
+ }
8035
+ function enableSession(key) {
8036
+ const path = sessionKeyFlagPath(key);
8037
+ makeSessionsDir();
8038
+ removeQuietly(sessionKeyOptOutPath(key));
8039
+ writeFileSync2(path, "", { mode: OWNER_ONLY_FILE });
8040
+ return path;
8041
+ }
8042
+ function optOutSession(key) {
8043
+ const path = sessionKeyOptOutPath(key);
8044
+ makeSessionsDir();
8045
+ removeQuietly(sessionKeyFlagPath(key));
8046
+ writeFileSync2(path, "", { mode: OWNER_ONLY_FILE });
8047
+ return path;
7929
8048
  }
7930
8049
  function enable(directory) {
7931
8050
  const path = flagPath(directory);
@@ -7938,46 +8057,80 @@ function disable(directory) {
7938
8057
  if (!existsSync4(path)) {
7939
8058
  return false;
7940
8059
  }
7941
- unlinkSync(path);
8060
+ unlinkSync2(path);
7942
8061
  return true;
7943
8062
  }
7944
-
7945
- // src/helpers/removeQuietly.ts
7946
- import { unlinkSync as unlinkSync2 } from "node:fs";
7947
- function removeQuietly(path) {
7948
- try {
7949
- unlinkSync2(path);
7950
- } catch {
8063
+ var SESSION_KEY_LENGTH = 8;
8064
+ function sessionKey(agentSessionId2) {
8065
+ const digest = createHash("sha1").update(agentSessionId2).digest("hex");
8066
+ return `s-${digest.slice(0, SESSION_KEY_LENGTH)}`;
8067
+ }
8068
+ function watchSocketPath(directory, key, override) {
8069
+ if (override !== void 0) {
8070
+ return override;
7951
8071
  }
8072
+ return key ? sessionKeySocketPath(key) : sessionSocketPath(directory);
7952
8073
  }
7953
-
7954
- // src/helpers/resultFilePath.ts
7955
- import { randomBytes } from "node:crypto";
7956
- import { tmpdir } from "node:os";
7957
- import { join as join6 } from "node:path";
7958
- var NAME_BYTES = 6;
7959
- function resultFilePath() {
7960
- const name = `pair-result-${randomBytes(NAME_BYTES).toString("hex")}.json`;
7961
- return join6(tmpdir(), name);
8074
+ function watchUrlPath(directory, key) {
8075
+ return key ? sessionKeyUrlPath(key) : sessionUrlPath(directory);
8076
+ }
8077
+ function keyFor(agentSessionId2) {
8078
+ return agentSessionId2 === void 0 || agentSessionId2 === "" ? void 0 : sessionKey(agentSessionId2);
8079
+ }
8080
+ function sessionKeyPath(key, extension) {
8081
+ return join6(sessionsDir(), `${key}${extension}`);
8082
+ }
8083
+ function sessionKeySocketPath(key) {
8084
+ return sessionKeyPath(key, ".sock");
8085
+ }
8086
+ function sessionKeyFlagPath(key) {
8087
+ return sessionKeyPath(key, ".on");
8088
+ }
8089
+ function sessionKeyOptOutPath(key) {
8090
+ return sessionKeyPath(key, ".off");
8091
+ }
8092
+ function sessionKeyUrlPath(key) {
8093
+ return sessionKeyPath(key, ".url");
7962
8094
  }
7963
8095
 
7964
- // src/helpers/spawn.ts
7965
- import { spawnSync as spawnSync2 } from "node:child_process";
7966
- var defaultSpawn = (command, args) => {
7967
- const result = spawnSync2(command, args, { stdio: ["ignore", "ignore", "pipe"] });
7968
- const stderr = result.stderr ? result.stderr.toString("utf-8") : result.error?.message ?? "";
7969
- return { status: result.status, stderr };
7970
- };
7971
-
7972
- // src/helpers/splitLines.ts
7973
- function splitLines(text) {
7974
- const lines = text.split("\n");
7975
- const last = lines.at(-1);
7976
- if (last === "") {
7977
- lines.pop();
8096
+ // src/core/state/record.ts
8097
+ import { spawnSync as spawnSync3 } from "node:child_process";
8098
+ import { basename as basename2 } from "node:path";
8099
+ var GIT_TIMEOUT_MS = 2e3;
8100
+ function currentBranch(directory) {
8101
+ const result = spawnSync3("git", ["-C", directory, "rev-parse", "--abbrev-ref", "HEAD"], {
8102
+ encoding: "utf-8",
8103
+ timeout: GIT_TIMEOUT_MS
8104
+ });
8105
+ if (result.status !== 0) {
8106
+ return null;
7978
8107
  }
7979
- return lines;
8108
+ const branch = result.stdout.trim();
8109
+ return branch === "" ? null : branch;
8110
+ }
8111
+ function sessionLabel(directory, branch) {
8112
+ const name = basename2(directory);
8113
+ return branch === null ? name : `${name}@${branch}`;
7980
8114
  }
8115
+ function buildSessionRecord(options, socketPath) {
8116
+ const branch = currentBranch(options.directory);
8117
+ const kind = options.sessionKey === void 0 ? "directory" : "session";
8118
+ return {
8119
+ id: options.sessionKey ?? basename2(socketPath, ".sock"),
8120
+ kind,
8121
+ label: sessionLabel(options.directory, branch),
8122
+ directory: options.directory,
8123
+ branch,
8124
+ agentSessionId: options.agentSessionId ?? null,
8125
+ agentKind: options.agentKind ?? null,
8126
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
8127
+ pid: process.pid
8128
+ };
8129
+ }
8130
+
8131
+ // src/cli/sessions/sessions.ts
8132
+ import { existsSync as existsSync5, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
8133
+ import { basename as basename3, join as join7 } from "node:path";
7981
8134
 
7982
8135
  // src/transports/session/wire.ts
7983
8136
  var CLIENT_KINDS = ["tui", "web"];
@@ -7992,11 +8145,11 @@ function createLineReader() {
7992
8145
  return parts.filter((line2) => line2.trim() !== "");
7993
8146
  };
7994
8147
  }
7995
- function isString(value) {
8148
+ function isString2(value) {
7996
8149
  return typeof value === "string";
7997
8150
  }
7998
8151
  function isClientKind(value) {
7999
- return isString(value) && CLIENT_KINDS.includes(value);
8152
+ return isString2(value) && CLIENT_KINDS.includes(value);
8000
8153
  }
8001
8154
  function isQuestion(value) {
8002
8155
  if (!isRecord(value)) {
@@ -8004,16 +8157,16 @@ function isQuestion(value) {
8004
8157
  }
8005
8158
  const line2 = value["line"];
8006
8159
  const lineOk = line2 === null || typeof line2 === "number";
8007
- return lineOk && isString(value["code"]) && isString(value["text"]);
8160
+ return lineOk && isString2(value["code"]) && isString2(value["text"]);
8008
8161
  }
8009
8162
  function isQuestionList(value) {
8010
8163
  return Array.isArray(value) && value.every(isQuestion);
8011
8164
  }
8012
8165
  function toSubmit(raw) {
8013
- if (!isString(raw["tool"]) || !isString(raw["path"])) {
8166
+ if (!isString2(raw["tool"]) || !isString2(raw["path"])) {
8014
8167
  return null;
8015
8168
  }
8016
- if (!isString(raw["before"]) || !isString(raw["after"])) {
8169
+ if (!isString2(raw["before"]) || !isString2(raw["after"])) {
8017
8170
  return null;
8018
8171
  }
8019
8172
  return {
@@ -8029,7 +8182,7 @@ function toAttach(raw) {
8029
8182
  }
8030
8183
  function toReview(raw) {
8031
8184
  const submit = toSubmit({ ...raw, type: "submit" });
8032
- if (submit === null || !isString(raw["id"])) {
8185
+ if (submit === null || !isString2(raw["id"])) {
8033
8186
  return null;
8034
8187
  }
8035
8188
  return {
@@ -8042,13 +8195,31 @@ function toReview(raw) {
8042
8195
  };
8043
8196
  }
8044
8197
  function toVerdict(raw) {
8045
- if (!isString(raw["id"]) || !isQuestionList(raw["questions"])) {
8198
+ if (!isString2(raw["id"]) || !isQuestionList(raw["questions"])) {
8046
8199
  return null;
8047
8200
  }
8048
8201
  return { type: "verdict", id: raw["id"], questions: raw["questions"] };
8049
8202
  }
8050
8203
  function toCancel(raw) {
8051
- return isString(raw["id"]) ? { type: "cancel", id: raw["id"] } : null;
8204
+ return isString2(raw["id"]) ? { type: "cancel", id: raw["id"] } : null;
8205
+ }
8206
+ function isNumber(value) {
8207
+ return typeof value === "number" && Number.isFinite(value);
8208
+ }
8209
+ function toState(raw) {
8210
+ const lastAttachAt = raw["lastAttachAt"];
8211
+ if (lastAttachAt !== null && !isString2(lastAttachAt)) {
8212
+ return null;
8213
+ }
8214
+ if (!isNumber(raw["clientCount"]) || !isNumber(raw["waitingDepth"])) {
8215
+ return null;
8216
+ }
8217
+ return {
8218
+ type: "state",
8219
+ clientCount: raw["clientCount"],
8220
+ waitingDepth: raw["waitingDepth"],
8221
+ lastAttachAt
8222
+ };
8052
8223
  }
8053
8224
  function decodeLine(line2) {
8054
8225
  let parsed;
@@ -8076,13 +8247,19 @@ function decodeLine(line2) {
8076
8247
  if (type === "cancel") {
8077
8248
  return toCancel(parsed);
8078
8249
  }
8250
+ if (type === "status") {
8251
+ return { type: "status" };
8252
+ }
8253
+ if (type === "state") {
8254
+ return toState(parsed);
8255
+ }
8079
8256
  return null;
8080
8257
  }
8081
8258
 
8082
8259
  // src/transports/session/server.ts
8083
8260
  import { createServer, createConnection } from "node:net";
8084
8261
  import { randomBytes as randomBytes2 } from "node:crypto";
8085
- import { chmodSync, mkdirSync as mkdirSync3 } from "node:fs";
8262
+ import { chmodSync, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
8086
8263
  import { dirname as dirname5 } from "node:path";
8087
8264
 
8088
8265
  // src/transports/session/queue.ts
@@ -8096,17 +8273,21 @@ function enqueue(state, id, request) {
8096
8273
  function findReview(state, id) {
8097
8274
  return state.reviews.find((review) => review.id === id) ?? null;
8098
8275
  }
8276
+ function offeredReviews(state) {
8277
+ return state.reviews.filter((review) => review.status === "offered");
8278
+ }
8099
8279
  function waitingDepth(state) {
8100
8280
  return state.reviews.filter((review) => review.status === "waiting").length;
8101
8281
  }
8102
- function takeNext(state) {
8103
- const next = state.reviews.find((review) => review.status === "waiting");
8104
- if (next === void 0) {
8105
- return { state, review: null };
8282
+ function offerAll(state) {
8283
+ const waiting = state.reviews.filter((review) => review.status === "waiting");
8284
+ if (waiting.length === 0) {
8285
+ return { state, reviews: [] };
8106
8286
  }
8107
- const taken = { ...next, status: "inFlight" };
8108
- const reviews = state.reviews.map((review) => review.id === next.id ? taken : review);
8109
- return { state: { reviews }, review: taken };
8287
+ const offered = waiting.map((review) => ({ ...review, status: "offered" }));
8288
+ const byId = new Map(offered.map((review) => [review.id, review]));
8289
+ const reviews = state.reviews.map((review) => byId.get(review.id) ?? review);
8290
+ return { state: { reviews }, reviews: offered };
8110
8291
  }
8111
8292
  function complete(state, id) {
8112
8293
  return { reviews: state.reviews.filter((review) => review.id !== id) };
@@ -8120,8 +8301,9 @@ function release(state, id) {
8120
8301
 
8121
8302
  // src/transports/session/server.ts
8122
8303
  var ID_BYTES = 8;
8123
- var OWNER_ONLY_DIR = 448;
8304
+ var OWNER_ONLY_DIR2 = 448;
8124
8305
  var OWNER_ONLY_SOCKET = 384;
8306
+ var OWNER_ONLY_RECORD = 384;
8125
8307
  var STALE_PROBE_TIMEOUT_MS = 250;
8126
8308
  function defaultGenerateId() {
8127
8309
  return randomBytes2(ID_BYTES).toString("hex");
@@ -8161,7 +8343,7 @@ function restrictToOwner(path) {
8161
8343
  }
8162
8344
  }
8163
8345
  async function bindSocket(server, path) {
8164
- mkdirSync3(dirname5(path), { recursive: true, mode: OWNER_ONLY_DIR });
8346
+ mkdirSync3(dirname5(path), { recursive: true, mode: OWNER_ONLY_DIR2 });
8165
8347
  try {
8166
8348
  await listenOn(server, path);
8167
8349
  restrictToOwner(path);
@@ -8178,14 +8360,30 @@ async function bindSocket(server, path) {
8178
8360
  await listenOn(server, path);
8179
8361
  restrictToOwner(path);
8180
8362
  }
8363
+ function recordPathFor(socketPath) {
8364
+ return socketPath.replace(/\.sock$/, ".json");
8365
+ }
8366
+ function writeRecord(socketPath, record) {
8367
+ try {
8368
+ writeFileSync3(recordPathFor(socketPath), JSON.stringify(record, null, 2) + "\n", {
8369
+ encoding: "utf-8",
8370
+ mode: OWNER_ONLY_RECORD
8371
+ });
8372
+ } catch {
8373
+ }
8374
+ }
8181
8375
  async function startSessionServer(options) {
8182
8376
  const generateId = options.generateId ?? defaultGenerateId;
8183
8377
  const reportError = options.onError ?? defaultReportError;
8184
8378
  let queue = emptyQueue();
8185
- const agents = /* @__PURE__ */ new Map();
8379
+ const agentByReview = /* @__PURE__ */ new Map();
8186
8380
  const clients = /* @__PURE__ */ new Map();
8381
+ const clientBySocket = /* @__PURE__ */ new Map();
8382
+ const clientsByReview = /* @__PURE__ */ new Map();
8187
8383
  const connections = /* @__PURE__ */ new Set();
8188
8384
  const changeHandlers = [];
8385
+ let lastAttachAt = null;
8386
+ let nextClientId = 0;
8189
8387
  function announce() {
8190
8388
  changeHandlers.forEach((handler) => handler());
8191
8389
  }
@@ -8194,81 +8392,102 @@ async function startSessionServer(options) {
8194
8392
  socket.write(encode(message));
8195
8393
  }
8196
8394
  }
8197
- function idleClient() {
8198
- const entry = [...clients.entries()].find(([, held]) => held === null);
8199
- return entry === void 0 ? null : entry[0];
8200
- }
8201
- function clientHolding(id) {
8202
- const entry = [...clients.entries()].find(([, held]) => held === id);
8203
- return entry === void 0 ? null : entry[0];
8395
+ function reviewMessage(review) {
8396
+ return {
8397
+ type: "review",
8398
+ id: review.id,
8399
+ tool: review.request.tool,
8400
+ path: review.request.filePath,
8401
+ before: review.request.before,
8402
+ after: review.request.after
8403
+ };
8204
8404
  }
8205
8405
  function dispatch() {
8206
- let client = idleClient();
8207
- while (client !== null) {
8208
- const result = takeNext(queue);
8209
- if (result.review === null) {
8210
- break;
8211
- }
8212
- queue = result.state;
8213
- clients.set(client, result.review.id);
8214
- send(client, {
8215
- type: "review",
8216
- id: result.review.id,
8217
- tool: result.review.request.tool,
8218
- path: result.review.request.filePath,
8219
- before: result.review.request.before,
8220
- after: result.review.request.after
8221
- });
8222
- client = idleClient();
8406
+ if (clients.size === 0) {
8407
+ announce();
8408
+ return;
8223
8409
  }
8410
+ const result = offerAll(queue);
8411
+ queue = result.state;
8412
+ result.reviews.forEach((review) => {
8413
+ clientsByReview.set(review.id, new Map(clients));
8414
+ clients.forEach((client) => send(client.socket, reviewMessage(review)));
8415
+ });
8224
8416
  announce();
8225
8417
  }
8226
8418
  function handleSubmit(socket, request) {
8227
8419
  const id = generateId();
8228
8420
  queue = enqueue(queue, id, request);
8229
- agents.set(id, socket);
8421
+ agentByReview.set(id, socket);
8230
8422
  dispatch();
8231
8423
  }
8232
8424
  function handleAttach(socket) {
8233
- clients.set(socket, null);
8425
+ if (clientBySocket.has(socket)) {
8426
+ return;
8427
+ }
8428
+ nextClientId += 1;
8429
+ const client = { id: `c${nextClientId}`, socket };
8430
+ clients.set(client.id, client);
8431
+ clientBySocket.set(socket, client);
8432
+ lastAttachAt = (/* @__PURE__ */ new Date()).toISOString();
8433
+ offeredReviews(queue).forEach((review) => {
8434
+ const viewers = clientsByReview.get(review.id) ?? /* @__PURE__ */ new Map();
8435
+ viewers.set(client.id, client);
8436
+ clientsByReview.set(review.id, viewers);
8437
+ send(socket, reviewMessage(review));
8438
+ });
8234
8439
  dispatch();
8235
8440
  }
8236
8441
  function handleVerdict(socket, message) {
8237
- const agent = agents.get(message.id);
8238
- if (agent !== void 0) {
8442
+ const answering = clientBySocket.get(socket);
8443
+ if (!answering) {
8444
+ return;
8445
+ }
8446
+ const viewers = clientsByReview.get(message.id);
8447
+ if (!viewers?.has(answering.id)) {
8448
+ return;
8449
+ }
8450
+ const agent = agentByReview.get(message.id);
8451
+ if (agent) {
8239
8452
  send(agent, message);
8240
- agents.delete(message.id);
8453
+ agentByReview.delete(message.id);
8241
8454
  }
8455
+ viewers.forEach((viewer) => {
8456
+ if (viewer.id !== answering.id) {
8457
+ send(viewer.socket, { type: "cancel", id: message.id });
8458
+ }
8459
+ });
8460
+ clientsByReview.delete(message.id);
8242
8461
  queue = complete(queue, message.id);
8243
- if (clients.get(socket) === message.id) {
8244
- clients.set(socket, null);
8245
- }
8246
8462
  dispatch();
8247
8463
  }
8248
8464
  function dropAgent(socket) {
8249
- const owned = [...agents.entries()].filter(([, agentSocket]) => agentSocket === socket);
8465
+ const owned = [...agentByReview.entries()].filter(([, agentSocket]) => agentSocket === socket);
8466
+ if (owned.length === 0) {
8467
+ return;
8468
+ }
8250
8469
  owned.forEach(([id]) => {
8251
- agents.delete(id);
8252
- const holder = clientHolding(id);
8253
- if (holder !== null) {
8254
- send(holder, { type: "cancel", id });
8255
- clients.set(holder, null);
8256
- }
8470
+ agentByReview.delete(id);
8471
+ clientsByReview.get(id)?.forEach((viewer) => send(viewer.socket, { type: "cancel", id }));
8472
+ clientsByReview.delete(id);
8257
8473
  queue = complete(queue, id);
8258
8474
  });
8259
- if (owned.length > 0) {
8260
- dispatch();
8261
- }
8475
+ dispatch();
8262
8476
  }
8263
8477
  function dropClient(socket) {
8264
- const held = clients.get(socket);
8265
- if (held === void 0) {
8478
+ const leaving = clientBySocket.get(socket);
8479
+ if (!leaving) {
8266
8480
  return;
8267
8481
  }
8268
- clients.delete(socket);
8269
- if (held !== null && findReview(queue, held) !== null) {
8270
- queue = release(queue, held);
8271
- }
8482
+ clientBySocket.delete(socket);
8483
+ clients.delete(leaving.id);
8484
+ clientsByReview.forEach((viewers, id) => {
8485
+ viewers.delete(leaving.id);
8486
+ if (viewers.size === 0 && findReview(queue, id) !== null) {
8487
+ queue = release(queue, id);
8488
+ clientsByReview.delete(id);
8489
+ }
8490
+ });
8272
8491
  dispatch();
8273
8492
  }
8274
8493
  function handleLine(socket, line2) {
@@ -8289,6 +8508,15 @@ async function startSessionServer(options) {
8289
8508
  handleAttach(socket);
8290
8509
  return;
8291
8510
  }
8511
+ if (message.type === "status") {
8512
+ send(socket, {
8513
+ type: "state",
8514
+ clientCount: clients.size,
8515
+ waitingDepth: waitingDepth(queue),
8516
+ lastAttachAt
8517
+ });
8518
+ return;
8519
+ }
8292
8520
  if (message.type === "verdict") {
8293
8521
  handleVerdict(socket, message);
8294
8522
  }
@@ -8309,12 +8537,18 @@ async function startSessionServer(options) {
8309
8537
  }
8310
8538
  const server = createServer(handleConnection);
8311
8539
  await bindSocket(server, options.socketPath);
8540
+ if (options.record !== void 0) {
8541
+ writeRecord(options.socketPath, options.record);
8542
+ }
8312
8543
  server.on("error", reportError);
8313
8544
  return {
8314
8545
  socketPath: options.socketPath,
8315
8546
  clientCount() {
8316
8547
  return clients.size;
8317
8548
  },
8549
+ lastAttachAt() {
8550
+ return lastAttachAt;
8551
+ },
8318
8552
  waitingDepth() {
8319
8553
  return waitingDepth(queue);
8320
8554
  },
@@ -8326,6 +8560,7 @@ async function startSessionServer(options) {
8326
8560
  [...connections].forEach((socket) => socket.destroy());
8327
8561
  server.close(() => {
8328
8562
  removeQuietly(options.socketPath);
8563
+ removeQuietly(recordPathFor(options.socketPath));
8329
8564
  resolve3();
8330
8565
  });
8331
8566
  });
@@ -8333,76 +8568,873 @@ async function startSessionServer(options) {
8333
8568
  };
8334
8569
  }
8335
8570
 
8571
+ // src/transports/session/host.ts
8572
+ import { createConnection as createConnection2 } from "node:net";
8573
+ function connect(socketPath) {
8574
+ return new Promise((resolve3, reject) => {
8575
+ const socket = createConnection2(socketPath);
8576
+ socket.setEncoding("utf-8");
8577
+ socket.once("error", reject);
8578
+ socket.once("connect", () => resolve3(socket));
8579
+ });
8580
+ }
8581
+ function emptyHandlers() {
8582
+ return {
8583
+ review: [],
8584
+ cancel: [],
8585
+ change: [],
8586
+ close: [],
8587
+ bufferedReviews: [],
8588
+ bufferedCancels: []
8589
+ };
8590
+ }
8591
+ function addReviewHandler(handlers, handler) {
8592
+ handlers.review.push(handler);
8593
+ handlers.bufferedReviews.splice(0).forEach((review) => handler(review));
8594
+ }
8595
+ function addCancelHandler(handlers, handler) {
8596
+ handlers.cancel.push(handler);
8597
+ handlers.bufferedCancels.splice(0).forEach((id) => handler(id));
8598
+ }
8599
+ async function attach(socketPath, client, handlers, onState) {
8600
+ const socket = await connect(socketPath);
8601
+ const readLines = createLineReader();
8602
+ socket.on("error", () => socket.destroy());
8603
+ socket.on("data", (chunk) => {
8604
+ readLines(chunk).forEach((line2) => {
8605
+ const message = decodeLine(line2);
8606
+ if (message?.type === "review") {
8607
+ if (handlers.review.length === 0) {
8608
+ handlers.bufferedReviews.push(message);
8609
+ return;
8610
+ }
8611
+ handlers.review.forEach((handler) => handler(message));
8612
+ return;
8613
+ }
8614
+ if (message?.type === "cancel") {
8615
+ if (handlers.cancel.length === 0) {
8616
+ handlers.bufferedCancels.push(message.id);
8617
+ return;
8618
+ }
8619
+ handlers.cancel.forEach((handler) => handler(message.id));
8620
+ return;
8621
+ }
8622
+ if (message?.type === "state") {
8623
+ onState(message);
8624
+ }
8625
+ });
8626
+ });
8627
+ socket.write(encode({ type: "attach", client }));
8628
+ return socket;
8629
+ }
8630
+ async function ownerHost(options) {
8631
+ const handlers = emptyHandlers();
8632
+ const server = await startSessionServer({
8633
+ socketPath: options.socketPath,
8634
+ record: options.record,
8635
+ onError: options.onError
8636
+ });
8637
+ server.onChange(() => handlers.change.forEach((handler) => handler()));
8638
+ const socket = await attach(options.socketPath, options.client, handlers, () => {
8639
+ });
8640
+ return {
8641
+ socketPath: options.socketPath,
8642
+ owns: true,
8643
+ counts() {
8644
+ return { clients: server.clientCount(), waiting: server.waitingDepth() };
8645
+ },
8646
+ // The server holds the counts in process, so nothing needs asking.
8647
+ refreshCounts() {
8648
+ },
8649
+ verdict(id, questions) {
8650
+ socket.write(encode({ type: "verdict", id, questions }));
8651
+ },
8652
+ onReview(handler) {
8653
+ addReviewHandler(handlers, handler);
8654
+ },
8655
+ onCancel(handler) {
8656
+ addCancelHandler(handlers, handler);
8657
+ },
8658
+ onChange(handler) {
8659
+ handlers.change.push(handler);
8660
+ },
8661
+ // The owner's socket only closes when the server it owns closes, so a handler here would never fire.
8662
+ onClose() {
8663
+ },
8664
+ async close() {
8665
+ socket.destroy();
8666
+ await server.close();
8667
+ }
8668
+ };
8669
+ }
8670
+ async function viewerHost(options) {
8671
+ const handlers = emptyHandlers();
8672
+ let remote = null;
8673
+ const socket = await attach(options.socketPath, options.client, handlers, (state) => {
8674
+ remote = state;
8675
+ handlers.change.forEach((handler) => handler());
8676
+ });
8677
+ socket.on("close", () => {
8678
+ remote = null;
8679
+ handlers.change.forEach((handler) => handler());
8680
+ handlers.close.forEach((handler) => handler());
8681
+ });
8682
+ return {
8683
+ socketPath: options.socketPath,
8684
+ owns: false,
8685
+ counts() {
8686
+ return { clients: remote?.clientCount ?? 0, waiting: remote?.waitingDepth ?? 0 };
8687
+ },
8688
+ refreshCounts() {
8689
+ if (!socket.destroyed) {
8690
+ socket.write(encode({ type: "status" }));
8691
+ }
8692
+ },
8693
+ verdict(id, questions) {
8694
+ if (!socket.destroyed) {
8695
+ socket.write(encode({ type: "verdict", id, questions }));
8696
+ }
8697
+ },
8698
+ onReview(handler) {
8699
+ addReviewHandler(handlers, handler);
8700
+ },
8701
+ onCancel(handler) {
8702
+ addCancelHandler(handlers, handler);
8703
+ },
8704
+ onChange(handler) {
8705
+ handlers.change.push(handler);
8706
+ },
8707
+ onClose(handler) {
8708
+ handlers.close.push(handler);
8709
+ },
8710
+ close() {
8711
+ socket.destroy();
8712
+ return Promise.resolve();
8713
+ }
8714
+ };
8715
+ }
8716
+
8717
+ // src/transports/session/probe.ts
8718
+ import { createConnection as createConnection3 } from "node:net";
8719
+ var STATUS_TIMEOUT_MS = 250;
8720
+ var ABANDONED_CODES = ["ECONNREFUSED", "ENOENT", "ENOTSOCK", "ENOTDIR"];
8721
+ function probeSession(socketPath) {
8722
+ return new Promise((resolve3) => {
8723
+ let settled = false;
8724
+ let connected = false;
8725
+ const settle = (probe) => {
8726
+ if (settled) {
8727
+ return;
8728
+ }
8729
+ settled = true;
8730
+ clearTimeout(timer);
8731
+ socket.destroy();
8732
+ resolve3(probe);
8733
+ };
8734
+ const timer = setTimeout(() => settle({ status: "silent" }), STATUS_TIMEOUT_MS);
8735
+ const socket = createConnection3(socketPath);
8736
+ socket.setEncoding("utf-8");
8737
+ const readLines = createLineReader();
8738
+ socket.on("error", (error) => {
8739
+ const code2 = "code" in error ? error.code : null;
8740
+ const abandoned = !connected && isString(code2) && ABANDONED_CODES.includes(code2);
8741
+ settle(abandoned ? { status: "refused" } : { status: "silent" });
8742
+ });
8743
+ socket.on("close", () => settle({ status: "silent" }));
8744
+ socket.on("data", (chunk) => {
8745
+ readLines(chunk).forEach((line2) => {
8746
+ const message = decodeLine(line2);
8747
+ if (message?.type !== "state") {
8748
+ return;
8749
+ }
8750
+ settle({ status: "answered", state: message });
8751
+ });
8752
+ });
8753
+ socket.on("connect", () => {
8754
+ connected = true;
8755
+ socket.write(encode({ type: "status" }));
8756
+ });
8757
+ });
8758
+ }
8759
+
8760
+ // src/cli/sessions/sessions.ts
8761
+ var UNKNOWN_LABEL = "unknown";
8762
+ var UNKNOWN_AGE = "-";
8763
+ var UNKNOWN_COUNT = "?";
8764
+ var SESSION_KINDS = ["session", "directory"];
8765
+ var SECOND_MS = 1e3;
8766
+ var MINUTE_MS = 60 * SECOND_MS;
8767
+ var HOUR_MS = 60 * MINUTE_MS;
8768
+ var DAY_MS = 24 * HOUR_MS;
8769
+ function isSessionKind(value) {
8770
+ return isString(value) && SESSION_KINDS.includes(value);
8771
+ }
8772
+ function isSessionRecord(value) {
8773
+ if (!isRecord(value)) {
8774
+ return false;
8775
+ }
8776
+ if (!isString(value["id"]) || !isSessionKind(value["kind"])) {
8777
+ return false;
8778
+ }
8779
+ if (!isString(value["label"]) || !isString(value["directory"])) {
8780
+ return false;
8781
+ }
8782
+ if (!isNullableString(value["branch"]) || !isNullableString(value["agentSessionId"])) {
8783
+ return false;
8784
+ }
8785
+ if (!isNullableString(value["agentKind"]) || !isString(value["createdAt"])) {
8786
+ return false;
8787
+ }
8788
+ return typeof value["pid"] === "number";
8789
+ }
8790
+ function readRecord(id) {
8791
+ const path = join7(sessionsDir(), `${id}.json`);
8792
+ if (!existsSync5(path)) {
8793
+ return null;
8794
+ }
8795
+ try {
8796
+ const parsed = JSON.parse(readFileSync2(path, "utf-8"));
8797
+ return isSessionRecord(parsed) ? parsed : null;
8798
+ } catch {
8799
+ return null;
8800
+ }
8801
+ }
8802
+ function sessionIds() {
8803
+ try {
8804
+ return readdirSync(sessionsDir()).filter((name) => name.endsWith(".sock")).map((name) => basename3(name, ".sock")).sort();
8805
+ } catch {
8806
+ return [];
8807
+ }
8808
+ }
8809
+ function removeSession(id) {
8810
+ [".sock", ".json", ".url"].forEach(
8811
+ (extension) => removeQuietly(join7(sessionsDir(), `${id}${extension}`))
8812
+ );
8813
+ }
8814
+ var FLAG_EXTENSIONS = [".on", ".off"];
8815
+ var FLAG_EXPIRY_MS = 14 * DAY_MS;
8816
+ function flagFiles() {
8817
+ try {
8818
+ return readdirSync(sessionsDir()).filter((name) => FLAG_EXTENSIONS.some((extension) => name.endsWith(extension))).filter((name) => name.startsWith("s-")).sort();
8819
+ } catch {
8820
+ return [];
8821
+ }
8822
+ }
8823
+ function ageOf(path, now) {
8824
+ try {
8825
+ return now - statSync(path).mtimeMs;
8826
+ } catch {
8827
+ return null;
8828
+ }
8829
+ }
8830
+ function sweepExpiredFlags(now = Date.now()) {
8831
+ const expired = [];
8832
+ flagFiles().forEach((name) => {
8833
+ const id = name.replace(/\.(on|off)$/, "");
8834
+ if (existsSync5(join7(sessionsDir(), `${id}.sock`))) {
8835
+ return;
8836
+ }
8837
+ const path = join7(sessionsDir(), name);
8838
+ const age = ageOf(path, now);
8839
+ if (age === null || age < FLAG_EXPIRY_MS) {
8840
+ return;
8841
+ }
8842
+ removeQuietly(path);
8843
+ expired.push(name);
8844
+ });
8845
+ return expired;
8846
+ }
8847
+ function kindOf(id, record) {
8848
+ if (record !== null) {
8849
+ return record.kind;
8850
+ }
8851
+ return id.startsWith("s-") ? "session" : "directory";
8852
+ }
8853
+ function toListing(id, probe) {
8854
+ const record = readRecord(id);
8855
+ const state = probe.status === "answered" ? probe.state : null;
8856
+ return {
8857
+ id,
8858
+ kind: kindOf(id, record),
8859
+ label: record?.label ?? UNKNOWN_LABEL,
8860
+ directory: record?.directory ?? "",
8861
+ clients: state?.clientCount ?? null,
8862
+ waiting: state?.waitingDepth ?? null,
8863
+ createdAt: record?.createdAt ?? "",
8864
+ alive: true
8865
+ };
8866
+ }
8867
+ async function scan() {
8868
+ const ids = sessionIds();
8869
+ const probes = await Promise.all(
8870
+ ids.map((id) => probeSession(join7(sessionsDir(), `${id}.sock`)))
8871
+ );
8872
+ const listings = [];
8873
+ const swept = [];
8874
+ ids.forEach((id, index) => {
8875
+ const probe = probes[index];
8876
+ if (probe === void 0 || probe.status === "refused") {
8877
+ removeSession(id);
8878
+ swept.push(id);
8879
+ return;
8880
+ }
8881
+ listings.push(toListing(id, probe));
8882
+ });
8883
+ return { listings, swept, expired: sweepExpiredFlags() };
8884
+ }
8885
+ function formatAge(createdAt, now) {
8886
+ const started = Date.parse(createdAt);
8887
+ if (Number.isNaN(started)) {
8888
+ return UNKNOWN_AGE;
8889
+ }
8890
+ const elapsed = Math.max(0, now - started);
8891
+ if (elapsed >= DAY_MS) {
8892
+ return `${Math.floor(elapsed / DAY_MS)}d`;
8893
+ }
8894
+ if (elapsed >= HOUR_MS) {
8895
+ return `${Math.floor(elapsed / HOUR_MS)}h`;
8896
+ }
8897
+ if (elapsed >= MINUTE_MS) {
8898
+ return `${Math.floor(elapsed / MINUTE_MS)}m`;
8899
+ }
8900
+ return `${Math.floor(elapsed / SECOND_MS)}s`;
8901
+ }
8902
+ function formatCount(count) {
8903
+ return count === null ? UNKNOWN_COUNT : String(count);
8904
+ }
8905
+ function formatTable(listings, now) {
8906
+ const header = ["ID", "LABEL", "KIND", "WATCHERS", "QUEUED", "AGE"];
8907
+ const rows = listings.map((entry) => [
8908
+ entry.id,
8909
+ entry.label,
8910
+ entry.kind,
8911
+ formatCount(entry.clients),
8912
+ formatCount(entry.waiting),
8913
+ formatAge(entry.createdAt, now)
8914
+ ]);
8915
+ const widths = header.map(
8916
+ (name, column) => Math.max(name.length, ...rows.map((row) => row[column]?.length ?? 0))
8917
+ );
8918
+ const line2 = (cells) => cells.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
8919
+ return [line2(header), ...rows.map(line2)].join("\n");
8920
+ }
8921
+ function sweptLine(swept) {
8922
+ const noun = swept.length === 1 ? "session" : "sessions";
8923
+ return `swept ${swept.length} dead ${noun}`;
8924
+ }
8925
+ function expiredLine(expired) {
8926
+ const noun = expired.length === 1 ? "flag" : "flags";
8927
+ return `expired ${expired.length} stale session ${noun}`;
8928
+ }
8929
+ async function listSessions() {
8930
+ const { listings, swept, expired } = await scan();
8931
+ const table = listings.length === 0 ? "no pair-mode sessions" : formatTable(listings, Date.now());
8932
+ const notes = [
8933
+ ...swept.length === 0 ? [] : [sweptLine(swept)],
8934
+ ...expired.length === 0 ? [] : [expiredLine(expired)]
8935
+ ];
8936
+ const text = notes.length === 0 ? table : `${table}
8937
+
8938
+ ${notes.join("\n")}`;
8939
+ return { listings, swept, expired, text, exitCode: 0 };
8940
+ }
8941
+ async function sweepDeadSessions() {
8942
+ const { swept } = await scan();
8943
+ return swept;
8944
+ }
8945
+
8946
+ // src/cli/sessions/connect.ts
8947
+ var CLEAR_SCREEN = "\x1B[2J\x1B[H";
8948
+ var DOWN_KEYS = ["j", "\x1B[B"];
8949
+ var UP_KEYS = ["k", "\x1B[A"];
8950
+ var SELECT_KEYS = ["\r", "\n"];
8951
+ var QUIT_KEYS = ["q", ""];
8952
+ var HELP = "j/k move, Enter watches, q quits";
8953
+ function watchers(entry) {
8954
+ return entry.clients === null ? "?" : String(entry.clients);
8955
+ }
8956
+ function paint(io, listings, cursor) {
8957
+ const rows = listings.map((entry, index) => {
8958
+ const marker = index === cursor ? ">" : " ";
8959
+ return `${marker} ${entry.id} ${entry.label} ${watchers(entry)} watching`;
8960
+ });
8961
+ io.write(`${CLEAR_SCREEN}pair mode sessions\r
8962
+ \r
8963
+ ${rows.join("\r\n")}\r
8964
+ \r
8965
+ ${HELP}\r
8966
+ `);
8967
+ }
8968
+ function pick(io, listings) {
8969
+ return new Promise((resolve3) => {
8970
+ let cursor = 0;
8971
+ io.onKey((key) => {
8972
+ if (QUIT_KEYS.includes(key)) {
8973
+ resolve3({ selected: null, exitCode: 0 });
8974
+ return;
8975
+ }
8976
+ if (SELECT_KEYS.includes(key)) {
8977
+ resolve3({ selected: listings[cursor] ?? null, exitCode: 0 });
8978
+ return;
8979
+ }
8980
+ if (DOWN_KEYS.includes(key)) {
8981
+ cursor = Math.min(cursor + 1, listings.length - 1);
8982
+ }
8983
+ if (UP_KEYS.includes(key)) {
8984
+ cursor = Math.max(cursor - 1, 0);
8985
+ }
8986
+ paint(io, listings, cursor);
8987
+ });
8988
+ paint(io, listings, cursor);
8989
+ });
8990
+ }
8991
+ async function runConnect(io) {
8992
+ try {
8993
+ if (!io.isTty()) {
8994
+ io.write("connect needs a terminal; run pair-mode sessions instead\n");
8995
+ return { selected: null, exitCode: 1 };
8996
+ }
8997
+ const { listings } = await listSessions();
8998
+ if (listings.length === 0) {
8999
+ io.write("no pair-mode sessions\n");
9000
+ return { selected: null, exitCode: 0 };
9001
+ }
9002
+ return await pick(io, listings);
9003
+ } finally {
9004
+ io.shutdown();
9005
+ }
9006
+ }
9007
+
9008
+ // src/cli/toggle.ts
9009
+ import { spawn } from "node:child_process";
9010
+ import { existsSync as existsSync6, readFileSync as readFileSync3, unlinkSync as unlinkSync3 } from "node:fs";
9011
+ import { join as join8 } from "node:path";
9012
+ var POLL_MS = 100;
9013
+ var POLL_ATTEMPTS = 60;
9014
+ var SESSION_ENV_VARS = ["CLAUDE_CODE_SESSION_ID", "CODEX_SESSION_ID", "CODEX_THREAD_ID"];
9015
+ function agentSessionId(env) {
9016
+ const found = SESSION_ENV_VARS.map((name) => env[name]).find(
9017
+ (value) => typeof value === "string" && value !== ""
9018
+ );
9019
+ return found ?? null;
9020
+ }
9021
+ function currentSessionKey() {
9022
+ return keyFor(agentSessionId(process.env) ?? void 0);
9023
+ }
9024
+ function statusProbe(directory) {
9025
+ return join8(directory, ".pair-mode-status-probe");
9026
+ }
9027
+ function sleep(ms) {
9028
+ return new Promise((done) => setTimeout(done, ms));
9029
+ }
9030
+ function readLink(directory, key) {
9031
+ const path = watchUrlPath(directory, key);
9032
+ if (!existsSync6(path)) {
9033
+ return null;
9034
+ }
9035
+ try {
9036
+ const parsed = JSON.parse(readFileSync3(path, "utf-8"));
9037
+ if (isRecord(parsed) && typeof parsed["url"] === "string" && typeof parsed["pid"] === "number") {
9038
+ return { url: parsed["url"], pid: parsed["pid"] };
9039
+ }
9040
+ } catch {
9041
+ return null;
9042
+ }
9043
+ return null;
9044
+ }
9045
+ async function waitForLink(directory, key) {
9046
+ const attempts = Array.from({ length: POLL_ATTEMPTS }, (_, index) => index);
9047
+ for (const _attempt of attempts) {
9048
+ const link = readLink(directory, key);
9049
+ if (link !== null) {
9050
+ return link;
9051
+ }
9052
+ await sleep(POLL_MS);
9053
+ }
9054
+ return null;
9055
+ }
9056
+ function stopLink(directory, key) {
9057
+ const link = readLink(directory, key);
9058
+ if (link === null) {
9059
+ return false;
9060
+ }
9061
+ try {
9062
+ process.kill(link.pid, "SIGTERM");
9063
+ } catch {
9064
+ }
9065
+ try {
9066
+ unlinkSync3(watchUrlPath(directory, key));
9067
+ } catch {
9068
+ }
9069
+ return true;
9070
+ }
9071
+ function onHeadline(directory, key) {
9072
+ return key ? `pair mode ON \xB7 ${key} \xB7 ${directory}` : `pair mode ON for ${directory}`;
9073
+ }
9074
+ function pairOn(directory, key) {
9075
+ if (key) {
9076
+ enableSession(key);
9077
+ } else {
9078
+ enable(directory);
9079
+ }
9080
+ return onHeadline(directory, key);
9081
+ }
9082
+ async function pairOnWeb(directory, cliPath, key) {
9083
+ const headline = onHeadline(directory, key);
9084
+ if (key) {
9085
+ enableSession(key);
9086
+ } else {
9087
+ enable(directory);
9088
+ }
9089
+ const existing = readLink(directory, key);
9090
+ if (existing !== null) {
9091
+ return `${headline}
9092
+ ${existing.url}`;
9093
+ }
9094
+ const target = key ?? directory;
9095
+ const child = spawn(process.execPath, [cliPath, "watch", "--web", target], {
9096
+ cwd: directory,
9097
+ detached: true,
9098
+ stdio: "ignore"
9099
+ });
9100
+ child.unref();
9101
+ const link = await waitForLink(directory, key);
9102
+ if (link === null) {
9103
+ return `${headline}
9104
+ the web watcher did not report a link`;
9105
+ }
9106
+ return `${headline}
9107
+ ${link.url}`;
9108
+ }
9109
+ function pairOff(directory, key) {
9110
+ if (key) {
9111
+ optOutSession(key);
9112
+ const stopped2 = stopLink(directory, key);
9113
+ return stopped2 ? `pair mode OFF \xB7 ${key} (web watcher stopped)` : `pair mode OFF \xB7 ${key}`;
9114
+ }
9115
+ disable(directory);
9116
+ const stopped = stopLink(directory);
9117
+ return stopped ? `pair mode OFF for ${directory} (web watcher stopped)` : `pair mode OFF for ${directory}`;
9118
+ }
9119
+ async function pairToggle(directory, cliPath, web, key) {
9120
+ const on = key ? isEnabled(statusProbe(directory), key) : existsSync6(flagPath(directory));
9121
+ if (on) {
9122
+ return pairOff(directory, key);
9123
+ }
9124
+ if (web) {
9125
+ return await pairOnWeb(directory, cliPath, key);
9126
+ }
9127
+ return pairOn(directory, key);
9128
+ }
9129
+ function pairStatus(directory, key) {
9130
+ const on = isEnabled(statusProbe(directory), key);
9131
+ const link = readLink(directory, key);
9132
+ const scope = key ? `${key} \xB7 ${directory}` : directory;
9133
+ const state = `pair mode ${on ? "ON" : "OFF"} for ${scope}`;
9134
+ return link === null ? state : `${state}
9135
+ ${link.url}`;
9136
+ }
9137
+
8336
9138
  // src/editors/micro.ts
8337
9139
  var import_yaml = __toESM(require_dist(), 1);
8338
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
8339
- import { join as join8 } from "node:path";
9140
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
9141
+ import { join as join10 } from "node:path";
8340
9142
 
8341
9143
  // src/editors/languages.ts
8342
- import { extname as extname2 } from "node:path";
8343
- var LANGS = {
9144
+ import { openSync, readSync, closeSync } from "node:fs";
9145
+ import { basename as basename4, extname as extname2 } from "node:path";
9146
+ var EXTENSIONS = {
8344
9147
  ".go": "go",
8345
9148
  ".rb": "ruby",
8346
9149
  ".rake": "ruby",
9150
+ ".gemspec": "ruby",
9151
+ ".ru": "ruby",
9152
+ ".erb": "erb",
9153
+ ".haml": "haml",
8347
9154
  ".ts": "typescript",
8348
- ".tsx": "typescript",
9155
+ ".mts": "typescript",
9156
+ ".cts": "typescript",
9157
+ ".tsx": "tsx",
8349
9158
  ".js": "javascript",
8350
- ".jsx": "javascript",
8351
9159
  ".mjs": "javascript",
8352
- ".py": "python3",
9160
+ ".cjs": "javascript",
9161
+ ".jsx": "jsx",
9162
+ ".vue": "vue",
9163
+ ".svelte": "svelte",
9164
+ ".astro": "astro",
9165
+ ".py": "python",
9166
+ ".pyi": "python",
8353
9167
  ".ex": "elixir",
8354
9168
  ".exs": "elixir",
9169
+ ".heex": "elixir",
9170
+ ".erl": "erlang",
9171
+ ".hrl": "erlang",
8355
9172
  ".rs": "rust",
8356
- ".sh": "sh",
8357
- ".bash": "sh",
8358
- ".fish": "fish",
9173
+ ".sh": "shellscript",
9174
+ ".bash": "shellscript",
9175
+ ".ksh": "shellscript",
8359
9176
  ".zsh": "zsh",
9177
+ ".fish": "fish",
9178
+ ".ps1": "powershell",
9179
+ ".psm1": "powershell",
9180
+ ".bat": "bat",
9181
+ ".cmd": "bat",
8360
9182
  ".sql": "sql",
8361
9183
  ".json": "json",
9184
+ ".jsonc": "jsonc",
9185
+ ".json5": "json5",
8362
9186
  ".tf": "terraform",
9187
+ ".tfvars": "terraform",
9188
+ ".hcl": "hcl",
8363
9189
  ".proto": "proto",
8364
- ".dockerfile": "dockerfile",
9190
+ ".dockerfile": "docker",
8365
9191
  ".toml": "toml",
8366
9192
  ".yaml": "yaml",
8367
9193
  ".yml": "yaml",
9194
+ ".ini": "ini",
9195
+ ".cfg": "ini",
9196
+ ".properties": "properties",
9197
+ ".env": "dotenv",
8368
9198
  ".md": "markdown",
9199
+ ".markdown": "markdown",
9200
+ ".mdx": "mdx",
8369
9201
  ".css": "css",
9202
+ ".scss": "scss",
9203
+ ".sass": "sass",
9204
+ ".less": "less",
9205
+ ".styl": "stylus",
8370
9206
  ".html": "html",
8371
- ".erb": "html",
9207
+ ".htm": "html",
9208
+ ".xml": "xml",
9209
+ ".xsl": "xml",
9210
+ ".svg": "xml",
8372
9211
  ".lua": "lua",
8373
9212
  ".c": "c",
8374
- ".h": "c"
9213
+ ".h": "c",
9214
+ ".cc": "cpp",
9215
+ ".cpp": "cpp",
9216
+ ".cxx": "cpp",
9217
+ ".hpp": "cpp",
9218
+ ".hh": "cpp",
9219
+ ".cs": "csharp",
9220
+ ".java": "java",
9221
+ ".kt": "kotlin",
9222
+ ".kts": "kotlin",
9223
+ ".swift": "swift",
9224
+ ".m": "objective-c",
9225
+ ".mm": "objective-c",
9226
+ ".php": "php",
9227
+ ".pl": "perl",
9228
+ ".pm": "perl",
9229
+ ".scala": "scala",
9230
+ ".sc": "scala",
9231
+ ".clj": "clojure",
9232
+ ".cljs": "clojure",
9233
+ ".cljc": "clojure",
9234
+ ".hs": "haskell",
9235
+ ".nix": "nix",
9236
+ ".r": "r",
9237
+ ".jl": "julia",
9238
+ ".zig": "zig",
9239
+ ".nim": "nim",
9240
+ ".dart": "dart",
9241
+ ".groovy": "groovy",
9242
+ ".gradle": "groovy",
9243
+ ".cr": "crystal",
9244
+ ".ml": "ocaml",
9245
+ ".mli": "ocaml",
9246
+ ".fs": "fsharp",
9247
+ ".fsx": "fsharp",
9248
+ ".elm": "elm",
9249
+ ".graphql": "graphql",
9250
+ ".gql": "graphql",
9251
+ ".prisma": "prisma",
9252
+ ".sol": "solidity",
9253
+ ".vim": "viml",
9254
+ ".diff": "diff",
9255
+ ".patch": "diff",
9256
+ ".tex": "latex",
9257
+ ".wgsl": "wgsl",
9258
+ ".glsl": "glsl",
9259
+ ".cue": "cue"
9260
+ };
9261
+ var FILENAMES = {
9262
+ gemfile: "ruby",
9263
+ rakefile: "ruby",
9264
+ capfile: "ruby",
9265
+ vagrantfile: "ruby",
9266
+ guardfile: "ruby",
9267
+ podfile: "ruby",
9268
+ fastfile: "ruby",
9269
+ appfile: "ruby",
9270
+ brewfile: "ruby",
9271
+ "config.ru": "ruby",
9272
+ dockerfile: "docker",
9273
+ containerfile: "docker",
9274
+ makefile: "make",
9275
+ gnumakefile: "make",
9276
+ "cmakelists.txt": "cmake",
9277
+ "cargo.lock": "toml",
9278
+ "gemfile.lock": "toml",
9279
+ ".babelrc": "json",
9280
+ ".eslintrc": "json",
9281
+ ".prettierrc": "json",
9282
+ ".env": "dotenv",
9283
+ ".gitconfig": "ini",
9284
+ ".editorconfig": "ini",
9285
+ "nginx.conf": "nginx",
9286
+ "pnpm-workspace.yaml": "yaml"
8375
9287
  };
8376
- function syntaxName(sourcePath) {
8377
- const ext = extname2(sourcePath).toLowerCase();
8378
- return LANGS[ext] ?? null;
8379
- }
8380
- var SHIKI_TRANSLATIONS = {
8381
- python3: "python",
8382
- sh: "shellscript",
8383
- zsh: "shellscript",
9288
+ var SHEBANGS = [
9289
+ { pattern: /^#!.*\/(env\s+)?ruby(\s|$)/, id: "ruby" },
9290
+ { pattern: /^#!.*\/(env\s+)?python[\d.]*(\s|$)/, id: "python" },
9291
+ { pattern: /^#!.*\/(env\s+)?(node|bun|deno)(\s|$)/, id: "javascript" },
9292
+ { pattern: /^#!.*\/(env\s+)?(bash|sh|ksh|dash)(\s|$)/, id: "shellscript" },
9293
+ { pattern: /^#!.*\/(env\s+)?zsh(\s|$)/, id: "zsh" },
9294
+ { pattern: /^#!.*\/(env\s+)?fish(\s|$)/, id: "fish" },
9295
+ { pattern: /^#!.*\/(env\s+)?perl(\s|$)/, id: "perl" },
9296
+ { pattern: /^#!.*\/(env\s+)?php(\s|$)/, id: "php" },
9297
+ { pattern: /^#!.*\/(env\s+)?(elixir|iex)(\s|$)/, id: "elixir" },
9298
+ { pattern: /^#!.*\/(env\s+)?lua(\s|$)/, id: "lua" },
9299
+ { pattern: /^#!.*\/(env\s+)?Rscript(\s|$)/, id: "r" },
9300
+ { pattern: /^#!.*\/(env\s+)?pwsh(\s|$)/, id: "powershell" }
9301
+ ];
9302
+ var FIRST_LINE_BYTES = 256;
9303
+ function firstLine(sourcePath) {
9304
+ let handle = null;
9305
+ try {
9306
+ handle = openSync(sourcePath, "r");
9307
+ const buffer = Buffer.alloc(FIRST_LINE_BYTES);
9308
+ const read = readSync(handle, buffer, 0, FIRST_LINE_BYTES, 0);
9309
+ const text = buffer.toString("utf-8", 0, read);
9310
+ const newline = text.indexOf("\n");
9311
+ return newline === -1 ? text : text.slice(0, newline);
9312
+ } catch {
9313
+ return null;
9314
+ } finally {
9315
+ if (handle !== null) {
9316
+ try {
9317
+ closeSync(handle);
9318
+ } catch {
9319
+ }
9320
+ }
9321
+ }
9322
+ }
9323
+ function shebangLanguage(sourcePath) {
9324
+ const line2 = firstLine(sourcePath);
9325
+ if (line2 === null || !line2.startsWith("#!")) {
9326
+ return null;
9327
+ }
9328
+ return SHEBANGS.find((rule) => rule.pattern.test(line2))?.id ?? null;
9329
+ }
9330
+ function detectLanguage(sourcePath) {
9331
+ const name = basename4(sourcePath).toLowerCase();
9332
+ const ext = extname2(name);
9333
+ if (ext !== "" && EXTENSIONS[ext] !== void 0) {
9334
+ return EXTENSIONS[ext];
9335
+ }
9336
+ if (FILENAMES[name] !== void 0) {
9337
+ return FILENAMES[name];
9338
+ }
9339
+ return shebangLanguage(sourcePath);
9340
+ }
9341
+ var MICRO_SYNTAX = {
9342
+ c: "c",
9343
+ clojure: "clojure",
9344
+ cmake: "cmake",
9345
+ crystal: "crystal",
9346
+ csharp: "csharp",
9347
+ css: "css",
9348
+ dart: "dart",
9349
+ docker: "dockerfile",
9350
+ elixir: "elixir",
9351
+ elm: "elm",
9352
+ erb: "erb",
9353
+ erlang: "erlang",
8384
9354
  fish: "fish",
9355
+ fsharp: "fsharp",
9356
+ go: "go",
9357
+ graphql: "graphql",
9358
+ groovy: "groovy",
9359
+ haml: "haml",
9360
+ haskell: "haskell",
9361
+ html: "html",
9362
+ ini: "ini",
9363
+ java: "java",
9364
+ javascript: "javascript",
9365
+ json: "json",
9366
+ julia: "julia",
9367
+ kotlin: "kotlin",
9368
+ lua: "lua",
9369
+ make: "makefile",
9370
+ markdown: "markdown",
9371
+ nginx: "nginx",
9372
+ nim: "nim",
9373
+ nix: "nix",
9374
+ "objective-c": "objc",
9375
+ ocaml: "ocaml",
9376
+ perl: "perl",
9377
+ php: "php",
8385
9378
  proto: "proto",
8386
- dockerfile: "docker",
8387
- terraform: "terraform"
9379
+ python: "python3",
9380
+ r: "r",
9381
+ ruby: "ruby",
9382
+ rust: "rust",
9383
+ scala: "scala",
9384
+ shellscript: "sh",
9385
+ sql: "sql",
9386
+ svelte: "svelte",
9387
+ swift: "swift",
9388
+ terraform: "terraform",
9389
+ toml: "toml",
9390
+ typescript: "typescript",
9391
+ vue: "vue",
9392
+ xml: "xml",
9393
+ yaml: "yaml",
9394
+ zig: "zig",
9395
+ zsh: "zsh"
9396
+ };
9397
+ var VIM_FILETYPES = {
9398
+ shellscript: "sh",
9399
+ docker: "dockerfile",
9400
+ csharp: "cs",
9401
+ "objective-c": "objc",
9402
+ bat: "dosbatch",
9403
+ viml: "vim",
9404
+ mdx: "markdown",
9405
+ jsx: "javascriptreact",
9406
+ tsx: "typescriptreact",
9407
+ dotenv: "sh",
9408
+ properties: "jproperties",
9409
+ stylus: "stylus"
8388
9410
  };
8389
9411
  function shikiLanguage(sourcePath) {
8390
- const name = syntaxName(sourcePath);
8391
- if (name === null) {
9412
+ return detectLanguage(sourcePath);
9413
+ }
9414
+ function microSyntaxName(sourcePath) {
9415
+ const id = detectLanguage(sourcePath);
9416
+ if (id === null) {
9417
+ return null;
9418
+ }
9419
+ return MICRO_SYNTAX[id] ?? null;
9420
+ }
9421
+ function vimFiletype(sourcePath) {
9422
+ const id = detectLanguage(sourcePath);
9423
+ if (id === null) {
8392
9424
  return null;
8393
9425
  }
8394
- return SHIKI_TRANSLATIONS[name] ?? name;
9426
+ return VIM_FILETYPES[id] ?? id;
8395
9427
  }
8396
9428
 
8397
9429
  // src/editors/syntax-cache.ts
8398
- import { existsSync as existsSync5, readFileSync as readFileSync2 } from "node:fs";
8399
- import { dirname as dirname6, join as join7 } from "node:path";
9430
+ import { existsSync as existsSync7, readFileSync as readFileSync4 } from "node:fs";
9431
+ import { dirname as dirname6, join as join9 } from "node:path";
8400
9432
  import { fileURLToPath as fileURLToPath3 } from "node:url";
8401
9433
  function defaultAssetsDir() {
8402
9434
  let dir = dirname6(fileURLToPath3(import.meta.url));
8403
9435
  while (true) {
8404
- const candidate = join7(dir, "assets", "syntax");
8405
- if (existsSync5(candidate)) {
9436
+ const candidate = join9(dir, "assets", "syntax");
9437
+ if (existsSync7(candidate)) {
8406
9438
  return candidate;
8407
9439
  }
8408
9440
  const parent = dirname6(dir);
@@ -8416,11 +9448,11 @@ function syntaxSource(lang, assetsDir = defaultAssetsDir()) {
8416
9448
  if (assetsDir === null) {
8417
9449
  return null;
8418
9450
  }
8419
- const path = join7(assetsDir, `${lang}.yaml`);
8420
- if (!existsSync5(path)) {
9451
+ const path = join9(assetsDir, `${lang}.yaml`);
9452
+ if (!existsSync7(path)) {
8421
9453
  return null;
8422
9454
  }
8423
- return readFileSync2(path, "utf-8");
9455
+ return readFileSync4(path, "utf-8");
8424
9456
  }
8425
9457
 
8426
9458
  // src/editors/micro.ts
@@ -8453,7 +9485,7 @@ function bandRules() {
8453
9485
  ];
8454
9486
  }
8455
9487
  function writeColorScheme(configDir, theme2) {
8456
- const dir = join8(configDir, "colorschemes");
9488
+ const dir = join10(configDir, "colorschemes");
8457
9489
  mkdirSync4(dir, { recursive: true });
8458
9490
  const text = `include "monokai"
8459
9491
 
@@ -8461,7 +9493,7 @@ color-link pairadd "#d7ffd7,${theme2.add}"
8461
9493
  color-link pairdel "#ffd7d7,${theme2.del}"
8462
9494
  color-link pairskip "#6a6a6a,${theme2.fold}"
8463
9495
  `;
8464
- writeFileSync3(join8(dir, "pair.micro"), text, "utf-8");
9496
+ writeFileSync4(join10(dir, "pair.micro"), text, "utf-8");
8465
9497
  }
8466
9498
  function syntaxText(lang, source) {
8467
9499
  const parsed = (0, import_yaml.parse)(source);
@@ -8477,7 +9509,7 @@ function syntaxText(lang, source) {
8477
9509
  return (0, import_yaml.stringify)(file, { lineWidth: 0 });
8478
9510
  }
8479
9511
  function writeSyntax(configDir, sourcePath) {
8480
- const lang = syntaxName(sourcePath);
9512
+ const lang = microSyntaxName(sourcePath);
8481
9513
  if (lang === null) {
8482
9514
  return;
8483
9515
  }
@@ -8489,9 +9521,9 @@ function writeSyntax(configDir, sourcePath) {
8489
9521
  if (text === null) {
8490
9522
  return;
8491
9523
  }
8492
- const dir = join8(configDir, "syntax");
9524
+ const dir = join10(configDir, "syntax");
8493
9525
  mkdirSync4(dir, { recursive: true });
8494
- writeFileSync3(join8(dir, `pair-${lang}.yaml`), text, "utf-8");
9526
+ writeFileSync4(join10(dir, `pair-${lang}.yaml`), text, "utf-8");
8495
9527
  }
8496
9528
  function createMicroEditor(resolvesOnPath = defaultResolvesOnPath) {
8497
9529
  return {
@@ -8504,7 +9536,7 @@ function createMicroEditor(resolvesOnPath = defaultResolvesOnPath) {
8504
9536
  return ["# F3 moves between panes. Ctrl+W or F2 sends and closes."];
8505
9537
  },
8506
9538
  bufferSuffix(sourcePath) {
8507
- const lang = syntaxName(sourcePath);
9539
+ const lang = microSyntaxName(sourcePath);
8508
9540
  if (lang === null) {
8509
9541
  return ".diff";
8510
9542
  }
@@ -8512,13 +9544,13 @@ function createMicroEditor(resolvesOnPath = defaultResolvesOnPath) {
8512
9544
  },
8513
9545
  prepare(context) {
8514
9546
  mkdirSync4(context.configDir, { recursive: true });
8515
- writeFileSync3(
8516
- join8(context.configDir, "bindings.json"),
9547
+ writeFileSync4(
9548
+ join10(context.configDir, "bindings.json"),
8517
9549
  JSON.stringify(MICRO_BINDINGS, null, 2),
8518
9550
  "utf-8"
8519
9551
  );
8520
- writeFileSync3(
8521
- join8(context.configDir, "settings.json"),
9552
+ writeFileSync4(
9553
+ join10(context.configDir, "settings.json"),
8522
9554
  JSON.stringify(MICRO_SETTINGS, null, 2),
8523
9555
  "utf-8"
8524
9556
  );
@@ -8534,16 +9566,6 @@ function createMicroEditor(resolvesOnPath = defaultResolvesOnPath) {
8534
9566
 
8535
9567
  // src/editors/vim.ts
8536
9568
  import { extname as extname3 } from "node:path";
8537
- var VIM_FILETYPE_OVERRIDES = {
8538
- python3: "python"
8539
- };
8540
- function vimFiletype(sourcePath) {
8541
- const lang = syntaxName(sourcePath);
8542
- if (lang === null) {
8543
- return null;
8544
- }
8545
- return VIM_FILETYPE_OVERRIDES[lang] ?? lang;
8546
- }
8547
9569
  function safeThemeColor(value) {
8548
9570
  if (!isHexColor(value)) {
8549
9571
  throw new Error(`invalid theme colour for vim highlight: ${value}`);
@@ -8597,8 +9619,8 @@ function vimEditor(name, resolvesOnPath = defaultResolvesOnPath) {
8597
9619
  }
8598
9620
 
8599
9621
  // src/editors/nano.ts
8600
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "node:fs";
8601
- import { join as join9 } from "node:path";
9622
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "node:fs";
9623
+ import { join as join11 } from "node:path";
8602
9624
  function safeThemeColor2(value) {
8603
9625
  if (!isHexColor(value)) {
8604
9626
  throw new Error(`invalid theme colour for nano rcfile: ${value}`);
@@ -8610,8 +9632,8 @@ function writeNanorc(configDir, theme2) {
8610
9632
  color ,${safeThemeColor2(theme2.del)} "^\u258C\u258C-"
8611
9633
  color ,${safeThemeColor2(theme2.fold)} "^\u22EF"
8612
9634
  `;
8613
- const path = join9(configDir, "pair.nanorc");
8614
- writeFileSync4(path, text, "utf-8");
9635
+ const path = join11(configDir, "pair.nanorc");
9636
+ writeFileSync5(path, text, "utf-8");
8615
9637
  return path;
8616
9638
  }
8617
9639
  function createNanoEditor(resolvesOnPath = defaultResolvesOnPath) {
@@ -8755,17 +9777,17 @@ function createTmuxMultiplexer(spawn2 = defaultSpawn, resolvesOnPath = defaultRe
8755
9777
  }
8756
9778
 
8757
9779
  // src/multiplexers/tty.ts
8758
- import { openSync, closeSync } from "node:fs";
8759
- import { spawnSync as spawnSync3 } from "node:child_process";
8760
- var defaultOpen = () => openSync("/dev/tty", "r+");
9780
+ import { openSync as openSync2, closeSync as closeSync2 } from "node:fs";
9781
+ import { spawnSync as spawnSync4 } from "node:child_process";
9782
+ var defaultOpen = () => openSync2("/dev/tty", "r+");
8761
9783
  var defaultRunner = (command, args, fd) => {
8762
- const result = spawnSync3(command, args, { stdio: [fd, fd, fd] });
9784
+ const result = spawnSync4(command, args, { stdio: [fd, fd, fd] });
8763
9785
  const detail = result.status === 0 ? "" : String(result.error?.message ?? result.stderr ?? "");
8764
9786
  return { ok: result.status === 0, detail };
8765
9787
  };
8766
9788
  function closeQuietly(fd) {
8767
9789
  try {
8768
- closeSync(fd);
9790
+ closeSync2(fd);
8769
9791
  } catch {
8770
9792
  }
8771
9793
  }
@@ -8823,9 +9845,16 @@ function detect(preference, adapters = {}) {
8823
9845
  }
8824
9846
 
8825
9847
  // src/cli/register/register.ts
8826
- import { existsSync as existsSync6, readFileSync as readFileSync3, writeFileSync as writeFileSync5, mkdirSync as mkdirSync6, copyFileSync } from "node:fs";
8827
- import { basename as basename2, dirname as dirname7, join as join10, sep as sep2 } from "node:path";
9848
+ import { existsSync as existsSync8, readFileSync as readFileSync5, writeFileSync as writeFileSync6, mkdirSync as mkdirSync6, copyFileSync } from "node:fs";
9849
+ import { basename as basename5, dirname as dirname7, join as join12, sep as sep2 } from "node:path";
8828
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
+ }
8829
9858
  function isHookEntry(value) {
8830
9859
  if (!isRecord(value)) {
8831
9860
  return false;
@@ -8854,10 +9883,10 @@ function isHookGroup(value) {
8854
9883
  return value["hooks"].every(isHookEntry);
8855
9884
  }
8856
9885
  function readJsonObject(path) {
8857
- if (!existsSync6(path)) {
9886
+ if (!existsSync8(path)) {
8858
9887
  return { ok: true, root: {} };
8859
9888
  }
8860
- const text = readFileSync3(path, "utf-8");
9889
+ const text = readFileSync5(path, "utf-8");
8861
9890
  let parsed;
8862
9891
  try {
8863
9892
  parsed = JSON.parse(text);
@@ -8874,7 +9903,7 @@ function readJsonObject(path) {
8874
9903
  }
8875
9904
  var backedUpThisRun = /* @__PURE__ */ new Set();
8876
9905
  function backupIfPresent(path) {
8877
- if (!existsSync6(path)) {
9906
+ if (!existsSync8(path)) {
8878
9907
  return null;
8879
9908
  }
8880
9909
  const backupPath = `${path}.pair-backup`;
@@ -8915,8 +9944,8 @@ function hasCommand(groups, command) {
8915
9944
  });
8916
9945
  }
8917
9946
  function matchesOurCommand(command) {
8918
- const suffix = sep2 + join10("dist", basename2(command));
8919
- return (entry) => entry.command === command || entry.command.endsWith(suffix);
9947
+ const suffix = sep2 + join12("dist", basename5(command));
9948
+ return (entry) => entry.command === command || entry.command.endsWith(suffix) || entry.command.endsWith(suffix + "'");
8920
9949
  }
8921
9950
  function upsertHookGroup(groups, matcher, command, timeout) {
8922
9951
  const matches = matchesOurCommand(command);
@@ -8947,7 +9976,7 @@ function upsertHookGroup(groups, matcher, command, timeout) {
8947
9976
  }
8948
9977
  function writeJsonObject(path, root) {
8949
9978
  mkdirSync6(dirname7(path), { recursive: true });
8950
- writeFileSync5(path, JSON.stringify(root, null, 2) + "\n", "utf-8");
9979
+ writeFileSync6(path, JSON.stringify(root, null, 2) + "\n", "utf-8");
8951
9980
  }
8952
9981
  function registerPreToolUseHook(path, matcher, command, timeout) {
8953
9982
  const read = readJsonObject(path);
@@ -8971,7 +10000,7 @@ function registerPreToolUseHook(path, matcher, command, timeout) {
8971
10000
  return { path, changed: true, backupPath };
8972
10001
  }
8973
10002
  function isPreToolUseRegistered(path, command) {
8974
- if (!existsSync6(path)) {
10003
+ if (!existsSync8(path)) {
8975
10004
  return false;
8976
10005
  }
8977
10006
  const read = readJsonObject(path);
@@ -8981,24 +10010,24 @@ function isPreToolUseRegistered(path, command) {
8981
10010
  return hasCommand(preToolUseGroups(read.root), command);
8982
10011
  }
8983
10012
  function claudeCodeSettingsPath(homeDir) {
8984
- return join10(homeDir, ".claude", "settings.json");
10013
+ return join12(homeDir, ".claude", "settings.json");
8985
10014
  }
8986
10015
  function registerClaudeCode(homeDir, installRoot2) {
8987
10016
  const path = claudeCodeSettingsPath(homeDir);
8988
- const command = join10(installRoot2, "dist", "claude-code.js");
10017
+ const command = hookCommand(installRoot2, "claude-code.js");
8989
10018
  return registerPreToolUseHook(path, "Write|Edit|MultiEdit", command, HOOK_TIMEOUT_SECONDS);
8990
10019
  }
8991
10020
  function codexHooksPath(homeDir) {
8992
- return join10(homeDir, ".codex", "hooks.json");
10021
+ return join12(homeDir, ".codex", "hooks.json");
8993
10022
  }
8994
10023
  function registerCodex(homeDir, installRoot2) {
8995
10024
  const path = codexHooksPath(homeDir);
8996
- const command = join10(installRoot2, "dist", "codex.js");
10025
+ const command = hookCommand(installRoot2, "codex.js");
8997
10026
  return registerPreToolUseHook(path, "apply_patch|Edit|Write", command, HOOK_TIMEOUT_SECONDS);
8998
10027
  }
8999
10028
  function findMultiEditMatchers(homeDir) {
9000
10029
  const path = codexHooksPath(homeDir);
9001
- if (!existsSync6(path)) {
10030
+ if (!existsSync8(path)) {
9002
10031
  return [];
9003
10032
  }
9004
10033
  const read = readJsonObject(path);
@@ -9058,12 +10087,12 @@ function correctMultiEditMatchers(homeDir) {
9058
10087
  return { path, changed: true, backupPath, note };
9059
10088
  }
9060
10089
  function writeFileIfChanged(path, content) {
9061
- if (existsSync6(path) && readFileSync3(path, "utf-8") === content) {
10090
+ if (existsSync8(path) && readFileSync5(path, "utf-8") === content) {
9062
10091
  return { path, changed: false, backupPath: null };
9063
10092
  }
9064
10093
  const backupPath = backupIfPresent(path);
9065
10094
  mkdirSync6(dirname7(path), { recursive: true });
9066
- writeFileSync5(path, content, "utf-8");
10095
+ writeFileSync6(path, content, "utf-8");
9067
10096
  return { path, changed: true, backupPath };
9068
10097
  }
9069
10098
  function writeReExport(path, target) {
@@ -9076,29 +10105,29 @@ export { default } from "${target}";
9076
10105
  `;
9077
10106
  }
9078
10107
  function isReExportRegistered(path, target) {
9079
- if (!existsSync6(path)) {
10108
+ if (!existsSync8(path)) {
9080
10109
  return false;
9081
10110
  }
9082
- return readFileSync3(path, "utf-8").includes(target);
10111
+ return readFileSync5(path, "utf-8").includes(target);
9083
10112
  }
9084
10113
  function opencodePluginPath(homeDir) {
9085
- return join10(homeDir, ".config", "opencode", "plugin", "pair-mode.ts");
10114
+ return join12(homeDir, ".config", "opencode", "plugin", "pair-mode.ts");
9086
10115
  }
9087
10116
  function registerOpencode(homeDir, installRoot2) {
9088
- const target = join10(installRoot2, "dist", "opencode.js");
10117
+ const target = join12(installRoot2, "dist", "opencode.js");
9089
10118
  return writeReExport(opencodePluginPath(homeDir), target);
9090
10119
  }
9091
10120
  function piExtensionPath(homeDir) {
9092
- return join10(homeDir, ".pi", "agent", "extensions", "pair-mode.ts");
10121
+ return join12(homeDir, ".pi", "agent", "extensions", "pair-mode.ts");
9093
10122
  }
9094
10123
  function registerPi(homeDir, installRoot2) {
9095
- const target = join10(installRoot2, "dist", "pi.js");
10124
+ const target = join12(installRoot2, "dist", "pi.js");
9096
10125
  return writeFileIfChanged(piExtensionPath(homeDir), piExtensionSource(target));
9097
10126
  }
9098
10127
 
9099
10128
  // src/cli/register/commands.ts
9100
- import { existsSync as existsSync7, readFileSync as readFileSync4 } from "node:fs";
9101
- import { join as join11 } from "node:path";
10129
+ import { existsSync as existsSync9, readFileSync as readFileSync6 } from "node:fs";
10130
+ import { join as join13 } from "node:path";
9102
10131
  var DESCRIPTION = "Toggle pair mode. Every proposed edit opens in the pair review pane for line annotation.";
9103
10132
  var INFER_ACTION = "Read the action from the user's message, then run `pair-mode <action>` and report the resulting state in one line.";
9104
10133
  var SPECS = {
@@ -9128,7 +10157,7 @@ var SPECS = {
9128
10157
  }
9129
10158
  };
9130
10159
  function pairCommandPath(homeDir, cli) {
9131
- return join11(homeDir, ...SPECS[cli].segments);
10160
+ return join13(homeDir, ...SPECS[cli].segments);
9132
10161
  }
9133
10162
  function pairCommandSource(cli) {
9134
10163
  const spec = SPECS[cli];
@@ -9155,10 +10184,10 @@ function pairCommandSource(cli) {
9155
10184
  }
9156
10185
  function isPairCommandRegistered(homeDir, cli) {
9157
10186
  const path = pairCommandPath(homeDir, cli);
9158
- if (!existsSync7(path)) {
10187
+ if (!existsSync9(path)) {
9159
10188
  return false;
9160
10189
  }
9161
- return readFileSync4(path, "utf-8") === pairCommandSource(cli);
10190
+ return readFileSync6(path, "utf-8") === pairCommandSource(cli);
9162
10191
  }
9163
10192
  function registerPairCommand(homeDir, cli) {
9164
10193
  return writeFileIfChanged(pairCommandPath(homeDir, cli), pairCommandSource(cli));
@@ -9194,7 +10223,7 @@ function checkMultiplexer(config, adapters) {
9194
10223
  function checkControllingTerminal(openTty) {
9195
10224
  try {
9196
10225
  const fd = openTty();
9197
- closeSync2(fd);
10226
+ closeSync3(fd);
9198
10227
  return { name: "controlling terminal", passed: true, detail: "/dev/tty opened" };
9199
10228
  } catch (error) {
9200
10229
  const name = error instanceof Error ? error.name : "Error";
@@ -9232,30 +10261,32 @@ function checkCommandOnPath(resolves) {
9232
10261
  };
9233
10262
  }
9234
10263
  function checkClis(home, root) {
9235
- const claudeCommand = join12(root, "dist", "claude-code.js");
9236
- const codexCommand = join12(root, "dist", "codex.js");
9237
- const opencodeTarget = join12(root, "dist", "opencode.js");
9238
- const piTarget = join12(root, "dist", "pi.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");
10268
+ const opencodeTarget = join14(root, "dist", "opencode.js");
10269
+ const piTarget = join14(root, "dist", "pi.js");
9239
10270
  const specs = [
9240
10271
  {
9241
10272
  cli: "claude-code",
9242
10273
  registered: isPreToolUseRegistered(claudeCodeSettingsPath(home), claudeCommand),
9243
- targetExists: existsSync8(claudeCommand)
10274
+ targetExists: existsSync10(claudeTarget)
9244
10275
  },
9245
10276
  {
9246
10277
  cli: "codex",
9247
10278
  registered: isPreToolUseRegistered(codexHooksPath(home), codexCommand),
9248
- targetExists: existsSync8(codexCommand)
10279
+ targetExists: existsSync10(codexTarget)
9249
10280
  },
9250
10281
  {
9251
10282
  cli: "opencode",
9252
10283
  registered: isReExportRegistered(opencodePluginPath(home), opencodeTarget),
9253
- targetExists: existsSync8(opencodeTarget)
10284
+ targetExists: existsSync10(opencodeTarget)
9254
10285
  },
9255
10286
  {
9256
10287
  cli: "pi",
9257
10288
  registered: isReExportRegistered(piExtensionPath(home), piTarget),
9258
- targetExists: existsSync8(piTarget)
10289
+ targetExists: existsSync10(piTarget)
9259
10290
  }
9260
10291
  ];
9261
10292
  const shown = specs.filter((spec) => isReleased(spec.cli) || spec.registered);
@@ -9265,14 +10296,14 @@ function checkClis(home, root) {
9265
10296
  var SHEBANG_LINE = "#!/usr/bin/env node\n";
9266
10297
  function isExecutable(path) {
9267
10298
  try {
9268
- return (statSync(path).mode & 73) !== 0;
10299
+ return (statSync2(path).mode & 73) !== 0;
9269
10300
  } catch {
9270
10301
  return false;
9271
10302
  }
9272
10303
  }
9273
10304
  function hasShebang(path) {
9274
10305
  try {
9275
- return readFileSync5(path, "utf-8").startsWith(SHEBANG_LINE);
10306
+ return readFileSync7(path, "utf-8").startsWith(SHEBANG_LINE);
9276
10307
  } catch {
9277
10308
  return false;
9278
10309
  }
@@ -9286,10 +10317,10 @@ function checkEntryPoints(root) {
9286
10317
  "pi.js",
9287
10318
  "pair-tui.js"
9288
10319
  ];
9289
- const missing = entryPoints.filter((entry) => !existsSync8(join12(root, "dist", entry)));
10320
+ const missing = entryPoints.filter((entry) => !existsSync10(join14(root, "dist", entry)));
9290
10321
  const present = entryPoints.filter((entry) => !missing.includes(entry));
9291
- const notExecutable = present.filter((entry) => !isExecutable(join12(root, "dist", entry)));
9292
- const missingShebang = present.filter((entry) => !hasShebang(join12(root, "dist", entry)));
10322
+ const notExecutable = present.filter((entry) => !isExecutable(join14(root, "dist", entry)));
10323
+ const missingShebang = present.filter((entry) => !hasShebang(join14(root, "dist", entry)));
9293
10324
  const problems = [];
9294
10325
  if (missing.length > 0) {
9295
10326
  problems.push(`missing: ${missing.join(", ")}`);
@@ -9327,11 +10358,11 @@ function checkTrace(config) {
9327
10358
  if (!config.trace) {
9328
10359
  return null;
9329
10360
  }
9330
- const tracePath = join12(stateDir(), "trace.log");
9331
- if (!existsSync8(tracePath)) {
10361
+ const tracePath = join14(stateDir(), "trace.log");
10362
+ if (!existsSync10(tracePath)) {
9332
10363
  return { name: "trace log", passed: true, detail: "tracing is on; no log written yet" };
9333
10364
  }
9334
- const lines = readFileSync5(tracePath, "utf-8").split("\n").filter((line2) => line2 !== "");
10365
+ const lines = readFileSync7(tracePath, "utf-8").split("\n").filter((line2) => line2 !== "");
9335
10366
  const tail = lines.slice(-10);
9336
10367
  return {
9337
10368
  name: "trace log",
@@ -9339,12 +10370,14 @@ function checkTrace(config) {
9339
10370
  detail: tail.length === 0 ? "empty" : tail.join(" | ")
9340
10371
  };
9341
10372
  }
9342
- var defaultOpenTty = () => openSync2("/dev/tty", "r+");
10373
+ var defaultOpenTty = () => openSync3("/dev/tty", "r+");
10374
+ var PROBE_NAME = ".pair-mode-doctor-probe";
9343
10375
  async function checkSession(config, directory, probe) {
9344
- const path = sessionSocketPath(directory);
10376
+ const probeFile = join14(directory, PROBE_NAME);
10377
+ const path = resolveSocketPath(probeFile, currentSessionKey()) ?? sessionSocketPath(directory);
9345
10378
  const name = `session: ${path}`;
9346
10379
  const wanted = config.transport === "session";
9347
- if (!existsSync8(path)) {
10380
+ if (!existsSync10(path)) {
9348
10381
  return {
9349
10382
  name,
9350
10383
  passed: !wanted,
@@ -9352,11 +10385,12 @@ async function checkSession(config, directory, probe) {
9352
10385
  warnOnly: !wanted
9353
10386
  };
9354
10387
  }
9355
- const alive = await (probe ?? probeSocket)(path);
9356
- if (alive) {
10388
+ const result = await (probe ?? probeSession)(path);
10389
+ if (result.status !== "refused") {
9357
10390
  return { name, passed: true, detail: "a watcher is attached" };
9358
10391
  }
9359
- return { name, passed: false, detail: `stale socket, remove it with: rm ${path}` };
10392
+ removeSession(basename6(path, ".sock"));
10393
+ return { name, passed: true, detail: "removed a stale socket", warnOnly: true };
9360
10394
  }
9361
10395
  async function runDoctor(options = {}) {
9362
10396
  const home = options.homeDir ?? homedir4();
@@ -9373,7 +10407,7 @@ async function runDoctor(options = {}) {
9373
10407
  ...checkClis(home, root),
9374
10408
  checkEntryPoints(root),
9375
10409
  checkShiki(options.resolvesShiki),
9376
- await checkSession(config, options.directory ?? process.cwd(), options.probeSocket)
10410
+ await checkSession(config, options.directory ?? process.cwd(), options.probeSession)
9377
10411
  ];
9378
10412
  const traceCheck = checkTrace(config);
9379
10413
  if (traceCheck !== null) {
@@ -9593,95 +10627,6 @@ async function runSetup(options = {}) {
9593
10627
  }
9594
10628
  }
9595
10629
 
9596
- // src/cli/toggle.ts
9597
- import { spawn } from "node:child_process";
9598
- import { existsSync as existsSync9, readFileSync as readFileSync6, unlinkSync as unlinkSync3 } from "node:fs";
9599
- var POLL_MS = 100;
9600
- var POLL_ATTEMPTS = 60;
9601
- function sleep(ms) {
9602
- return new Promise((done) => setTimeout(done, ms));
9603
- }
9604
- function readLink(directory) {
9605
- const path = sessionUrlPath(directory);
9606
- if (!existsSync9(path)) {
9607
- return null;
9608
- }
9609
- try {
9610
- const parsed = JSON.parse(readFileSync6(path, "utf-8"));
9611
- if (isRecord(parsed) && typeof parsed["url"] === "string" && typeof parsed["pid"] === "number") {
9612
- return { url: parsed["url"], pid: parsed["pid"] };
9613
- }
9614
- } catch {
9615
- return null;
9616
- }
9617
- return null;
9618
- }
9619
- async function waitForLink(directory) {
9620
- const attempts = Array.from({ length: POLL_ATTEMPTS }, (_, index) => index);
9621
- for (const _attempt of attempts) {
9622
- const link = readLink(directory);
9623
- if (link !== null) {
9624
- return link;
9625
- }
9626
- await sleep(POLL_MS);
9627
- }
9628
- return null;
9629
- }
9630
- function stopLink(directory) {
9631
- const link = readLink(directory);
9632
- if (link === null) {
9633
- return false;
9634
- }
9635
- try {
9636
- process.kill(link.pid, "SIGTERM");
9637
- } catch {
9638
- }
9639
- try {
9640
- unlinkSync3(sessionUrlPath(directory));
9641
- } catch {
9642
- }
9643
- return true;
9644
- }
9645
- function pairOn(directory) {
9646
- enable(directory);
9647
- return `pair mode ON for ${directory}`;
9648
- }
9649
- async function pairOnWeb(directory, cliPath) {
9650
- enable(directory);
9651
- const existing = readLink(directory);
9652
- if (existing !== null) {
9653
- return `pair mode ON for ${directory}
9654
- ${existing.url}`;
9655
- }
9656
- const child = spawn(process.execPath, [cliPath, "watch", "--web", directory], {
9657
- detached: true,
9658
- stdio: "ignore"
9659
- });
9660
- child.unref();
9661
- const link = await waitForLink(directory);
9662
- if (link === null) {
9663
- return `pair mode ON for ${directory}
9664
- the web watcher did not report a link`;
9665
- }
9666
- return `pair mode ON for ${directory}
9667
- ${link.url}`;
9668
- }
9669
- function pairOff(directory) {
9670
- disable(directory);
9671
- const stopped = stopLink(directory);
9672
- return stopped ? `pair mode OFF for ${directory} (web watcher stopped)` : `pair mode OFF for ${directory}`;
9673
- }
9674
- function pairStatus(directory) {
9675
- const on = existsSync9(flagPath(directory));
9676
- const link = readLink(directory);
9677
- const state = `pair mode ${on ? "ON" : "OFF"} for ${directory}`;
9678
- return link === null ? state : `${state}
9679
- ${link.url}`;
9680
- }
9681
-
9682
- // src/cli/watch/watch.ts
9683
- import { createConnection as createConnection2 } from "node:net";
9684
-
9685
10630
  // node_modules/.pnpm/diff@9.0.0/node_modules/diff/libesm/diff/base.js
9686
10631
  var Diff = class {
9687
10632
  diff(oldStr, newStr, options = {}) {
@@ -10167,7 +11112,7 @@ function diffArrays(oldArr, newArr, options) {
10167
11112
  }
10168
11113
 
10169
11114
  // src/tui/paint/layout.ts
10170
- import { basename as basename3 } from "node:path";
11115
+ import { basename as basename7 } from "node:path";
10171
11116
 
10172
11117
  // src/core/diff/diff.ts
10173
11118
  function mergeChangedPair(first, second, i, j) {
@@ -10227,7 +11172,7 @@ var TAB_WIDTH = 8;
10227
11172
  var CONTROL_BYTE_CEILING = 32;
10228
11173
  var CONTROL_BYTE_PLACEHOLDER = "?";
10229
11174
  function sanitizeLine(text) {
10230
- const scan = text.split("").reduce(
11175
+ const scan2 = text.split("").reduce(
10231
11176
  (state, char) => {
10232
11177
  if (char === " ") {
10233
11178
  const width = TAB_WIDTH - state.column % TAB_WIDTH;
@@ -10243,7 +11188,7 @@ function sanitizeLine(text) {
10243
11188
  },
10244
11189
  { output: "", column: 0 }
10245
11190
  );
10246
- return scan.output;
11191
+ return scan2.output;
10247
11192
  }
10248
11193
  function buildRows(before, after) {
10249
11194
  return opcodes(before, after).flatMap((opcode) => {
@@ -10890,7 +11835,7 @@ function paintFoldRow(fold2, width, truecolor, cursorRow) {
10890
11835
  return fg(color, truecolor) + " ".repeat(leftPadding) + label + " ".repeat(rightPadding) + RESET;
10891
11836
  }
10892
11837
  function paintHeader(path, addCount, delCount, width, truecolor) {
10893
- const prefix = `pair mode\u2502${basename3(path)}\u2502`;
11838
+ const prefix = `pair mode\u2502${basename7(path)}\u2502`;
10894
11839
  const addText = `+${addCount}`;
10895
11840
  const delText = `-${delCount}`;
10896
11841
  const used = prefix.length + addText.length + HEADER_COUNT_GAP_WIDTH + delText.length;
@@ -11412,7 +12357,7 @@ function indentWidth(line2) {
11412
12357
  }
11413
12358
  function changedSpans(before, after) {
11414
12359
  const chunks = diffWordsWithSpace(before, after);
11415
- const scan = chunks.reduce(
12360
+ const scan2 = chunks.reduce(
11416
12361
  (state, chunk) => {
11417
12362
  const length = chunk.value.length;
11418
12363
  if (chunk.removed === true) {
@@ -11438,7 +12383,7 @@ function changedSpans(before, after) {
11438
12383
  },
11439
12384
  { left: [], right: [], sharedLength: 0, leftCursor: 0, rightCursor: 0 }
11440
12385
  );
11441
- const { left, right, sharedLength } = scan;
12386
+ const { left, right, sharedLength } = scan2;
11442
12387
  const indent = Math.min(indentWidth(before), indentWidth(after));
11443
12388
  const longer = Math.max(before.length, after.length) - indent;
11444
12389
  const sharedFraction = longer <= 0 ? 1 : (sharedLength - indent) / longer;
@@ -11461,7 +12406,7 @@ function decideLayout(options) {
11461
12406
  function layoutStatusMessage(options) {
11462
12407
  return decideLayout(options).overrideReason;
11463
12408
  }
11464
- function paint(options) {
12409
+ function paint2(options) {
11465
12410
  const { layout } = decideLayout(options);
11466
12411
  return layout === "unified" ? paintUnified(options) : paintSplit(options);
11467
12412
  }
@@ -11671,7 +12616,8 @@ function splitInput(chunk) {
11671
12616
  }
11672
12617
 
11673
12618
  // src/tui/notes/notes.ts
11674
- import { writeFileSync as writeFileSync6 } from "node:fs";
12619
+ import { writeFileSync as writeFileSync7 } from "node:fs";
12620
+ var OWNER_ONLY_FILE2 = 384;
11675
12621
  function rangeOf(selection) {
11676
12622
  const reversed = selection.anchorRow > selection.headRow || selection.anchorRow === selection.headRow && selection.anchorColumn > selection.headColumn;
11677
12623
  if (!reversed) {
@@ -11742,7 +12688,10 @@ function toQuestions(notes) {
11742
12688
  }
11743
12689
  function writeResult(path, notes) {
11744
12690
  try {
11745
- writeFileSync6(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
+ });
11746
12695
  } catch {
11747
12696
  return;
11748
12697
  }
@@ -12039,7 +12988,7 @@ function runTui(options, io, abort) {
12039
12988
  let finished = false;
12040
12989
  const repaint = () => {
12041
12990
  const { width, height } = io.size();
12042
- const result = paint({
12991
+ const result = paint2({
12043
12992
  model: state.model,
12044
12993
  width,
12045
12994
  height,
@@ -12200,6 +13149,9 @@ function createWatchIo() {
12200
13149
  });
12201
13150
  process.stdout.on("resize", () => resizeHandler?.());
12202
13151
  return {
13152
+ isTty() {
13153
+ return process.stdin.isTTY === true;
13154
+ },
12203
13155
  onKey(handler) {
12204
13156
  keyHandler = handler;
12205
13157
  },
@@ -12232,23 +13184,15 @@ function createWatchIo() {
12232
13184
  }
12233
13185
 
12234
13186
  // src/cli/watch/watch.ts
12235
- var CLEAR_SCREEN = "\x1B[2J\x1B[H";
12236
- var QUIT_KEYS = ["q", "", ""];
13187
+ var CLEAR_SCREEN2 = "\x1B[2J\x1B[H";
13188
+ var QUIT_KEYS2 = ["q", "", ""];
12237
13189
  function reportErrors(errors) {
12238
13190
  errors.forEach((error) => process.stderr.write(`pair-mode: ${error.message}
12239
13191
  `));
12240
13192
  }
12241
13193
  function paintIdle(io, status, truecolor) {
12242
13194
  const { width } = io.size();
12243
- io.write(CLEAR_SCREEN + renderIdle(status, width, truecolor).join("\r\n") + "\r\n");
12244
- }
12245
- function connectSelf(socketPath) {
12246
- return new Promise((resolve3, reject) => {
12247
- const socket = createConnection2(socketPath);
12248
- socket.setEncoding("utf-8");
12249
- socket.once("error", reject);
12250
- socket.once("connect", () => resolve3(socket));
12251
- });
13195
+ io.write(CLEAR_SCREEN2 + renderIdle(status, width, truecolor).join("\r\n") + "\r\n");
12252
13196
  }
12253
13197
  async function optionsFor(review, config, io, resultFile) {
12254
13198
  const truecolor = supportsTruecolor(process.env);
@@ -12275,24 +13219,29 @@ async function optionsFor(review, config, io, resultFile) {
12275
13219
  };
12276
13220
  }
12277
13221
  async function runWatch(options, config) {
12278
- const socketPath = options.socketPath ?? sessionSocketPath(options.directory);
13222
+ const socketPath = watchSocketPath(options.directory, options.sessionKey, options.socketPath);
12279
13223
  let errors = [];
12280
- const server = await startSessionServer({
13224
+ const owns = (await probeSession(socketPath)).status === "refused";
13225
+ const host = owns ? await ownerHost({
12281
13226
  socketPath,
13227
+ client: "tui",
13228
+ record: buildSessionRecord(options, socketPath),
12282
13229
  onError: (error) => {
12283
13230
  errors = [...errors, error];
12284
13231
  }
12285
- });
13232
+ }) : await viewerHost({ socketPath, client: "tui" });
13233
+ await sweepDeadSessions();
12286
13234
  const truecolor = supportsTruecolor(process.env);
12287
13235
  const io = options.io ?? createWatchIo();
12288
- const status = () => ({
12289
- directory: options.directory,
12290
- socketPath,
12291
- clients: server.clientCount(),
12292
- waiting: server.waitingDepth()
12293
- });
12294
- const client = await connectSelf(socketPath);
12295
- const readLines = createLineReader();
13236
+ const status = () => {
13237
+ const counts = host.counts();
13238
+ return {
13239
+ directory: options.directory,
13240
+ socketPath,
13241
+ clients: counts.clients,
13242
+ waiting: counts.waiting
13243
+ };
13244
+ };
12296
13245
  const pending = [];
12297
13246
  const cancelled = /* @__PURE__ */ new Set();
12298
13247
  const aborts = /* @__PURE__ */ new Map();
@@ -12306,37 +13255,45 @@ async function runWatch(options, config) {
12306
13255
  };
12307
13256
  const listenIdle = () => {
12308
13257
  io.onKey((chunk) => {
12309
- if (QUIT_KEYS.includes(chunk)) {
13258
+ if (QUIT_KEYS2.includes(chunk)) {
12310
13259
  quitting = true;
12311
13260
  nudge();
12312
13261
  }
12313
13262
  });
12314
13263
  io.onResize(() => paintIdle(io, status(), truecolor));
12315
13264
  };
12316
- client.on("data", (chunk) => {
12317
- readLines(chunk).forEach((line2) => {
12318
- const message = decodeLine(line2);
12319
- if (message?.type === "review") {
12320
- pending.push(message);
12321
- nudge();
12322
- return;
12323
- }
12324
- if (message?.type === "cancel") {
12325
- cancelled.add(message.id);
12326
- aborts.get(message.id)?.abort();
12327
- nudge();
12328
- }
12329
- });
13265
+ host.onReview((review) => {
13266
+ pending.push(review);
13267
+ nudge();
12330
13268
  });
12331
- server.onChange(() => {
13269
+ host.onCancel((id) => {
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
+ }
13281
+ });
13282
+ host.onChange(() => {
12332
13283
  if (!busy && !quitting) {
12333
13284
  paintIdle(io, status(), truecolor);
12334
13285
  }
12335
13286
  });
12336
- client.write(encode({ type: "attach", client: "tui" }));
13287
+ host.onClose(() => {
13288
+ quitting = true;
13289
+ aborts.forEach((abort) => abort.abort());
13290
+ errors = [...errors, new Error("the session owner exited")];
13291
+ nudge();
13292
+ });
12337
13293
  while (!quitting) {
12338
13294
  const review = pending.shift();
12339
13295
  if (review === void 0) {
13296
+ host.refreshCounts();
12340
13297
  listenIdle();
12341
13298
  paintIdle(io, status(), truecolor);
12342
13299
  await new Promise((resolve3) => {
@@ -12352,8 +13309,7 @@ async function runWatch(options, config) {
12352
13309
  const tuiOptions = await optionsFor(review, config, io, resultFile);
12353
13310
  const result = await runTui(tuiOptions, io, abort.signal);
12354
13311
  if (!cancelled.has(review.id)) {
12355
- const questions = result.quit === "send" ? result.questions : [];
12356
- client.write(encode({ type: "verdict", id: review.id, questions }));
13312
+ host.verdict(review.id, result.quit === "send" ? result.questions : []);
12357
13313
  }
12358
13314
  } finally {
12359
13315
  aborts.delete(review.id);
@@ -12362,10 +13318,9 @@ async function runWatch(options, config) {
12362
13318
  busy = false;
12363
13319
  }
12364
13320
  }
12365
- io.write(CLEAR_SCREEN);
13321
+ io.write(CLEAR_SCREEN2);
12366
13322
  io.shutdown();
12367
- client.destroy();
12368
- await server.close();
13323
+ await host.close();
12369
13324
  reportErrors(errors);
12370
13325
  return 0;
12371
13326
  }
@@ -12601,7 +13556,7 @@ function runConfig(args, path) {
12601
13556
  // src/web/server.ts
12602
13557
  import { createServer as createServer2 } from "node:http";
12603
13558
  import { randomBytes as randomBytes4 } from "node:crypto";
12604
- import { existsSync as existsSync10, readFileSync as readFileSync7 } from "node:fs";
13559
+ import { existsSync as existsSync11, readFileSync as readFileSync8 } from "node:fs";
12605
13560
  import { fileURLToPath as fileURLToPath4 } from "node:url";
12606
13561
 
12607
13562
  // src/web/client/bundle.ts
@@ -13532,12 +14487,17 @@ function post(payload) {
13532
14487
  .then((response) => {
13533
14488
  // A refused verdict means another client already answered, so the notes typed here can never land.
13534
14489
  if (!response.ok) {
13535
- clearReview();
14490
+ if (review !== null && review.id === payload.id) {
14491
+ clearReview();
14492
+ }
13536
14493
  warn("this review was already answered elsewhere - your notes were not sent");
13537
14494
  return;
13538
14495
  }
13539
14496
 
13540
- 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
+ }
13541
14501
  })
13542
14502
  .catch(() => {
13543
14503
  sendButton.disabled = notes.length === 0;
@@ -13552,10 +14512,16 @@ approveButton.addEventListener("click", () => post({ id: review.id, notes: [] })
13552
14512
  const events = new EventSource("/r/" + token + "/events");
13553
14513
 
13554
14514
  events.addEventListener("review", (event) => {
13555
- review = JSON.parse(event.data);
13556
- notes = [];
13557
- expanded = new Set();
13558
- 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;
13559
14525
  render();
13560
14526
  });
13561
14527
 
@@ -13668,7 +14634,7 @@ var MAX_BODY_BYTES = 1e6;
13668
14634
  function readAsset(name) {
13669
14635
  const bundled = fileURLToPath4(new URL(`../assets/${name}`, import.meta.url));
13670
14636
  const source = fileURLToPath4(new URL(`../../assets/${name}`, import.meta.url));
13671
- return readFileSync7(existsSync10(bundled) ? bundled : source);
14637
+ return readFileSync8(existsSync11(bundled) ? bundled : source);
13672
14638
  }
13673
14639
  var IMAGES = {
13674
14640
  "favicon.png": readAsset("favicon.png"),
@@ -13751,7 +14717,7 @@ function startWebServer(options) {
13751
14717
  const token = options.token ?? defaultToken();
13752
14718
  const base = `/r/${token}`;
13753
14719
  const viewers = /* @__PURE__ */ new Set();
13754
- let current = null;
14720
+ let pending = [];
13755
14721
  function sendEvent(response, event, data) {
13756
14722
  response.write(`event: ${event}
13757
14723
  data: ${data}
@@ -13768,14 +14734,30 @@ data: ${data}
13768
14734
  response.write(": open\n\n");
13769
14735
  viewers.add(response);
13770
14736
  response.on("close", () => viewers.delete(response));
13771
- if (current !== null) {
13772
- sendEvent(response, "review", JSON.stringify(current));
14737
+ const open = pending[0];
14738
+ if (!open) {
14739
+ return;
13773
14740
  }
14741
+ sendEvent(response, "review", JSON.stringify(open));
13774
14742
  }
13775
14743
  function broadcastCancel(id) {
13776
14744
  const data = JSON.stringify({ id });
13777
14745
  viewers.forEach((viewer) => sendEvent(viewer, "cancel", data));
13778
14746
  }
14747
+ function broadcastReview(review) {
14748
+ const data = JSON.stringify(review);
14749
+ viewers.forEach((viewer) => sendEvent(viewer, "review", data));
14750
+ }
14751
+ function retire(id) {
14752
+ const wasOpen = pending[0]?.id === id;
14753
+ pending = pending.filter((review) => review.id !== id);
14754
+ broadcastCancel(id);
14755
+ const next = pending[0];
14756
+ if (!wasOpen || !next) {
14757
+ return;
14758
+ }
14759
+ broadcastReview(next);
14760
+ }
13779
14761
  async function handleVerdict(request, response) {
13780
14762
  const result = await readBody(request);
13781
14763
  if (result.kind === "too-large") {
@@ -13791,13 +14773,12 @@ data: ${data}
13791
14773
  response.writeHead(BAD_REQUEST).end();
13792
14774
  return;
13793
14775
  }
13794
- const answered = current;
13795
- if (answered === null || answered.id !== verdict.id) {
14776
+ const answered = pending[0];
14777
+ if (!answered || answered.id !== verdict.id) {
13796
14778
  response.writeHead(CONFLICT, { "content-type": "application/json" }).end("{}");
13797
14779
  return;
13798
14780
  }
13799
- current = null;
13800
- broadcastCancel(verdict.id);
14781
+ retire(verdict.id);
13801
14782
  options.onVerdict(verdict.id, webNotesToQuestions(answered, verdict.notes));
13802
14783
  response.writeHead(OK, { "content-type": "application/json" }).end("{}");
13803
14784
  }
@@ -13809,7 +14790,7 @@ data: ${data}
13809
14790
  return;
13810
14791
  }
13811
14792
  const image = url.startsWith(`${base}/`) ? IMAGES[url.slice(base.length + 1)] : void 0;
13812
- if (image !== void 0 && request.method === "GET") {
14793
+ if (image && request.method === "GET") {
13813
14794
  response.writeHead(OK, { "content-type": "image/png" });
13814
14795
  response.end(image);
13815
14796
  return;
@@ -13838,21 +14819,20 @@ data: ${data}
13838
14819
  return viewers.size;
13839
14820
  },
13840
14821
  offer(review) {
13841
- current = review;
13842
- const data = JSON.stringify(review);
13843
- viewers.forEach((viewer) => sendEvent(viewer, "review", data));
14822
+ pending = [...pending, review];
14823
+ if (pending.length === 1) {
14824
+ broadcastReview(review);
14825
+ }
13844
14826
  },
13845
14827
  // A withdrawal for an older review must leave the one now open alone.
13846
14828
  withdraw(id) {
13847
- if (current?.id === id) {
13848
- current = null;
13849
- }
13850
- broadcastCancel(id);
14829
+ retire(id);
13851
14830
  },
13852
14831
  close() {
13853
14832
  return new Promise((done) => {
13854
14833
  viewers.forEach((viewer) => viewer.end());
13855
14834
  viewers.clear();
14835
+ pending = [];
13856
14836
  server.close(() => done());
13857
14837
  });
13858
14838
  }
@@ -13862,8 +14842,7 @@ data: ${data}
13862
14842
  }
13863
14843
 
13864
14844
  // src/web/watch.ts
13865
- import { createConnection as createConnection3 } from "node:net";
13866
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7 } from "node:fs";
14845
+ import { existsSync as existsSync12, mkdirSync as mkdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "node:fs";
13867
14846
  import { dirname as dirname8 } from "node:path";
13868
14847
 
13869
14848
  // src/web/review.ts
@@ -13892,73 +14871,105 @@ async function toWebReview(review, config) {
13892
14871
  }
13893
14872
 
13894
14873
  // src/web/watch.ts
14874
+ var OWNER_ONLY_DIR3 = 448;
14875
+ var OWNER_ONLY_FILE3 = 384;
13895
14876
  function publishUrl(path, url) {
13896
- mkdirSync7(dirname8(path), { recursive: true });
13897
- writeFileSync7(path, JSON.stringify({ url, pid: process.pid }) + "\n", "utf-8");
13898
- }
13899
- function connectSelf2(socketPath) {
13900
- return new Promise((resolve3, reject) => {
13901
- const socket = createConnection3(socketPath);
13902
- socket.setEncoding("utf-8");
13903
- socket.once("error", reject);
13904
- socket.once("connect", () => resolve3(socket));
14877
+ mkdirSync7(dirname8(path), { recursive: true, mode: OWNER_ONLY_DIR3 });
14878
+ writeFileSync8(path, JSON.stringify({ url, pid: process.pid }) + "\n", {
14879
+ encoding: "utf-8",
14880
+ mode: OWNER_ONLY_FILE3
13905
14881
  });
13906
14882
  }
14883
+ function linkPid(path) {
14884
+ try {
14885
+ const parsed = JSON.parse(readFileSync9(path, "utf-8"));
14886
+ return isRecord(parsed) && typeof parsed["pid"] === "number" ? parsed["pid"] : null;
14887
+ } catch {
14888
+ return null;
14889
+ }
14890
+ }
13907
14891
  async function startWebWatch(options, config) {
13908
- const socketPath = options.socketPath ?? sessionSocketPath(options.directory);
13909
- const session = await startSessionServer({ socketPath });
13910
- const client = await connectSelf2(socketPath);
14892
+ const socketPath = watchSocketPath(options.directory, options.sessionKey, options.socketPath);
14893
+ const owns = (await probeSession(socketPath)).status === "refused";
14894
+ const host = owns ? await ownerHost({
14895
+ socketPath,
14896
+ client: "web",
14897
+ record: buildSessionRecord(options, socketPath)
14898
+ }) : await viewerHost({ socketPath, client: "web" });
14899
+ const ownedIds = /* @__PURE__ */ new Set();
13911
14900
  const web = await startWebServer({
13912
14901
  port: options.port,
13913
14902
  token: options.token,
13914
14903
  layout: config.layout,
13915
14904
  onVerdict(id, questions) {
13916
- client.write(encode({ type: "verdict", id, questions }));
14905
+ ownedIds.delete(id);
14906
+ host.verdict(id, questions);
13917
14907
  }
13918
14908
  });
13919
- const readLines = createLineReader();
13920
- let ownedId = null;
13921
- client.on("data", (chunk) => {
13922
- readLines(chunk).forEach((line2) => {
13923
- const message = decodeLine(line2);
13924
- if (message?.type === "review") {
13925
- const id = message.id;
13926
- ownedId = id;
13927
- void toWebReview(message, config).then((review) => {
13928
- if (ownedId === id) {
13929
- web.offer(review);
13930
- }
13931
- }).catch(() => {
13932
- if (ownedId === id) {
13933
- client.write(encode({ type: "verdict", id, questions: [] }));
13934
- }
13935
- });
13936
- return;
14909
+ host.onReview((message) => {
14910
+ const id = message.id;
14911
+ ownedIds.add(id);
14912
+ void toWebReview(message, config).then((review) => {
14913
+ if (ownedIds.has(id)) {
14914
+ web.offer(review);
13937
14915
  }
13938
- if (message?.type === "cancel") {
13939
- if (ownedId === message.id) {
13940
- ownedId = null;
13941
- }
13942
- web.withdraw(message.id);
14916
+ }).catch(() => {
14917
+ if (ownedIds.delete(id)) {
14918
+ host.verdict(id, []);
13943
14919
  }
13944
14920
  });
13945
14921
  });
13946
- const urlPath = sessionUrlPath(options.directory);
13947
- publishUrl(urlPath, web.url);
13948
- client.write(encode({ type: "attach", client: "web" }));
14922
+ host.onCancel((id) => {
14923
+ ownedIds.delete(id);
14924
+ web.withdraw(id);
14925
+ });
14926
+ const urlPath = watchUrlPath(options.directory, options.sessionKey);
14927
+ const published = owns || !existsSync12(urlPath);
14928
+ if (published) {
14929
+ publishUrl(urlPath, web.url);
14930
+ }
13949
14931
  return {
13950
14932
  url: web.url,
13951
14933
  socketPath,
13952
14934
  port: web.port,
14935
+ owns,
13953
14936
  async close() {
13954
- removeQuietly(urlPath);
13955
- client.destroy();
14937
+ if (published && linkPid(urlPath) === process.pid) {
14938
+ removeQuietly(urlPath);
14939
+ }
13956
14940
  await web.close();
13957
- await session.close();
14941
+ await host.close();
13958
14942
  }
13959
14943
  };
13960
14944
  }
13961
14945
 
14946
+ // src/cli/watch-target.ts
14947
+ function usesWebWatcher(target, webEnabled) {
14948
+ return target.web || webEnabled;
14949
+ }
14950
+ async function watchSession(target) {
14951
+ const { directory, sessionKey: sessionKey2, socketPath } = target;
14952
+ const { config, errors } = loadConfig();
14953
+ errors.forEach((error) => console.error(`config ${error.path}: ${error.message}`));
14954
+ if (!usesWebWatcher(target, config.web.enabled)) {
14955
+ return await runWatch({ directory, sessionKey: sessionKey2, socketPath }, config);
14956
+ }
14957
+ const watcher = await startWebWatch(
14958
+ { directory, sessionKey: sessionKey2, socketPath, port: config.web.port },
14959
+ config
14960
+ );
14961
+ console.log(`pair mode is watching ${directory}`);
14962
+ console.log(watcher.url);
14963
+ await new Promise((done) => {
14964
+ const stop = () => {
14965
+ void watcher.close().then(done);
14966
+ };
14967
+ process.once("SIGINT", stop);
14968
+ process.once("SIGTERM", stop);
14969
+ });
14970
+ return 0;
14971
+ }
14972
+
13962
14973
  // src/cli/index.ts
13963
14974
  var USAGE = `pair-mode <command> [directory]
13964
14975
 
@@ -13971,16 +14982,21 @@ Commands:
13971
14982
  on [dir] turn pair mode on for a directory (default: cwd)
13972
14983
  on --web [dir] turn pair mode on and serve the review in a browser
13973
14984
  off [dir] turn pair mode off for a directory (default: cwd)
14985
+ toggle [dir] flip pair mode for a directory (default: cwd)
14986
+ toggle --web [dir] flip pair mode, and serve the review in a browser when it turns on
13974
14987
  status [dir] report pair mode status for a directory (default: cwd)
13975
14988
  watch [dir] review edits in this terminal (default: cwd)
13976
14989
  watch --web [dir] serve the review in a browser and print the link
14990
+ watch <id> review edits for one session (see: pair-mode sessions)
14991
+ sessions list every live pair mode session
14992
+ connect pick a session from a list and watch it
13977
14993
  --version print the installed version
13978
14994
  --help print this message
13979
14995
  `;
13980
14996
  function readVersion() {
13981
- const pkgPath = join13(installRoot(), "package.json");
14997
+ const pkgPath = join15(installRoot(), "package.json");
13982
14998
  try {
13983
- const raw = JSON.parse(readFileSync8(pkgPath, "utf-8"));
14999
+ const raw = JSON.parse(readFileSync10(pkgPath, "utf-8"));
13984
15000
  if (isRecord(raw) && typeof raw["version"] === "string") {
13985
15001
  return raw["version"];
13986
15002
  }
@@ -14000,6 +15016,33 @@ function parseDirectoryArgs(args, allowedFlags) {
14000
15016
  unknownFlag: flags.find((flag) => !allowedFlags.includes(flag)) ?? null
14001
15017
  };
14002
15018
  }
15019
+ var SESSION_KEY_PATTERN = /^s-[0-9a-f]{8}$/;
15020
+ var SESSION_KEY_PREFIX = "s-";
15021
+ function parseWatchArgs(args) {
15022
+ const flags = args.filter(isFlag);
15023
+ const target = args.find((entry) => !isFlag(entry));
15024
+ const looksLikeKey = target !== void 0 && target.startsWith(SESSION_KEY_PREFIX);
15025
+ const isKey = looksLikeKey && SESSION_KEY_PATTERN.test(target);
15026
+ return {
15027
+ sessionKey: isKey ? target : void 0,
15028
+ malformedKey: looksLikeKey && !isKey ? target : null,
15029
+ directory: isKey ? process.cwd() : resolve2(target ?? process.cwd()),
15030
+ web: flags.includes("--web"),
15031
+ unknownFlag: flags.find((flag) => flag !== "--web") ?? null
15032
+ };
15033
+ }
15034
+ function reportExtraArgs(command, args) {
15035
+ const extra = args[0];
15036
+ if (extra === void 0) {
15037
+ return null;
15038
+ }
15039
+ if (isFlag(extra)) {
15040
+ return reportUnknownFlag(command, extra);
15041
+ }
15042
+ console.error(`unexpected argument for ${command}: ${extra}`);
15043
+ console.error(USAGE);
15044
+ return 1;
15045
+ }
14003
15046
  function reportUnknownFlag(command, flag) {
14004
15047
  console.error(`unknown option for ${command}: ${flag}`);
14005
15048
  console.error(USAGE);
@@ -14034,11 +15077,13 @@ async function main() {
14034
15077
  if (parsed.unknownFlag !== null) {
14035
15078
  return reportUnknownFlag(command, parsed.unknownFlag);
14036
15079
  }
15080
+ await sweepDeadSessions();
15081
+ const key = currentSessionKey();
14037
15082
  if (parsed.web) {
14038
- console.log(await pairOnWeb(parsed.directory, process.argv[1] ?? ""));
15083
+ console.log(await pairOnWeb(parsed.directory, process.argv[1] ?? "", key));
14039
15084
  return 0;
14040
15085
  }
14041
- console.log(pairOn(parsed.directory));
15086
+ console.log(pairOn(parsed.directory, key));
14042
15087
  return 0;
14043
15088
  }
14044
15089
  if (command === "off") {
@@ -14046,7 +15091,17 @@ async function main() {
14046
15091
  if (parsed.unknownFlag !== null) {
14047
15092
  return reportUnknownFlag(command, parsed.unknownFlag);
14048
15093
  }
14049
- console.log(pairOff(parsed.directory));
15094
+ console.log(pairOff(parsed.directory, currentSessionKey()));
15095
+ return 0;
15096
+ }
15097
+ if (command === "toggle") {
15098
+ const parsed = parseDirectoryArgs(process.argv.slice(3), ["--web"]);
15099
+ if (parsed.unknownFlag !== null) {
15100
+ return reportUnknownFlag(command, parsed.unknownFlag);
15101
+ }
15102
+ console.log(
15103
+ await pairToggle(parsed.directory, process.argv[1] ?? "", parsed.web, currentSessionKey())
15104
+ );
14050
15105
  return 0;
14051
15106
  }
14052
15107
  if (command === "status") {
@@ -14054,32 +15109,52 @@ async function main() {
14054
15109
  if (parsed.unknownFlag !== null) {
14055
15110
  return reportUnknownFlag(command, parsed.unknownFlag);
14056
15111
  }
14057
- console.log(pairStatus(parsed.directory));
15112
+ console.log(pairStatus(parsed.directory, currentSessionKey()));
14058
15113
  return 0;
14059
15114
  }
14060
15115
  if (command === "watch") {
14061
- const parsed = parseDirectoryArgs(process.argv.slice(3), ["--web"]);
15116
+ const parsed = parseWatchArgs(process.argv.slice(3));
14062
15117
  if (parsed.unknownFlag !== null) {
14063
15118
  return reportUnknownFlag(command, parsed.unknownFlag);
14064
15119
  }
14065
- const wantsWeb = parsed.web;
14066
- const directory = parsed.directory;
14067
- const { config, errors } = loadConfig();
14068
- errors.forEach((error) => console.error(`config ${error.path}: ${error.message}`));
14069
- if (!wantsWeb && !config.web.enabled) {
14070
- return runWatch({ directory }, config);
14071
- }
14072
- const watcher = await startWebWatch({ directory, port: config.web.port }, config);
14073
- console.log(`pair mode is watching ${directory}`);
14074
- console.log(watcher.url);
14075
- await new Promise((done) => {
14076
- const stop = () => {
14077
- void watcher.close().then(done);
14078
- };
14079
- process.once("SIGINT", stop);
14080
- process.once("SIGTERM", stop);
15120
+ if (parsed.malformedKey !== null) {
15121
+ console.error(`malformed session id: ${parsed.malformedKey}`);
15122
+ console.error(
15123
+ "an id is s- followed by eight hex characters; run pair-mode sessions to list them"
15124
+ );
15125
+ return 1;
15126
+ }
15127
+ return await watchSession({
15128
+ directory: parsed.directory,
15129
+ sessionKey: parsed.sessionKey,
15130
+ web: parsed.web
15131
+ });
15132
+ }
15133
+ if (command === "sessions") {
15134
+ const rejected = reportExtraArgs(command, process.argv.slice(3));
15135
+ if (rejected !== null) {
15136
+ return rejected;
15137
+ }
15138
+ const result = await listSessions();
15139
+ console.log(result.text);
15140
+ return result.exitCode;
15141
+ }
15142
+ if (command === "connect") {
15143
+ const rejected = reportExtraArgs(command, process.argv.slice(3));
15144
+ if (rejected !== null) {
15145
+ return rejected;
15146
+ }
15147
+ const result = await runConnect(createWatchIo());
15148
+ const chosen = result.selected;
15149
+ if (chosen === null) {
15150
+ return result.exitCode;
15151
+ }
15152
+ return await watchSession({
15153
+ directory: chosen.directory === "" ? process.cwd() : chosen.directory,
15154
+ sessionKey: chosen.kind === "session" ? chosen.id : void 0,
15155
+ socketPath: join15(sessionsDir(), `${chosen.id}.sock`),
15156
+ web: false
14081
15157
  });
14082
- return 0;
14083
15158
  }
14084
15159
  console.error(`unknown command: ${command}`);
14085
15160
  console.error(USAGE);