@webinson/peer 0.4.1 → 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 +3 -0
- package/bin/peer.mjs +48 -3
- 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
|
@@ -18,6 +18,9 @@ In a Claude Code thread:
|
|
|
18
18
|
- `/peer <message>` shares it and passes the message on to the agent as your prompt.
|
|
19
19
|
- `/peer stop` ends sharing. Quitting or restarting Claude Code only pauses it: the thread shows as stopped and
|
|
20
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.
|
|
21
24
|
- A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
|
|
22
25
|
back if Claude Code renames the thread, and `/peer stop` takes it off.
|
|
23
26
|
- `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
|
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
|
|
|
@@ -372,13 +378,49 @@ function install() {
|
|
|
372
378
|
fs.mkdirSync(COMMANDS, { recursive: true });
|
|
373
379
|
for (const [name, text] of Object.entries(COMMAND_FILES)) fs.writeFileSync(path.join(COMMANDS, name), text);
|
|
374
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
|
+
}
|
|
375
416
|
}
|
|
376
417
|
|
|
377
418
|
function uninstall() {
|
|
378
419
|
const settings = readJson(SETTINGS);
|
|
379
420
|
if (settings) writeJson(SETTINGS, withoutOurHooks(settings), 0o644);
|
|
380
421
|
for (const name of Object.keys(COMMAND_FILES)) fs.rmSync(path.join(COMMANDS, name), { force: true });
|
|
381
|
-
|
|
422
|
+
uninstallMark();
|
|
423
|
+
console.log("Claude Code: Peer's hooks, commands and thread mark removed.");
|
|
382
424
|
}
|
|
383
425
|
|
|
384
426
|
// ---- signing in ----
|
|
@@ -470,8 +512,11 @@ async function doctor() {
|
|
|
470
512
|
}
|
|
471
513
|
|
|
472
514
|
try {
|
|
473
|
-
const version = execFileSync(
|
|
515
|
+
const version = execFileSync(CLAUDE_BIN, ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
474
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`);
|
|
475
520
|
} catch {
|
|
476
521
|
console.log(" ? `claude` is not on this terminal's PATH, so its version is unknown");
|
|
477
522
|
}
|
|
@@ -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": {
|