@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.
- package/README.md +8 -2
- package/bin/peer.mjs +123 -13
- 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
|
|
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
|
-
|
|
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) },
|
|
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) =>
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
321
|
-
const
|
|
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":
|