@webinson/peer 0.4.16 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/peer.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  // Peer: shows a team which agent threads its people chose to share, and on what they work.
3
3
  // `peer login` signs in once; `/peer` in a Claude Code thread shares that one thread.
4
4
  import { execFileSync } from "node:child_process";
5
+ import crypto from "node:crypto";
5
6
  import fs from "node:fs";
6
7
  import os from "node:os";
7
8
  import path from "node:path";
@@ -30,11 +31,12 @@ const MARKETPLACE = "webinson-peer";
30
31
 
31
32
  const HELP = `peer: share chosen agent threads with your team
32
33
 
33
- peer login [email] [--hub <url>] [--workspace <slug>]
34
- sign in once and set up Claude Code (/peer, /peer stop, /peer-list)
34
+ peer login [email] [--hub <url>] [--workspace <slug>] [--codex [--client]]
35
+ sign in once and set up Claude Code or Codex
35
36
  peer list people in the workspace, their shared threads and what they work on
36
37
  peer status who you are, the workspace and the threads this computer shares
37
- peer doctor why Peer's hook did not answer /peer
38
+ peer doctor why Peer's hook did not answer /peer, and whether this repository is in one
39
+ Hub project (edits are checked against teammates' there)
38
40
  peer team connect <url> --as <member> [--project <id>]
39
41
  connect to the team's memory server (your key on stdin); new threads in the
40
42
  project's repositories then start with teammates' published findings. The
@@ -43,14 +45,15 @@ const HELP = `peer: share chosen agent threads with your team
43
45
  peer all [words] [here] [--newest | --oldest] [page N]
44
46
  your own memories in your personal OpenViking, or the ones with these words
45
47
  (full text); here: only this repository's. /peer-all in Claude Code
46
- peer install set up Claude Code again (hooks, the two commands and the thread mark)
47
- peer uninstall remove them from Claude Code
48
+ peer install [--codex [--client]]
49
+ set up the thread mark; --client also builds the Peer Codex terminal
50
+ peer uninstall [--codex] remove the integration from Claude Code or Codex
48
51
  peer logout forget the sign-in on this computer
49
52
 
50
53
  In Claude Code: /peer shares the thread (/peer <message> also sends the message on), /peer stop
51
54
  ends it, /peer-list shows the list.
52
- Only threads where /peer was typed are shared, and only their repository, branch and state:
53
- never prompts, answers or summaries.`;
55
+ Only threads where /peer was typed are shared, and only their repository, branch, state and the
56
+ paths of the files they edit: never prompts, answers, summaries or file contents.`;
54
57
 
55
58
  // ---- files ----
56
59
 
@@ -133,8 +136,8 @@ function workingOn(cwd) {
133
136
  return { repository, branch: branch && branch !== "HEAD" ? branch : undefined, repoKey };
134
137
  }
135
138
 
136
- async function report(saved, session, state, cwd) {
137
- await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent: "claude-code", state, ...workingOn(cwd) }, HUB_TIMEOUT_MS);
139
+ async function report(saved, session, state, cwd, agent = "claude-code", timeoutMs = HUB_TIMEOUT_MS) {
140
+ await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent, state, ...workingOn(cwd) }, timeoutMs);
138
141
  }
139
142
 
140
143
  // ---- the list ----
@@ -235,6 +238,40 @@ function log(line) {
235
238
  } catch {}
236
239
  }
237
240
 
241
+ async function shareThread(saved, session, cwd, working, agent, mode) {
242
+ const sharedAt = new Date().toISOString();
243
+ writeJson(markerOf(session), { workspace: saved.workspace, sharedAt, mode });
244
+ const where = workingOn(cwd);
245
+ const what = [where.repository, where.branch].filter(Boolean).map(plain).join(" @ ");
246
+ // Claude Code's thread mark checks each edit against teammates' (`peer collide`); Codex sends no paths.
247
+ const edits = !agent || agent === "claude-code";
248
+ let said = `Peer: this thread is shared in ${saved.workspace}${what ? ` as ${what}` : ""}. Teammates see its repository, branch${edits ? ", state and the paths of the files it edits" : " and state"}, nothing of the conversation. /peer stop ends it.`;
249
+ // The Hub project its edits are checked in, looked up beside the report; `project` in the marker also says
250
+ // the thread was shared by a version that sends edited paths.
251
+ const [, project] = await Promise.all([
252
+ report(saved, session, working ? "working" : "idle", cwd, agent).catch((error) => {
253
+ said = `Peer: shared, but the hub did not answer yet (${error.message}). The next turn tries again.`;
254
+ }),
255
+ edits ? shareProject(saved, where.repoKey) : undefined,
256
+ ]);
257
+ if (edits) {
258
+ writeJson(markerOf(session), { workspace: saved.workspace, sharedAt, mode, project, ...(where.repoKey ? { repoKey: where.repoKey } : {}) });
259
+ try {
260
+ environment();
261
+ } catch {}
262
+ }
263
+ return { said, what };
264
+ }
265
+
266
+ async function stopSharing(saved, session, cwd, agent) {
267
+ const marker = markerOf(session);
268
+ const shared = readJson(marker);
269
+ fs.rmSync(marker, { force: true });
270
+ if (!shared?.workspace) return "Peer: this thread was not shared.";
271
+ await report({ ...saved, workspace: shared.workspace }, session, "stopped", cwd, agent).catch(() => {});
272
+ return "Peer: this thread is no longer shared.";
273
+ }
274
+
238
275
  async function hook(event) {
239
276
  const input = await readStdin();
240
277
  const session = input.session_id;
@@ -268,19 +305,10 @@ async function hook(event) {
268
305
  return block(`Peer: ${error.message}`);
269
306
  }
270
307
  if (argument === "stop") {
271
- fs.rmSync(marker, { force: true });
272
- if (!shared) return block("Peer: this thread was not shared.", retitle(input, false));
273
- await report(saved, session, "stopped", input.cwd).catch(() => {});
274
- return block("Peer: this thread is no longer shared.", retitle(input, false));
308
+ return block(await stopSharing(saved, session, input.cwd), retitle(input, false));
275
309
  }
276
310
  // `/peer <message>` shares the thread and lets the message through: the command hands it to the model.
277
- writeJson(marker, { workspace: saved.workspace, sharedAt: new Date().toISOString() });
278
- const where = workingOn(input.cwd);
279
- const what = [where.repository, where.branch].filter(Boolean).join(" @ ");
280
- const sharedText = `Peer: this thread is shared in ${saved.workspace}${what ? ` as ${what}` : ""}. Teammates see its repository, branch and state, nothing of the conversation. /peer stop ends it.`;
281
- let hubError;
282
- await report(saved, session, argument ? "working" : "idle", input.cwd).catch((error) => (hubError = error));
283
- const said = hubError ? `Peer: shared, but the hub did not answer yet (${hubError.message}). The next turn tries again.` : sharedText;
311
+ const { said, what } = await shareThread(saved, session, input.cwd, Boolean(argument));
284
312
  const sessionTitle = retitle(input, true, what);
285
313
  // A bare /peer reaches the agent too, which confirms in one line: a block would show the person Claude
286
314
  // Code's "hook blocked your prompt" warning for what went right.
@@ -319,11 +347,11 @@ function forgetOldThreads() {
319
347
  }
320
348
 
321
349
  // A turn never waits on or fails because of the hub: a report that does not go through is dropped.
322
- async function reportQuietly(session, state, cwd) {
350
+ async function reportQuietly(session, state, cwd, agent, timeoutMs) {
323
351
  const saved = config();
324
352
  const marker = readJson(markerOf(session));
325
353
  if (!saved?.session || !marker?.workspace) return;
326
- await report({ ...saved, workspace: marker.workspace }, session, state, cwd).catch(() => {});
354
+ await report({ ...saved, workspace: marker.workspace }, session, state, cwd, agent, timeoutMs).catch(() => {});
327
355
  }
328
356
 
329
357
  // ---- Claude Code setup ----
@@ -336,7 +364,7 @@ const SHARED_NOTE = "[Peer's hook ran]";
336
364
  // Added when /peer came alone: there is no message to work on, only the confirmation, and its privacy line
337
365
  // is a promise the person relies on, so it stays whole.
338
366
  const ALONE_NOTE =
339
- "The person typed /peer alone. Reply with only that confirmation, in one or two short lines in the person's language: where the thread is shared, that teammates see its repository, branch and state but never the conversation, and that /peer stop ends it. Do nothing else and use no tools.";
367
+ "The person typed /peer alone. Reply with only that confirmation, in one or two short lines in the person's language: where the thread is shared, that teammates see its repository, branch, state and the paths of the files it edits but never the conversation, and that /peer stop ends it. Do nothing else and use no tools.";
340
368
 
341
369
  const COMMAND_FILES = {
342
370
  "peer.md": `---
@@ -458,6 +486,10 @@ function option(args, name) {
458
486
  }
459
487
 
460
488
  async function login(args) {
489
+ const forCodex = args.includes("--codex");
490
+ const client = args.includes("--client");
491
+ if (client && !forCodex) throw new Error("--client requires --codex");
492
+ args = args.filter((arg) => arg !== "--codex" && arg !== "--client");
461
493
  const hub = (option(args, "--hub") ?? process.env.PEER_HUB ?? config()?.hub ?? DEFAULT_HUB).replace(/\/+$/, "");
462
494
  let workspace = option(args, "--workspace");
463
495
  const ask = readline.createInterface({ input: process.stdin, output: process.stderr });
@@ -493,7 +525,8 @@ async function login(args) {
493
525
  } finally {
494
526
  ask.close();
495
527
  }
496
- install();
528
+ if (forCodex) (await import("./codex-thread-mark.mjs")).installCodex(codexPeer, { client });
529
+ else install();
497
530
  }
498
531
 
499
532
  async function logout() {
@@ -589,8 +622,26 @@ async function doctor() {
589
622
  }
590
623
  }
591
624
 
592
- const lines = fs.existsSync(HOOK_LOG) ? fs.readFileSync(HOOK_LOG, "utf8").trim().split("\n").slice(-5) : [];
625
+ // Edits are checked against teammates' only in a repository that one Hub project lists. Reads the manifest only.
626
+ const repoKey = repositoryPeer(process.cwd());
627
+ if (saved?.session && saved?.workspace && repoKey) {
628
+ const project = await shareProject(saved, repoKey);
629
+ if (project === "failed") bad(`the hub did not answer which project holds ${repoKey}: run \`peer doctor\` again`);
630
+ else if (project === "none") bad(`no single Hub project in ${saved.workspace} lists ${repoKey}, so edits are not checked against teammates': add the repository to one project`);
631
+ else ok(`${repoKey} is in Hub project ${project}: edits in shared threads are checked against teammates'`);
632
+ }
633
+
634
+ const all = fs.existsSync(HOOK_LOG) ? fs.readFileSync(HOOK_LOG, "utf8").trim().split("\n") : [];
635
+ // Edit checks come one per edit: counted by state, so they do not push the other runs out of view.
636
+ const isCollide = (l) => l.split(" ")[1] === "collide";
637
+ const lines = all.filter((l) => !isCollide(l)).slice(-5);
593
638
  if (lines.length) console.log(`\nLast hook runs (${HOOK_LOG}):\n${lines.map((l) => ` ${l}`).join("\n")}`);
639
+ const collides = all.filter(isCollide);
640
+ if (collides.length) {
641
+ const states = {};
642
+ for (const l of collides) states[l.split(" ")[2]] = (states[l.split(" ")[2]] ?? 0) + 1;
643
+ console.log(`Edit checks: ${Object.entries(states).map(([state, n]) => `${n} ${state}`).join(", ")}`);
644
+ }
594
645
  console.log(
595
646
  problems
596
647
  ? `\n${problems} thing(s) to fix. Then start a new Claude Code session.`
@@ -823,19 +874,20 @@ function threadUses(session, prompts) {
823
874
  return uses.slice(-200);
824
875
  }
825
876
 
826
- async function threadMemories(session) {
877
+ async function threadMemories(session, codex = false) {
827
878
  const ov = readJson(OV_CONF);
828
879
  if (!ov?.url) return { connected: false, reason: `no OpenViking client config at ${OV_CONF}` };
829
- const base = `viking://user/${ov.user}/sessions/cc-${session}`;
880
+ const base = `viking://user/${ov.user}/sessions/${codex ? "cx" : "cc"}-${session}`;
881
+ const uses = (prompts) => codex ? [] : threadUses(session, prompts);
830
882
  let archives;
831
883
  try {
832
884
  archives = await ovRead(ov, `/fs/ls?uri=${encodeURIComponent(`${base}/history`)}`);
833
885
  } catch (error) {
834
- if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [], uses: threadUses(session, transcriptPrompts(session)) };
886
+ if (/not found|no such|404/i.test(error.message)) return { connected: true, saves: [], uses: uses(codex ? [] : transcriptPrompts(session)) };
835
887
  throw error;
836
888
  }
837
889
  const saves = [];
838
- const prompts = transcriptPrompts(session);
890
+ const prompts = codex ? [] : transcriptPrompts(session);
839
891
  for (const archive of archives.filter((entry) => entry.isDir)) {
840
892
  let diff;
841
893
  try {
@@ -872,7 +924,15 @@ async function threadMemories(session) {
872
924
  items.forEach((item, index) => (item.body = String(bodies[index] ?? "").slice(0, 3000)));
873
925
  saves.push({ at: diff.extracted_at ?? archive.modTime, items });
874
926
  }
875
- return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))), uses: threadUses(session, prompts) };
927
+ return { connected: true, saves: saves.sort((a, b) => String(b.at).localeCompare(String(a.at))), uses: uses(prompts) };
928
+ }
929
+
930
+ function threadMemoryText(memories) {
931
+ if (!memories.connected) return `Peer: ${memories.reason}`;
932
+ if (!memories.saves.length) return "This thread has not saved anything to memory yet.";
933
+ return memories.saves.flatMap((save) => [save.at, ...save.items.map((item) =>
934
+ ` ${item.change} ${item.type} "${item.name}": ${item.lines.join(" / ")}${item.systemText ? " [holds Claude Code's own text]" : ""}`,
935
+ )]).join("\n");
876
936
  }
877
937
 
878
938
  // ---- team memory: teammates' published project knowledge at the start of a thread ----
@@ -912,13 +972,14 @@ async function teamCall(team, method, route, body, timeoutMs = 3000) {
912
972
  return { status: response.status, value: await response.json().catch(() => ({})) };
913
973
  }
914
974
 
975
+ const keysOf = (project) => (project.repositories ?? []).map((r) => repositoryKey(r.url ?? "")).filter(Boolean);
976
+
915
977
  // The project is the Hub's: the workspace project whose repositories include this one, with all of its
916
978
  // repositories. A repository in no Hub project (or without a sign-in) is a project of its own, named by its key.
917
979
  async function teamProject(repo) {
918
980
  const saved = config();
919
981
  if (!saved?.session || !saved?.workspace) return { project: repo, repos: [repo] };
920
982
  const manifest = await call(saved, "GET", `/workspaces/${saved.workspace}/manifest`, undefined, 10000);
921
- const keysOf = (project) => (project.repositories ?? []).map((r) => repositoryKey(r.url ?? "")).filter(Boolean);
922
983
  const found = (manifest?.projects ?? []).find((project) => keysOf(project).includes(repo));
923
984
  return found ? { project: found.id, repos: keysOf(found) } : { project: repo, repos: [repo] };
924
985
  }
@@ -973,8 +1034,7 @@ function changesSince(cwd, paths, date) {
973
1034
  return log ? log.split("\n").filter(Boolean).length : 0;
974
1035
  }
975
1036
 
976
- async function teamHook(event) {
977
- const input = await readStdin();
1037
+ async function teamHook(event, input) {
978
1038
  const team = readJson(TEAM_FILE);
979
1039
  const session = input.session_id;
980
1040
  if (!team || !validSession(session)) return;
@@ -1033,7 +1093,7 @@ async function teamHook(event) {
1033
1093
  ...entries,
1034
1094
  "</teammates-project-knowledge>",
1035
1095
  ].join("\n");
1036
- answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block } });
1096
+ return { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: block } };
1037
1097
  }
1038
1098
 
1039
1099
  // ---- your own memory: what your personal OpenViking remembered, listed and searched ----
@@ -1191,6 +1251,91 @@ async function memoryText(query) {
1191
1251
 
1192
1252
  // ---- main ----
1193
1253
 
1254
+ // ---- the collision signal: after an edit in a shared thread, who else was on that file ----
1255
+ // The thread-mark mod runs `peer collide` after each edit; the Hub's `intent` records the claim and answers whose
1256
+ // other sessions are on the path (claims count 180 s). Only the path leaves the computer, never its contents.
1257
+
1258
+ const ENVIRONMENT = path.join(HOME, "environment");
1259
+ const COLLIDE_TIMEOUT_MS = 1500;
1260
+
1261
+ // The Hub project of a shared thread: the one project listing this repository, `none` for zero or several, `failed`
1262
+ // when the Hub does not answer.
1263
+ async function shareProject(saved, repoKey) {
1264
+ if (!repoKey) return "none";
1265
+ try {
1266
+ const manifest = await call(saved, "GET", `/workspaces/${saved.workspace}/manifest`, undefined, HUB_TIMEOUT_MS);
1267
+ const found = (manifest?.projects ?? []).filter((project) => keysOf(project).includes(repoKey));
1268
+ return found.length === 1 ? found[0].id : "none";
1269
+ } catch {
1270
+ return "failed";
1271
+ }
1272
+ }
1273
+
1274
+ // This computer as the Hub's claims name it: made once, kept across sign-ins, so a claim stays this computer's.
1275
+ function environment() {
1276
+ try {
1277
+ return fs.readFileSync(ENVIRONMENT, "utf8").trim();
1278
+ } catch {}
1279
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
1280
+ try {
1281
+ fs.writeFileSync(ENVIRONMENT, crypto.randomUUID(), { flag: "wx", mode: 0o600 });
1282
+ } catch (error) {
1283
+ if (error.code !== "EEXIST") throw error;
1284
+ }
1285
+ return fs.readFileSync(ENVIRONMENT, "utf8").trim();
1286
+ }
1287
+
1288
+ // A path inside the repository as the Hub accepts it: relative, forward slashes, no `..`, no control characters.
1289
+ const repositoryFile = (relative) =>
1290
+ relative && !relative.startsWith("/") && !relative.includes(":") && !/[\u0000-\u001f\u007f]/.test(relative) && !relative.split("/").includes("..") && relative.length <= 300;
1291
+
1292
+ async function collide(session, toolUseId, file) {
1293
+ const started = Date.now();
1294
+ let relative = null;
1295
+ const done = (state, reason, holders = []) => {
1296
+ log(`collide ${state} ${reason ?? "-"} ${Date.now() - started} ms`);
1297
+ console.log(JSON.stringify({ path: relative, state, ...(reason ? { reason } : {}), holders }));
1298
+ };
1299
+ const saved = config();
1300
+ const marker = validSession(session) ? readJson(markerOf(session)) : null;
1301
+ if (!saved?.session || !marker?.workspace || !marker.project) return done("skipped");
1302
+ if (marker.project === "none") return done("no-project");
1303
+ if (marker.project === "failed") return done("unchecked", "lookup-failed");
1304
+ let real;
1305
+ try {
1306
+ real = fs.realpathSync(String(file ?? ""));
1307
+ } catch {
1308
+ return done("skipped");
1309
+ }
1310
+ const top = git(path.dirname(real), "rev-parse", "--show-toplevel");
1311
+ if (!top || repositoryPeer(path.dirname(real)) !== marker.repoKey) return done("skipped");
1312
+ relative = path.relative(fs.realpathSync(top), real).split(path.sep).join("/");
1313
+ if (!repositoryFile(relative)) {
1314
+ relative = null;
1315
+ return done("skipped");
1316
+ }
1317
+ let answered;
1318
+ try {
1319
+ const body = { environment: environment(), session: `cc-${session}`, paths: [relative], ...(toolUseId ? { op: String(toolUseId).slice(0, 64) } : {}) };
1320
+ answered = await call({ ...saved, workspace: marker.workspace }, "POST", `/workspaces/${marker.workspace}/coord/${encodeURIComponent(marker.project)}/intent`, body, COLLIDE_TIMEOUT_MS);
1321
+ } catch (error) {
1322
+ return done("unchecked", error?.name === "TimeoutError" ? "hub-timeout" : "hub-error");
1323
+ }
1324
+ const verdict = (answered?.verdicts ?? []).find((v) => v.path === relative);
1325
+ if (!verdict) return done("clear");
1326
+ if (!Array.isArray(verdict.holders)) return done("unchecked", "old-hub");
1327
+ const me = String(saved.email ?? "").toLowerCase();
1328
+ const holders = verdict.holders.map((h) => ({ email: String(h.email ?? ""), at: h.at, own: String(h.email ?? "").toLowerCase() === me }));
1329
+ return done(holders.length ? "collision" : "clear", undefined, holders);
1330
+ }
1331
+
1332
+ const codexPeer = {
1333
+ home: HOME, cli: SELF, mod: MOD_DIR,
1334
+ validSession, readJson, writeJson, markerOf, config, forgetOldThreads,
1335
+ reportQuietly, signedIn, shareThread, stopSharing, listText, memoryQuery, memoryPage, memoryText,
1336
+ threadMemories, threadMemoryText, teamHook,
1337
+ };
1338
+
1194
1339
  const [command, ...args] = process.argv.slice(2);
1195
1340
  try {
1196
1341
  switch (command) {
@@ -1211,16 +1356,10 @@ try {
1211
1356
  case "thread": {
1212
1357
  // `peer thread --json <session id>` is for the thread-mark mod's pane.
1213
1358
  const session = args.find((arg) => !arg.startsWith("--"));
1214
- if (!validSession(session)) throw new Error("usage: peer thread [--json] <claude code session id>");
1215
- const memories = await threadMemories(session);
1359
+ if (!validSession(session)) throw new Error("usage: peer thread [--json] [--codex] <session id>");
1360
+ const memories = await threadMemories(session, args.includes("--codex"));
1216
1361
  if (args.includes("--json")) console.log(JSON.stringify(memories));
1217
- else if (!memories.connected) console.log(`Peer: ${memories.reason}`);
1218
- else if (!memories.saves.length) console.log("This thread has not saved anything to memory yet.");
1219
- else
1220
- for (const save of memories.saves) {
1221
- console.log(save.at);
1222
- for (const item of save.items) console.log(` ${item.change} ${item.type} "${item.name}": ${item.lines.join(" / ")}${item.systemText ? " [holds Claude Code's own text]" : ""}`);
1223
- }
1362
+ else console.log(threadMemoryText(memories));
1224
1363
  break;
1225
1364
  }
1226
1365
  case "status":
@@ -1231,14 +1370,31 @@ try {
1231
1370
  break;
1232
1371
  case "install":
1233
1372
  signedIn();
1234
- install();
1373
+ if (args.includes("--codex")) {
1374
+ (await import("./codex-thread-mark.mjs")).installCodex(codexPeer, { client: args.includes("--client") });
1375
+ } else if (args.includes("--client")) throw new Error("--client requires --codex");
1376
+ else install();
1235
1377
  break;
1236
1378
  case "uninstall":
1237
- uninstall();
1379
+ if (args.includes("--codex")) (await import("./codex-thread-mark.mjs")).uninstallCodex(codexPeer);
1380
+ else uninstall();
1381
+ break;
1382
+ case "codex-control":
1383
+ answer(await (await import("./codex-thread-mark.mjs")).codexControl(await readStdin(), codexPeer));
1238
1384
  break;
1239
1385
  case "hook":
1240
1386
  // A hook never breaks the person's session: whatever goes wrong, it exits 0.
1241
- await (args[0]?.startsWith("team-") ? teamHook(args[0]) : hook(args[0])).catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
1387
+ await (async () => {
1388
+ if (args[0] === "codex") {
1389
+ const { codexHook } = await import("./codex-thread-mark.mjs");
1390
+ const output = await codexHook(await readStdin(), codexPeer);
1391
+ if (output) answer(output);
1392
+ } else if (args[0]?.startsWith("team-")) {
1393
+ const output = await teamHook(args[0], await readStdin());
1394
+ if (output) answer(output);
1395
+ }
1396
+ else await hook(args[0]);
1397
+ })().catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
1242
1398
  break;
1243
1399
  case "all": {
1244
1400
  // `peer all --json …` is for the thread-mark mod's pane; without a personal OpenViking it says so.
@@ -1251,6 +1407,12 @@ try {
1251
1407
  }
1252
1408
  break;
1253
1409
  }
1410
+ case "collide": {
1411
+ // `peer collide --json <session> <tool_use_id> <absolute path>` is for the thread-mark mod, after an edit.
1412
+ const [session, toolUseId, file] = args.filter((arg) => arg !== "--json");
1413
+ await collide(session, toolUseId, file);
1414
+ break;
1415
+ }
1254
1416
  case "team":
1255
1417
  if (args[0] === "connect") await teamConnect(args.slice(1));
1256
1418
  else if (args[0] === "status") teamStatus();
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "webinson-peer-codex",
3
+ "interface": { "displayName": "Peer" },
4
+ "plugins": [
5
+ {
6
+ "name": "peer-thread-mark",
7
+ "source": { "source": "local", "path": "./peer-thread-mark" },
8
+ "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" },
9
+ "category": "Productivity"
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1 @@
1
+ *.patch whitespace=-blank-at-eol