@webinson/peer 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +8 -2
  2. package/bin/peer.mjs +123 -13
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,8 +45,13 @@ Claude Code hooks are global: they run in every session. `/peer` is caught by th
45
45
  `UserPromptSubmit` hook, which writes `~/.peer/sessions/<session_id>`; the hook writes it because
46
46
  only the hook knows the session id. After that the hooks report that thread around each turn
47
47
  (`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.
48
+ mark. A hook never fails or holds up a session: it gives the hub 3 seconds and drops what does not
49
+ go through. What the hooks did, without any prompt text, is in `~/.peer/hook.log`.
50
+
51
+ If `/peer` reaches the model ("the hook did not run"), `peer doctor` checks the sign-in, the hooks in
52
+ Claude Code's settings and the paths they run, and runs the hook the way Claude Code does. An app
53
+ or IDE that starts Claude through the Agent SDK may not load `~/.claude/settings.json`; `claude` in
54
+ a terminal does.
50
55
 
51
56
  - A resumed or forked session can get a new session id. It is then not shared until `/peer` again.
52
57
  - Subagents do not appear: the hooks Peer uses fire for the main thread only.
@@ -56,6 +61,7 @@ go through.
56
61
 
57
62
  ```
58
63
  peer status who you are, the workspace and the threads this computer shares
64
+ peer doctor why /peer reaches the model instead of Peer's hook
59
65
  peer install set Claude Code up again
60
66
  peer uninstall remove the hooks and commands from Claude Code
61
67
  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,6 +28,7 @@ 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
@@ -84,7 +89,9 @@ async function call(saved, method, route, body, timeoutMs = 15000) {
84
89
  }
85
90
  if (!response.ok) {
86
91
  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)}`);
92
+ throw Object.assign(new Error(`${method} ${route}: ${response.status} ${typeof message === "string" ? message : JSON.stringify(message)}`), {
93
+ status: response.status,
94
+ });
88
95
  }
89
96
  return value;
90
97
  }
@@ -110,7 +117,7 @@ function workingOn(cwd) {
110
117
  }
111
118
 
112
119
  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);
120
+ await call(saved, "PUT", `/workspaces/${saved.workspace}/agents/${session}`, { agent: "claude-code", state, ...workingOn(cwd) }, HUB_TIMEOUT_MS);
114
121
  }
115
122
 
116
123
  // ---- the list ----
@@ -172,7 +179,18 @@ async function readStdin() {
172
179
 
173
180
  // A hook answers Claude Code with JSON on stdout; `block` keeps the prompt from reaching the model.
174
181
  const answer = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
175
- const block = (reason) => answer({ decision: "block", reason });
182
+ const block = (reason) => {
183
+ log(`answered ${reason.split("\n")[0].slice(0, 80)}`);
184
+ answer({ decision: "block", reason });
185
+ };
186
+
187
+ function log(line) {
188
+ try {
189
+ fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
190
+ if (fs.existsSync(HOOK_LOG) && fs.statSync(HOOK_LOG).size > 64 * 1024) fs.renameSync(HOOK_LOG, `${HOOK_LOG}.1`);
191
+ fs.appendFileSync(HOOK_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
192
+ } catch {}
193
+ }
176
194
 
177
195
  async function hook(event) {
178
196
  const input = await readStdin();
@@ -183,7 +201,8 @@ async function hook(event) {
183
201
 
184
202
  if (event === "prompt") {
185
203
  const prompt = String(input.prompt ?? "").trim();
186
- const command = /^\/peer(?:\s+(\S+))?\s*$/.exec(prompt);
204
+ const command = /^\/peer(?:\s+([\s\S]*))?$/.exec(prompt);
205
+ const argument = command?.[1]?.trim() ?? "";
187
206
  if (prompt === "/peer-list") {
188
207
  let saved;
189
208
  try {
@@ -200,13 +219,13 @@ async function hook(event) {
200
219
  } catch (error) {
201
220
  return block(`Peer: ${error.message}`);
202
221
  }
203
- if (command[1] === "stop") {
222
+ if (argument === "stop") {
204
223
  fs.rmSync(marker, { force: true });
205
224
  if (!shared) return block("Peer: this thread was not shared.");
206
225
  await report(saved, session, "stopped", input.cwd).catch(() => {});
207
226
  return block("Peer: this thread is no longer shared.");
208
227
  }
209
- if (command[1]) return block("Peer: use /peer to share this thread, /peer stop to end it, /peer-list for the list.");
228
+ if (argument) return block("Peer: use /peer to share this thread, /peer stop to end it, /peer-list for the list.");
210
229
  writeJson(marker, { workspace: saved.workspace, sharedAt: new Date().toISOString() });
211
230
  const where = workingOn(input.cwd);
212
231
  try {
@@ -247,14 +266,14 @@ description: Share this thread on Peer (/peer), or stop sharing it (/peer stop)
247
266
  argument-hint: "[stop]"
248
267
  ---
249
268
  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.
269
+ the hook did not run or did not answer in time: tell the person to run \`peer doctor\` in a terminal, which says why.
251
270
  Do nothing else.
252
271
  `,
253
272
  "peer-list.md": `---
254
273
  description: List the people in your Peer workspace and the agent threads they share
255
274
  ---
256
275
  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.
276
+ 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
277
  Do nothing else.
259
278
  `,
260
279
  };
@@ -285,7 +304,7 @@ function install() {
285
304
  settings.hooks ??= {};
286
305
  for (const [event, name] of [["UserPromptSubmit", "prompt"], ["Stop", "stop"], ["SessionEnd", "end"]]) {
287
306
  settings.hooks[event] ??= [];
288
- settings.hooks[event].push({ hooks: [{ type: "command", command: hookCommand(name), timeout: HOOK_TIMEOUT_MS / 1000 }] });
307
+ settings.hooks[event].push({ hooks: [{ type: "command", command: hookCommand(name), timeout: HOOK_TIMEOUT_S }] });
289
308
  }
290
309
  writeJson(SETTINGS, settings, 0o644);
291
310
  fs.mkdirSync(COMMANDS, { recursive: true });
@@ -317,8 +336,13 @@ async function login(args) {
317
336
  const ask = readline.createInterface({ input: process.stdin, output: process.stderr });
318
337
  try {
319
338
  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();
339
+ // A code sent a moment ago still holds: the hub then sends no new one, and the last email's code is asked for.
340
+ const started = await call({ hub }, "POST", "/auth/email/start", { email }).catch((error) => {
341
+ if (error.status !== 429) throw error;
342
+ return { email, sent: false };
343
+ });
344
+ const which = started.sent === false ? "the last email" : "the email";
345
+ const code = started.code ?? (await ask.question(`Code from ${which} to ${started.email}: `)).trim();
322
346
  const finished = await call({ hub }, "POST", "/auth/email/verify", { email: started.email, code });
323
347
  const saved = { hub, email: finished.email ?? started.email, session: finished.session };
324
348
  const me = await call(saved, "GET", "/me");
@@ -360,6 +384,89 @@ function status() {
360
384
  console.log(shared.length ? `Threads shared from this computer: ${shared.join(", ")}` : "No thread shared from this computer.");
361
385
  }
362
386
 
387
+ // ---- peer doctor: why /peer reaches the model instead of the hook ----
388
+
389
+ async function doctor() {
390
+ let problems = 0;
391
+ const ok = (text) => console.log(` ok ${text}`);
392
+ const bad = (text) => {
393
+ problems += 1;
394
+ console.log(` FIX ${text}`);
395
+ };
396
+ console.log(`peer ${readJson(path.join(path.dirname(SELF), "..", "package.json"))?.version ?? "?"} at ${SELF}, node ${process.version}`);
397
+
398
+ const saved = config();
399
+ if (!saved?.session || !saved?.workspace) bad("not signed in: run `peer login`");
400
+ else {
401
+ try {
402
+ const me = await call(saved, "GET", "/me", undefined, HUB_TIMEOUT_MS);
403
+ if ((me.workspaces ?? []).some((w) => w.slug === saved.workspace)) ok(`signed in to ${saved.hub} as ${saved.email}, workspace ${saved.workspace}`);
404
+ else bad(`workspace ${saved.workspace} does not list ${saved.email} (removed, or the workspace is gone): run \`peer login\``);
405
+ } catch (error) {
406
+ bad(`the hub does not accept the sign-in or does not answer (${error.message}): run \`peer login\``);
407
+ }
408
+ }
409
+
410
+ try {
411
+ const version = execFileSync("claude", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
412
+ ok(`Claude Code ${version} (Peer is tested with 2.1.316)`);
413
+ } catch {
414
+ console.log(" ? `claude` is not on this terminal's PATH, so its version is unknown");
415
+ }
416
+
417
+ const settings = readJson(SETTINGS);
418
+ const ours = [];
419
+ for (const [event, groups] of Object.entries(settings?.hooks ?? {})) {
420
+ for (const group of groups) for (const h of group.hooks ?? []) if (String(h.command ?? "").includes(HOOK_TAG)) ours.push({ event, ...h });
421
+ }
422
+ const events = ["UserPromptSubmit", "Stop", "SessionEnd"];
423
+ const missing = events.filter((e) => !ours.some((h) => h.event === e));
424
+ if (!settings) bad(`no ${SETTINGS}: run \`peer install\``);
425
+ else if (missing.length) bad(`${SETTINGS} lacks Peer's ${missing.join(", ")} hook: run \`peer install\``);
426
+ else ok(`hooks in ${SETTINGS}`);
427
+ for (const h of ours) {
428
+ const paths = [...String(h.command).matchAll(/"([^"]+)"/g)].map((m) => m[1]);
429
+ const gone = paths.filter((p) => !fs.existsSync(p));
430
+ if (gone.length) bad(`the ${h.event} hook runs ${gone.join(" and ")}, which no longer exists: run \`peer install\` again`);
431
+ }
432
+ for (const file of [SETTINGS, path.join(CLAUDE, "settings.local.json"), "/Library/Application Support/ClaudeCode/managed-settings.json", "/etc/claude-code/managed-settings.json"]) {
433
+ const read = readJson(file);
434
+ if (read?.disableAllHooks) bad(`${file} sets disableAllHooks: Claude Code runs no hooks at all`);
435
+ if (read?.allowManagedHooksOnly) bad(`${file} sets allowManagedHooksOnly: only an administrator's hooks run`);
436
+ }
437
+ 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`);
438
+ const commands = Object.keys(COMMAND_FILES).filter((name) => fs.existsSync(path.join(COMMANDS, name)));
439
+ if (commands.length === Object.keys(COMMAND_FILES).length) ok(`/peer and /peer-list in ${COMMANDS}`);
440
+ else bad(`commands missing in ${COMMANDS}: run \`peer install\``);
441
+
442
+ // The hook Claude Code would run, run here: a /peer-list must come back answered within Claude's limit.
443
+ const prompt = ours.find((h) => h.event === "UserPromptSubmit");
444
+ if (prompt) {
445
+ const started = Date.now();
446
+ try {
447
+ const out = execFileSync("/bin/sh", ["-c", prompt.command], {
448
+ encoding: "utf8",
449
+ timeout: HOOK_TIMEOUT_S * 1000,
450
+ input: JSON.stringify({ session_id: "peer-doctor", cwd: process.cwd(), hook_event_name: "UserPromptSubmit", prompt: "/peer-list" }),
451
+ });
452
+ const took = Date.now() - started;
453
+ if (JSON.parse(out || "{}").decision === "block") ok(`the hook answers /peer-list in ${took} ms (Claude Code waits ${HOOK_TIMEOUT_S} s)`);
454
+ else bad(`the hook ran but did not answer /peer-list: ${out.slice(0, 200)}`);
455
+ } catch (error) {
456
+ bad(`the hook failed when run as Claude Code runs it (${Date.now() - started} ms): ${String(error.stderr || error.message).split("\n")[0]}`);
457
+ }
458
+ }
459
+
460
+ const lines = fs.existsSync(HOOK_LOG) ? fs.readFileSync(HOOK_LOG, "utf8").trim().split("\n").slice(-5) : [];
461
+ if (lines.length) console.log(`\nLast hook runs (${HOOK_LOG}):\n${lines.map((l) => ` ${l}`).join("\n")}`);
462
+ console.log(
463
+ problems
464
+ ? `\n${problems} thing(s) to fix. Then start a new Claude Code session.`
465
+ : "\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.",
466
+ );
467
+ if (problems) process.exitCode = 1;
468
+ }
469
+
363
470
  // ---- main ----
364
471
 
365
472
  const [command, ...args] = process.argv.slice(2);
@@ -377,6 +484,9 @@ try {
377
484
  case "status":
378
485
  status();
379
486
  break;
487
+ case "doctor":
488
+ await doctor();
489
+ break;
380
490
  case "install":
381
491
  signedIn();
382
492
  install();
@@ -386,7 +496,7 @@ try {
386
496
  break;
387
497
  case "hook":
388
498
  // A hook never breaks the person's session: whatever goes wrong, it exits 0.
389
- await hook(args[0]).catch(() => {});
499
+ await hook(args[0]).catch((error) => log(`hook ${args[0]} failed: ${error?.message ?? error}`));
390
500
  break;
391
501
  case undefined:
392
502
  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.1",
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",