@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.
- package/README.md +11 -4
- package/bin/peer.mjs +141 -24
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) },
|
|
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) =>
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
321
|
-
const
|
|
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":
|