@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 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. Ending the session ends it too.
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 for 2 hours.
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 two commands)
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
- console.log("Claude Code: Peer's hooks and commands removed.");
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("claude", ["--version"], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"] }).trim();
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 = ["UserPromptSubmit", "Stop", "SessionEnd"];
470
- const missing = events.filter((e) => !ours.some((h) => h.event === e));
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,7 @@
1
+ {
2
+ "name": "peer-thread-mark",
3
+ "version": "0.4.2",
4
+ "description": "Marks a Claude Code thread shared with Peer (/peer) with a band above the prompt, with /peer stop and /peer start",
5
+ "license": "MIT",
6
+ "types": "./types/index.d.ts"
7
+ }
@@ -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.0",
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": {