@webinson/peer 0.2.0 → 0.2.2

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 (3) hide show
  1. package/README.md +11 -4
  2. package/bin/peer.mjs +141 -24
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,12 +15,13 @@ and the commands `/peer` and `/peer-list` in `~/.claude/commands`.
15
15
  In a Claude Code thread:
16
16
 
17
17
  - `/peer` shares this thread in your workspace.
18
+ - `/peer <message>` shares it and passes the message on to the agent as your prompt.
18
19
  - `/peer stop` ends sharing. Ending the session ends it too.
19
20
  - `/peer-list` lists the people who share threads, each thread's state and what it works on,
20
21
  and the threads that stopped in the last hours.
21
22
 
22
- The three commands are answered by the hook before the prompt reaches the model, so they cost no
23
- tokens. `peer list` prints the same list in a terminal.
23
+ `/peer`, `/peer stop` and `/peer-list` are answered by the hook before the prompt reaches the model,
24
+ so they cost no tokens. `peer list` prints the same list in a terminal.
24
25
 
25
26
  ## What is shared
26
27
 
@@ -45,8 +46,13 @@ Claude Code hooks are global: they run in every session. `/peer` is caught by th
45
46
  `UserPromptSubmit` hook, which writes `~/.peer/sessions/<session_id>`; the hook writes it because
46
47
  only the hook knows the session id. After that the hooks report that thread around each turn
47
48
  (`UserPromptSubmit`: working, `Stop`: waiting) and `SessionEnd` reports it stopped and removes the
48
- mark. A hook never fails or holds up a session: it gives the hub 4 seconds and drops what does not
49
- go through.
49
+ mark. A hook never fails or holds up a session: it gives the hub 3 seconds and drops what does not
50
+ go through. What the hooks did, without any prompt text, is in `~/.peer/hook.log`.
51
+
52
+ If `/peer` reaches the model ("the hook did not run"), `peer doctor` checks the sign-in, the hooks in
53
+ Claude Code's settings and the paths they run, and runs the hook the way Claude Code does. An app
54
+ or IDE that starts Claude through the Agent SDK may not load `~/.claude/settings.json`; `claude` in
55
+ a terminal does.
50
56
 
51
57
  - A resumed or forked session can get a new session id. It is then not shared until `/peer` again.
52
58
  - Subagents do not appear: the hooks Peer uses fire for the main thread only.
@@ -56,6 +62,7 @@ go through.
56
62
 
57
63
  ```
58
64
  peer status who you are, the workspace and the threads this computer shares
65
+ peer doctor why /peer reaches the model instead of Peer's hook
59
66
  peer install set Claude Code up again
60
67
  peer uninstall remove the hooks and commands from Claude Code
61
68
  peer logout forget the sign-in on this computer
package/bin/peer.mjs CHANGED
@@ -16,7 +16,11 @@ const CLAUDE = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude
16
16
  const SELF = fileURLToPath(import.meta.url);
17
17
  // Every hook command this CLI installs carries this, so `peer uninstall` finds exactly its own.
18
18
  const HOOK_TAG = "peer-hook";
19
- const HOOK_TIMEOUT_MS = 4000;
19
+ // Claude Code waits this long for a hook; the hub gets a shorter time so a slow hub never hits the limit.
20
+ const HOOK_TIMEOUT_S = 10;
21
+ const HUB_TIMEOUT_MS = 3000;
22
+ // Why a hook did what it did, without any prompt text: `peer doctor` shows the end of it.
23
+ const HOOK_LOG = path.join(HOME, "hook.log");
20
24
 
21
25
  const HELP = `peer: share chosen agent threads with your team
22
26
 
@@ -24,11 +28,13 @@ const HELP = `peer: share chosen agent threads with your team
24
28
  sign in once and set up Claude Code (/peer, /peer stop, /peer-list)
25
29
  peer list people in the workspace, their shared threads and what they work on
26
30
  peer status who you are, the workspace and the threads this computer shares
31
+ peer doctor why /peer reaches the model instead of Peer's hook
27
32
  peer install set up Claude Code again (hooks and the two commands)
28
33
  peer uninstall remove them from Claude Code
29
34
  peer logout forget the sign-in on this computer
30
35
 
31
- In Claude Code: /peer shares the thread, /peer stop ends it, /peer-list shows the list.
36
+ In Claude Code: /peer shares the thread (/peer <message> also sends the message on), /peer stop
37
+ ends it, /peer-list shows the list.
32
38
  Only threads where /peer was typed are shared, and only their repository, branch and state:
33
39
  never prompts, answers or summaries.`;
34
40
 
@@ -84,7 +90,9 @@ async function call(saved, method, route, body, timeoutMs = 15000) {
84
90
  }
85
91
  if (!response.ok) {
86
92
  const message = value?.error?.message ?? value?.message ?? value?.error ?? response.statusText;
87
- throw new Error(`${method} ${route}: ${response.status} ${typeof message === "string" ? message : JSON.stringify(message)}`);
93
+ throw Object.assign(new Error(`${method} ${route}: ${response.status} ${typeof message === "string" ? message : JSON.stringify(message)}`), {
94
+ status: response.status,
95
+ });
88
96
  }
89
97
  return value;
90
98
  }
@@ -110,7 +118,7 @@ function workingOn(cwd) {
110
118
  }
111
119
 
112
120
  async function report(saved, session, state, cwd) {
113
- await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent: "claude-code", state, ...workingOn(cwd) }, HOOK_TIMEOUT_MS - 500);
121
+ await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent: "claude-code", state, ...workingOn(cwd) }, HUB_TIMEOUT_MS);
114
122
  }
115
123
 
116
124
  // ---- the list ----
@@ -172,7 +180,18 @@ async function readStdin() {
172
180
 
173
181
  // A hook answers Claude Code with JSON on stdout; `block` keeps the prompt from reaching the model.
174
182
  const answer = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
175
- const block = (reason) => answer({ decision: "block", reason });
183
+ const block = (reason) => {
184
+ log(`answered ${reason.split("\n")[0].slice(0, 80)}`);
185
+ answer({ decision: "block", reason });
186
+ };
187
+
188
+ function log(line) {
189
+ try {
190
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
191
+ if (fs.existsSync(HOOK_LOG) && fs.statSync(HOOK_LOG).size > 64 * 1024) fs.renameSync(HOOK_LOG, `${HOOK_LOG}.1`);
192
+ fs.appendFileSync(HOOK_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
193
+ } catch {}
194
+ }
176
195
 
177
196
  async function hook(event) {
178
197
  const input = await readStdin();
@@ -183,7 +202,8 @@ async function hook(event) {
183
202
 
184
203
  if (event === "prompt") {
185
204
  const prompt = String(input.prompt ?? "").trim();
186
- const command = /^\/peer(?:\s+(\S+))?\s*$/.exec(prompt);
205
+ const command = /^\/peer(?:\s+([\s\S]*))?$/.exec(prompt);
206
+ const argument = command?.[1]?.trim() ?? "";
187
207
  if (prompt === "/peer-list") {
188
208
  let saved;
189
209
  try {
@@ -200,22 +220,23 @@ async function hook(event) {
200
220
  } catch (error) {
201
221
  return block(`Peer: ${error.message}`);
202
222
  }
203
- if (command[1] === "stop") {
223
+ if (argument === "stop") {
204
224
  fs.rmSync(marker, { force: true });
205
225
  if (!shared) return block("Peer: this thread was not shared.");
206
226
  await report(saved, session, "stopped", input.cwd).catch(() => {});
207
227
  return block("Peer: this thread is no longer shared.");
208
228
  }
209
- if (command[1]) return block("Peer: use /peer to share this thread, /peer stop to end it, /peer-list for the list.");
229
+ // `/peer <message>` shares the thread and lets the message through: the command hands it to the model.
210
230
  writeJson(marker, { workspace: saved.workspace, sharedAt: new Date().toISOString() });
211
231
  const where = workingOn(input.cwd);
212
- try {
213
- await report(saved, session, "idle", input.cwd);
214
- } catch (error) {
215
- return block(`Peer: shared, but the hub did not answer yet (${error.message}). The next turn tries again.`);
216
- }
217
232
  const what = [where.repository, where.branch].filter(Boolean).join(" @ ");
218
- return block(`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.`);
233
+ 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.`;
234
+ let hubError;
235
+ await report(saved, session, argument ? "working" : "idle", input.cwd).catch((error) => (hubError = error));
236
+ const said = hubError ? `Peer: shared, but the hub did not answer yet (${hubError.message}). The next turn tries again.` : sharedText;
237
+ if (!argument) return block(said);
238
+ log("answered /peer with a message: shared, message passed on");
239
+ return answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `${SHARED_NOTE}\n${said}` } });
219
240
  }
220
241
  if (shared) await reportQuietly(session, "working", input.cwd);
221
242
  return;
@@ -241,20 +262,25 @@ async function reportQuietly(session, state, cwd) {
241
262
  const SETTINGS = path.join(CLAUDE, "settings.json");
242
263
  const COMMANDS = path.join(CLAUDE, "commands");
243
264
 
265
+ // The hook's note to the model when `/peer <message>` shares a thread; peer.md looks for it.
266
+ const SHARED_NOTE = "[Peer's hook ran]";
267
+
244
268
  const COMMAND_FILES = {
245
269
  "peer.md": `---
246
- description: Share this thread on Peer (/peer), or stop sharing it (/peer stop)
247
- argument-hint: "[stop]"
270
+ description: Share this thread on Peer (/peer), share it and go on (/peer <message>), or stop sharing it (/peer stop)
271
+ argument-hint: "[message | stop]"
248
272
  ---
249
- Peer's hook handles this command before it reaches you, so you should not be reading it. If you are,
250
- the hook did not run: tell the person to run \`peer install\` in a terminal and open a new session.
251
- Do nothing else.
273
+ $ARGUMENTS
274
+
275
+ (From Peer, not from the person: if a note from Peer's hook, starting with ${SHARED_NOTE}, says this thread is now
276
+ shared, say so in one short line, then treat the text above as the person's message. If there is no such note, the
277
+ hook did not run or did not answer in time: tell the person to run \`peer doctor\` in a terminal, which says why.)
252
278
  `,
253
279
  "peer-list.md": `---
254
280
  description: List the people in your Peer workspace and the agent threads they share
255
281
  ---
256
282
  Peer's hook answers this command before it reaches you, so you should not be reading it. If you are,
257
- the hook did not run: tell the person to run \`peer install\` in a terminal and open a new session.
283
+ the hook did not run or did not answer in time: tell the person to run \`peer doctor\` in a terminal, which says why.
258
284
  Do nothing else.
259
285
  `,
260
286
  };
@@ -285,7 +311,7 @@ function install() {
285
311
  settings.hooks ??= {};
286
312
  for (const [event, name] of [["UserPromptSubmit", "prompt"], ["Stop", "stop"], ["SessionEnd", "end"]]) {
287
313
  settings.hooks[event] ??= [];
288
- settings.hooks[event].push({ hooks: [{ type: "command", command: hookCommand(name), timeout: HOOK_TIMEOUT_MS / 1000 }] });
314
+ settings.hooks[event].push({ hooks: [{ type: "command", command: hookCommand(name), timeout: HOOK_TIMEOUT_S }] });
289
315
  }
290
316
  writeJson(SETTINGS, settings, 0o644);
291
317
  fs.mkdirSync(COMMANDS, { recursive: true });
@@ -317,8 +343,13 @@ async function login(args) {
317
343
  const ask = readline.createInterface({ input: process.stdin, output: process.stderr });
318
344
  try {
319
345
  const email = args[0] ?? (await ask.question("Your work email: ")).trim();
320
- const started = await call({ hub }, "POST", "/auth/email/start", { email });
321
- const code = started.code ?? (await ask.question(`Code from the email to ${started.email}: `)).trim();
346
+ // A code sent a moment ago still holds: the hub then sends no new one, and the last email's code is asked for.
347
+ const started = await call({ hub }, "POST", "/auth/email/start", { email }).catch((error) => {
348
+ if (error.status !== 429) throw error;
349
+ return { email, sent: false };
350
+ });
351
+ const which = started.sent === false ? "the last email" : "the email";
352
+ const code = started.code ?? (await ask.question(`Code from ${which} to ${started.email}: `)).trim();
322
353
  const finished = await call({ hub }, "POST", "/auth/email/verify", { email: started.email, code });
323
354
  const saved = { hub, email: finished.email ?? started.email, session: finished.session };
324
355
  const me = await call(saved, "GET", "/me");
@@ -360,6 +391,89 @@ function status() {
360
391
  console.log(shared.length ? `Threads shared from this computer: ${shared.join(", ")}` : "No thread shared from this computer.");
361
392
  }
362
393
 
394
+ // ---- peer doctor: why /peer reaches the model instead of the hook ----
395
+
396
+ async function doctor() {
397
+ let problems = 0;
398
+ const ok = (text) => console.log(` ok ${text}`);
399
+ const bad = (text) => {
400
+ problems += 1;
401
+ console.log(` FIX ${text}`);
402
+ };
403
+ console.log(`peer ${readJson(path.join(path.dirname(SELF), "..", "package.json"))?.version ?? "?"} at ${SELF}, node ${process.version}`);
404
+
405
+ const saved = config();
406
+ if (!saved?.session || !saved?.workspace) bad("not signed in: run `peer login`");
407
+ else {
408
+ try {
409
+ const me = await call(saved, "GET", "/me", undefined, HUB_TIMEOUT_MS);
410
+ if ((me.workspaces ?? []).some((w) => w.slug === saved.workspace)) ok(`signed in to ${saved.hub} as ${saved.email}, workspace ${saved.workspace}`);
411
+ else bad(`workspace ${saved.workspace} does not list ${saved.email} (removed, or the workspace is gone): run \`peer login\``);
412
+ } catch (error) {
413
+ bad(`the hub does not accept the sign-in or does not answer (${error.message}): run \`peer login\``);
414
+ }
415
+ }
416
+
417
+ try {
418
+ const version = execFileSync("claude", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
419
+ ok(`Claude Code ${version} (Peer is tested with 2.1.316)`);
420
+ } catch {
421
+ console.log(" ? `claude` is not on this terminal's PATH, so its version is unknown");
422
+ }
423
+
424
+ const settings = readJson(SETTINGS);
425
+ const ours = [];
426
+ for (const [event, groups] of Object.entries(settings?.hooks ?? {})) {
427
+ for (const group of groups) for (const h of group.hooks ?? []) if (String(h.command ?? "").includes(HOOK_TAG)) ours.push({ event, ...h });
428
+ }
429
+ const events = ["UserPromptSubmit", "Stop", "SessionEnd"];
430
+ const missing = events.filter((e) => !ours.some((h) => h.event === e));
431
+ if (!settings) bad(`no ${SETTINGS}: run \`peer install\``);
432
+ else if (missing.length) bad(`${SETTINGS} lacks Peer's ${missing.join(", ")} hook: run \`peer install\``);
433
+ else ok(`hooks in ${SETTINGS}`);
434
+ for (const h of ours) {
435
+ const paths = [...String(h.command).matchAll(/"([^"]+)"/g)].map((m) => m[1]);
436
+ const gone = paths.filter((p) => !fs.existsSync(p));
437
+ if (gone.length) bad(`the ${h.event} hook runs ${gone.join(" and ")}, which no longer exists: run \`peer install\` again`);
438
+ }
439
+ for (const file of [SETTINGS, path.join(CLAUDE, "settings.local.json"), "/Library/Application Support/ClaudeCode/managed-settings.json", "/etc/claude-code/managed-settings.json"]) {
440
+ const read = readJson(file);
441
+ if (read?.disableAllHooks) bad(`${file} sets disableAllHooks: Claude Code runs no hooks at all`);
442
+ if (read?.allowManagedHooksOnly) bad(`${file} sets allowManagedHooksOnly: only an administrator's hooks run`);
443
+ }
444
+ if (process.env.CLAUDE_CONFIG_DIR) console.log(` note CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR}: Claude Code must run with the same value`);
445
+ const commands = Object.keys(COMMAND_FILES).filter((name) => fs.existsSync(path.join(COMMANDS, name)));
446
+ if (commands.length === Object.keys(COMMAND_FILES).length) ok(`/peer and /peer-list in ${COMMANDS}`);
447
+ else bad(`commands missing in ${COMMANDS}: run \`peer install\``);
448
+
449
+ // The hook Claude Code would run, run here: a /peer-list must come back answered within Claude's limit.
450
+ const prompt = ours.find((h) => h.event === "UserPromptSubmit");
451
+ if (prompt) {
452
+ const started = Date.now();
453
+ try {
454
+ const out = execFileSync("/bin/sh", ["-c", prompt.command], {
455
+ encoding: "utf8",
456
+ timeout: HOOK_TIMEOUT_S * 1000,
457
+ input: JSON.stringify({ session_id: "peer-doctor", cwd: process.cwd(), hook_event_name: "UserPromptSubmit", prompt: "/peer-list" }),
458
+ });
459
+ const took = Date.now() - started;
460
+ if (JSON.parse(out || "{}").decision === "block") ok(`the hook answers /peer-list in ${took} ms (Claude Code waits ${HOOK_TIMEOUT_S} s)`);
461
+ else bad(`the hook ran but did not answer /peer-list: ${out.slice(0, 200)}`);
462
+ } catch (error) {
463
+ bad(`the hook failed when run as Claude Code runs it (${Date.now() - started} ms): ${String(error.stderr || error.message).split("\n")[0]}`);
464
+ }
465
+ }
466
+
467
+ const lines = fs.existsSync(HOOK_LOG) ? fs.readFileSync(HOOK_LOG, "utf8").trim().split("\n").slice(-5) : [];
468
+ if (lines.length) console.log(`\nLast hook runs (${HOOK_LOG}):\n${lines.map((l) => ` ${l}`).join("\n")}`);
469
+ console.log(
470
+ problems
471
+ ? `\n${problems} thing(s) to fix. Then start a new Claude Code session.`
472
+ : "\nPeer is set up here. If /peer still reaches the model, Claude Code is not loading your user settings: an app or IDE that runs Claude through the Agent SDK may ignore ~/.claude/settings.json. Start `claude` in a terminal and try /peer there.",
473
+ );
474
+ if (problems) process.exitCode = 1;
475
+ }
476
+
363
477
  // ---- main ----
364
478
 
365
479
  const [command, ...args] = process.argv.slice(2);
@@ -377,6 +491,9 @@ try {
377
491
  case "status":
378
492
  status();
379
493
  break;
494
+ case "doctor":
495
+ await doctor();
496
+ break;
380
497
  case "install":
381
498
  signedIn();
382
499
  install();
@@ -386,7 +503,7 @@ try {
386
503
  break;
387
504
  case "hook":
388
505
  // A hook never breaks the person's session: whatever goes wrong, it exits 0.
389
- await hook(args[0]).catch(() => {});
506
+ await hook(args[0]).catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
390
507
  break;
391
508
  case undefined:
392
509
  case "help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webinson/peer",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Share chosen Claude Code threads with your team: who has agents running and on what.",
5
5
  "license": "MIT",
6
6
  "type": "module",