@webinson/peer 0.4.0 → 0.4.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 +7 -2
- package/bin/peer.mjs +69 -7
- package/mod/.claude-plugin/marketplace.json +11 -0
- package/mod/peer-thread-mark/.claude-plugin/plugin.json +7 -0
- package/mod/peer-thread-mark/hooks/hooks.json +1 -0
- package/mod/peer-thread-mark/hooks/register.tsx +115 -0
- package/mod/peer-thread-mark/types/index.d.ts +9 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -16,7 +16,11 @@ In a Claude Code thread:
|
|
|
16
16
|
|
|
17
17
|
- `/peer` shares this thread in your workspace.
|
|
18
18
|
- `/peer <message>` shares it and passes the message on to the agent as your prompt.
|
|
19
|
-
- `/peer stop` ends sharing.
|
|
19
|
+
- `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
|
|
20
|
+
is shared again when the session is resumed. `/clear` and signing out of Claude Code end it.
|
|
21
|
+
- In a shared thread a band above the prompt says `Peer <workspace>` with `/peer stop` to click; after a stop it
|
|
22
|
+
turns gray with `/peer start`. It is a Claude Code plugin in this package (`mod/`) that `peer install` adds;
|
|
23
|
+
the desktop app draws it only while you type or a turn runs.
|
|
20
24
|
- A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
|
|
21
25
|
back if Claude Code renames the thread, and `/peer stop` takes it off.
|
|
22
26
|
- `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
|
|
@@ -42,7 +46,8 @@ branch name, say) and is data, not instructions. An agent that reads it must not
|
|
|
42
46
|
- **active**: the thread reported in the last 15 minutes, working or waiting for its person.
|
|
43
47
|
- **silent**: nothing for 15 minutes. The session may have crashed or the laptop closed. An agent
|
|
44
48
|
reports only around its turns, so an open thread nobody uses goes silent too.
|
|
45
|
-
- **stopped**: the session ended, `/peer stop` was typed, or the thread was silent
|
|
49
|
+
- **stopped**: the session ended or closed (until it is resumed), `/peer stop` was typed, or the thread was silent
|
|
50
|
+
for 2 hours.
|
|
46
51
|
Stopped threads stay in the list for 8 hours.
|
|
47
52
|
|
|
48
53
|
## How it works
|
package/bin/peer.mjs
CHANGED
|
@@ -21,6 +21,12 @@ const HOOK_TIMEOUT_S = 10;
|
|
|
21
21
|
const HUB_TIMEOUT_MS = 3000;
|
|
22
22
|
// Why a hook did what it did, without any prompt text: `peer doctor` shows the end of it.
|
|
23
23
|
const HOOK_LOG = path.join(HOME, "hook.log");
|
|
24
|
+
// The thread mark: a Claude Code plugin shipped in this package (mod/), a marketplace of one plugin read
|
|
25
|
+
// from that folder, so updating the package updates it.
|
|
26
|
+
const CLAUDE_BIN = process.env.PEER_CLAUDE || "claude";
|
|
27
|
+
const MOD_DIR = path.join(path.dirname(SELF), "..", "mod");
|
|
28
|
+
const MOD = "peer-thread-mark";
|
|
29
|
+
const MARKETPLACE = "webinson-peer";
|
|
24
30
|
|
|
25
31
|
const HELP = `peer: share chosen agent threads with your team
|
|
26
32
|
|
|
@@ -33,7 +39,7 @@ const HELP = `peer: share chosen agent threads with your team
|
|
|
33
39
|
connect to the team's memory server (your key on stdin); new threads in the
|
|
34
40
|
project's repositories then start with teammates' published findings
|
|
35
41
|
peer team status the team server, project and the last recalls
|
|
36
|
-
peer install set up Claude Code again (hooks and the
|
|
42
|
+
peer install set up Claude Code again (hooks, the two commands and the thread mark)
|
|
37
43
|
peer uninstall remove them from Claude Code
|
|
38
44
|
peer logout forget the sign-in on this computer
|
|
39
45
|
|
|
@@ -278,14 +284,29 @@ async function hook(event) {
|
|
|
278
284
|
if (sessionTitle) answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", sessionTitle } });
|
|
279
285
|
return;
|
|
280
286
|
}
|
|
287
|
+
if (event === "start") forgetOldThreads();
|
|
281
288
|
if (!shared) return;
|
|
282
289
|
if (event === "stop") await reportQuietly(session, "idle", input.cwd);
|
|
290
|
+
// A thread comes back with its session: a restart, a quit or /exit leaves it shared, and resuming it
|
|
291
|
+
// reports it again. /clear and signing out of Claude Code end it for good.
|
|
292
|
+
if (event === "start" && input.source === "resume") await reportQuietly(session, "idle", input.cwd);
|
|
283
293
|
if (event === "end") {
|
|
284
294
|
await reportQuietly(session, "stopped", input.cwd);
|
|
285
|
-
fs.rmSync(marker, { force: true });
|
|
295
|
+
if (input.reason === "clear" || input.reason === "logout") fs.rmSync(marker, { force: true });
|
|
286
296
|
}
|
|
287
297
|
}
|
|
288
298
|
|
|
299
|
+
// Threads not resumed for a month are forgotten, so a session resumed much later starts unshared.
|
|
300
|
+
const FORGET_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
|
|
301
|
+
function forgetOldThreads() {
|
|
302
|
+
try {
|
|
303
|
+
for (const name of fs.readdirSync(SESSIONS)) {
|
|
304
|
+
const file = path.join(SESSIONS, name);
|
|
305
|
+
if (Date.now() - fs.statSync(file).mtimeMs > FORGET_AFTER_MS) fs.rmSync(file, { force: true });
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
}
|
|
309
|
+
|
|
289
310
|
// A turn never waits on or fails because of the hub: a report that does not go through is dropped.
|
|
290
311
|
async function reportQuietly(session, state, cwd) {
|
|
291
312
|
const saved = config();
|
|
@@ -346,7 +367,7 @@ function install() {
|
|
|
346
367
|
}
|
|
347
368
|
const settings = withoutOurHooks(readJson(SETTINGS) ?? {});
|
|
348
369
|
settings.hooks ??= {};
|
|
349
|
-
const events = [["UserPromptSubmit", "prompt"], ["Stop", "stop"], ["SessionEnd", "end"]];
|
|
370
|
+
const events = [["UserPromptSubmit", "prompt"], ["Stop", "stop"], ["SessionEnd", "end"], ["SessionStart", "start"]];
|
|
350
371
|
// Team memory hooks only once this computer is connected to a team server (peer team connect).
|
|
351
372
|
if (fs.existsSync(TEAM_FILE)) events.push(["UserPromptSubmit", "team-prompt"], ["SessionStart", "team-start"]);
|
|
352
373
|
for (const [event, name] of events) {
|
|
@@ -357,13 +378,49 @@ function install() {
|
|
|
357
378
|
fs.mkdirSync(COMMANDS, { recursive: true });
|
|
358
379
|
for (const [name, text] of Object.entries(COMMAND_FILES)) fs.writeFileSync(path.join(COMMANDS, name), text);
|
|
359
380
|
console.log(`Claude Code: hooks in ${SETTINGS}, /peer and /peer-list in ${COMMANDS}. Sessions started from now on have them.`);
|
|
381
|
+
installMark();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function claudeCli(args) {
|
|
385
|
+
return execFileSync(CLAUDE_BIN, args, { encoding: "utf8", timeout: 60000, stdio: ["ignore", "pipe", "pipe"] });
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The band above the prompt in shared threads. Optional: /peer works without it, so a failure only says so.
|
|
389
|
+
function installMark() {
|
|
390
|
+
if (!fs.existsSync(path.join(MOD_DIR, ".claude-plugin", "marketplace.json"))) return;
|
|
391
|
+
const install = () => claudeCli(["plugin", "install", MOD, "--marketplace", MOD_DIR, "--scope", "user"]);
|
|
392
|
+
try {
|
|
393
|
+
try {
|
|
394
|
+
install();
|
|
395
|
+
} catch {
|
|
396
|
+
// A marketplace of that name added from an older place (another install of this package) is replaced.
|
|
397
|
+
try {
|
|
398
|
+
claudeCli(["plugin", "marketplace", "remove", MARKETPLACE]);
|
|
399
|
+
} catch {}
|
|
400
|
+
install();
|
|
401
|
+
}
|
|
402
|
+
console.log(`Claude Code: the thread mark (${MOD}), a band above the prompt in shared threads, from ${MOD_DIR}.`);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const reason = String(error.stderr || error.message).trim().split("\n").pop();
|
|
405
|
+
console.log(`Claude Code: the thread mark was not installed (${reason}); /peer works without it.`);
|
|
406
|
+
console.log(` To add it later: claude plugin install ${MOD} --marketplace "${MOD_DIR}"`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function uninstallMark() {
|
|
411
|
+
for (const args of [["plugin", "uninstall", `${MOD}@${MARKETPLACE}`], ["plugin", "marketplace", "remove", MARKETPLACE]]) {
|
|
412
|
+
try {
|
|
413
|
+
claudeCli(args);
|
|
414
|
+
} catch {}
|
|
415
|
+
}
|
|
360
416
|
}
|
|
361
417
|
|
|
362
418
|
function uninstall() {
|
|
363
419
|
const settings = readJson(SETTINGS);
|
|
364
420
|
if (settings) writeJson(SETTINGS, withoutOurHooks(settings), 0o644);
|
|
365
421
|
for (const name of Object.keys(COMMAND_FILES)) fs.rmSync(path.join(COMMANDS, name), { force: true });
|
|
366
|
-
|
|
422
|
+
uninstallMark();
|
|
423
|
+
console.log("Claude Code: Peer's hooks, commands and thread mark removed.");
|
|
367
424
|
}
|
|
368
425
|
|
|
369
426
|
// ---- signing in ----
|
|
@@ -455,8 +512,11 @@ async function doctor() {
|
|
|
455
512
|
}
|
|
456
513
|
|
|
457
514
|
try {
|
|
458
|
-
const version = execFileSync(
|
|
515
|
+
const version = execFileSync(CLAUDE_BIN, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
459
516
|
ok(`Claude Code ${version} (Peer is tested with 2.1.316)`);
|
|
517
|
+
const mark = JSON.parse(claudeCli(["plugin", "list", "--json"])).find((p) => p.id === `${MOD}@${MARKETPLACE}`);
|
|
518
|
+
if (mark?.enabled) ok(`the thread mark (${MOD}) is installed`);
|
|
519
|
+
else console.log(` -- the thread mark (${MOD}, a band above the prompt) is ${mark ? "turned off" : "not installed"}: \`peer install\` adds it`);
|
|
460
520
|
} catch {
|
|
461
521
|
console.log(" ? `claude` is not on this terminal's PATH, so its version is unknown");
|
|
462
522
|
}
|
|
@@ -466,8 +526,10 @@ async function doctor() {
|
|
|
466
526
|
for (const [event, groups] of Object.entries(settings?.hooks ?? {})) {
|
|
467
527
|
for (const group of groups) for (const h of group.hooks ?? []) if (String(h.command ?? "").includes(HOOK_TAG)) ours.push({ event, ...h });
|
|
468
528
|
}
|
|
469
|
-
const events =
|
|
470
|
-
const missing =
|
|
529
|
+
const events = { prompt: "UserPromptSubmit", stop: "Stop", end: "SessionEnd", start: "SessionStart" };
|
|
530
|
+
const missing = Object.entries(events)
|
|
531
|
+
.filter(([name, event]) => !ours.some((h) => h.event === event && String(h.command).includes(`hook ${name} `)))
|
|
532
|
+
.map(([, event]) => event);
|
|
471
533
|
if (!settings) bad(`no ${SETTINGS}: run \`peer install\``);
|
|
472
534
|
else if (missing.length) bad(`${SETTINGS} lacks Peer's ${missing.join(", ")} hook: run \`peer install\``);
|
|
473
535
|
else ok(`hooks in ${SETTINGS}`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "webinson-peer",
|
|
3
|
+
"owner": { "name": "Webinson" },
|
|
4
|
+
"plugins": [
|
|
5
|
+
{
|
|
6
|
+
"name": "peer-thread-mark",
|
|
7
|
+
"source": "./peer-thread-mark",
|
|
8
|
+
"description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { Shared } from '../types'
|
|
5
|
+
|
|
6
|
+
// The peer CLI's hook writes ~/.peer/sessions/<session id> on `/peer` and removes it on `/peer stop`;
|
|
7
|
+
// that file alone says whether this thread is shared, and in which workspace.
|
|
8
|
+
const shared = atom({ plugin: 'peer-thread-mark', key: 'shared' } as const, null)
|
|
9
|
+
|
|
10
|
+
async function peerHome($: EngineInterface) {
|
|
11
|
+
return (await $.env.get('PEER_HOME')) || `${await $.env.get('HOME')}/.peer`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function refresh($: EngineInterface) {
|
|
15
|
+
const home = await peerHome($)
|
|
16
|
+
const before = await read($, shared)
|
|
17
|
+
let now: Shared
|
|
18
|
+
try {
|
|
19
|
+
const marker = JSON.parse(await $.fs.read(`${home}/sessions/${await $.session.id()}`))
|
|
20
|
+
now = { workspace: String(marker.workspace ?? 'Peer'), isShared: true }
|
|
21
|
+
} catch {
|
|
22
|
+
// Not shared, or not readable: keep the band and the workspace it last showed, so stop and start differ
|
|
23
|
+
// only in state. A thread never shared on a computer without Peer stays without a band.
|
|
24
|
+
now = before ? { workspace: before.workspace, isShared: false } : null
|
|
25
|
+
}
|
|
26
|
+
if (before?.workspace !== now?.workspace || before?.isShared !== now?.isShared) {
|
|
27
|
+
await update($, shared, () => now)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The prompt hook exactly as `peer install` put it in Claude Code's settings, split into its arguments.
|
|
32
|
+
async function promptHook($: EngineInterface): Promise<string[] | undefined> {
|
|
33
|
+
const claude = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await $.env.get('HOME')}/.claude`
|
|
34
|
+
const settings = JSON.parse(await $.fs.read(`${claude}/settings.json`))
|
|
35
|
+
for (const group of settings.hooks?.UserPromptSubmit ?? []) {
|
|
36
|
+
for (const hook of group.hooks ?? []) {
|
|
37
|
+
const command = String(hook.command ?? '')
|
|
38
|
+
if (!command.includes('# peer-hook') || !/\bhook prompt\b/.test(command)) continue
|
|
39
|
+
return [...command.split('#')[0].matchAll(/"([^"]*)"|(\S+)/g)].map(m => m[1] ?? m[2])
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A press does what typing the command does: the CLI's own hook shares or stops the thread and says so.
|
|
46
|
+
// (A plugin may not submit a prompt that starts with `/`, so the hook is run directly.)
|
|
47
|
+
async function send($: EngineInterface, prompt: string) {
|
|
48
|
+
try {
|
|
49
|
+
const argv = await promptHook($)
|
|
50
|
+
if (!argv) {
|
|
51
|
+
$.ui.toast('Peer: no prompt hook in Claude Code settings; run `peer install`')
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
const input = { session_id: await $.session.id(), cwd: await $.env.get('PWD'), prompt }
|
|
55
|
+
const { stdout } = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: 10_000 })
|
|
56
|
+
const reason = JSON.parse(stdout.trim().split('\n').pop() || '{}').reason
|
|
57
|
+
if (reason) $.ui.toast(String(reason))
|
|
58
|
+
} catch (error) {
|
|
59
|
+
$.ui.toast(`Peer: ${error instanceof Error ? error.message : String(error)}`)
|
|
60
|
+
}
|
|
61
|
+
await refresh($)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const register: Register = on => {
|
|
65
|
+
// A session that loaded the mod after it started (/reload-plugins, an install) has no state yet: the first
|
|
66
|
+
// drawing looks once, so the band shows without waiting for the next prompt.
|
|
67
|
+
let looked = false
|
|
68
|
+
on('session.start', async ($, e, next) => {
|
|
69
|
+
const result = await next(e)
|
|
70
|
+
// Nothing under the prompt: clear the line an earlier version pinned there.
|
|
71
|
+
$.ui.status(undefined)
|
|
72
|
+
await refresh($).catch(() => {})
|
|
73
|
+
return result
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// The CLI's prompt hook writes or removes the marker while a typed command goes in, so look after it.
|
|
77
|
+
on('prompt.submit', async ($, e, next) => {
|
|
78
|
+
const result = await next(e)
|
|
79
|
+
await refresh($).catch(() => {})
|
|
80
|
+
return result
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
on('turn.complete', async ($, e, next) => {
|
|
84
|
+
await refresh($).catch(() => {})
|
|
85
|
+
return next(e)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
89
|
+
if (!looked) {
|
|
90
|
+
looked = true
|
|
91
|
+
await refresh($).catch(() => {})
|
|
92
|
+
}
|
|
93
|
+
const now = await read($, shared)
|
|
94
|
+
if (e.props.hasSurvey || now === null) return next(e)
|
|
95
|
+
|
|
96
|
+
const { Box, Button, Text } = $.ui.resolve(e)
|
|
97
|
+
// One band in both states: the label's own surface says the state (accent when shared, gray when not),
|
|
98
|
+
// and the one control changes in place, stop to start and back.
|
|
99
|
+
return (
|
|
100
|
+
<Box>
|
|
101
|
+
<Text backgroundColor={now.isShared ? 'suggestion' : 'inactive'} color="inverseText" bold>
|
|
102
|
+
{' Peer '}
|
|
103
|
+
</Text>
|
|
104
|
+
<Text bold={now.isShared} dimColor={!now.isShared}>{now.workspace ? ` ${now.workspace} ` : ' '}</Text>
|
|
105
|
+
<Button
|
|
106
|
+
key="toggle"
|
|
107
|
+
label={now.isShared ? '/peer stop' : '/peer start'}
|
|
108
|
+
plain
|
|
109
|
+
dimColor
|
|
110
|
+
onPress={() => send($, now.isShared ? '/peer stop' : '/peer')}
|
|
111
|
+
/>
|
|
112
|
+
</Box>
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Whether this thread is shared, in which Peer workspace; null when Peer is not signed in on this computer
|
|
2
|
+
// (the band then draws nothing).
|
|
3
|
+
export type Shared = { workspace: string; isShared: boolean } | null
|
|
4
|
+
|
|
5
|
+
declare module 'claude-code' {
|
|
6
|
+
interface PluginState {
|
|
7
|
+
'peer-thread-mark': { shared: Shared }
|
|
8
|
+
}
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webinson/peer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
|
+
"mod",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|