@webinson/peer 0.3.1 → 0.4.0

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.
Files changed (3) hide show
  1. package/README.md +9 -4
  2. package/bin/peer.mjs +49 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,16 +17,21 @@ In a Claude Code thread:
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
19
  - `/peer stop` ends sharing. Ending the session ends it too.
20
- - `/peer-list` lists the people who share threads, each thread's state and what it works on,
21
- and the threads that stopped in the last hours.
20
+ - A shared thread's title starts with `● Peer · `, so it stands out in Claude Code's session list; the mark comes
21
+ back if Claude Code renames the thread, and `/peer stop` takes it off.
22
+ - `/peer-list` lists the shared threads by repository, then by person, with each thread's branch and
23
+ state, and the threads that stopped in the last hours.
22
24
 
23
25
  `/peer`, `/peer stop` and `/peer-list` are answered by the hook before the prompt reaches the model,
24
26
  so they cost no tokens. `peer list` prints the same list in a terminal.
25
27
 
26
28
  ## What is shared
27
29
 
28
- For a shared thread: your email, the session id, the repository name and the branch, read from the
29
- checkout, and whether the agent is working, waiting for you or stopped. Never prompts, answers or
30
+ For a shared thread: your email, the session id, the repository name, the repository's key and the branch,
31
+ read from the checkout, and whether the agent is working, waiting for you or stopped. The key is the
32
+ `origin` address without credentials or protocol (for example `github.com-acme-shop`); every clone, branch
33
+ and worktree of one repository has the same key, so `/peer-list` shows teammates' threads in the same
34
+ repository together, whoever shared them. A fork has its own address and so its own key. Never prompts, answers or
30
35
  summaries. Other threads send nothing: their hooks find no mark for the session and exit.
31
36
 
32
37
  The list shows work, not people: no online times and no hours. Text in it comes from teammates (a
package/bin/peer.mjs CHANGED
@@ -118,7 +118,9 @@ function workingOn(cwd) {
118
118
  const remote = git(cwd, "remote", "get-url", "origin");
119
119
  const repository = remote ? remote.replace(/\.git$/, "").split(/[/:]/).pop() : path.basename(top);
120
120
  const branch = git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
121
- return { repository, branch: branch && branch !== "HEAD" ? branch : undefined };
121
+ // The same key for every clone, branch and worktree of one repository: its normalized origin URL.
122
+ const repoKey = remote ? repositoryPeer(cwd) : undefined;
123
+ return { repository, branch: branch && branch !== "HEAD" ? branch : undefined, repoKey };
122
124
  }
123
125
 
124
126
  async function report(saved, session, state, cwd) {
@@ -138,8 +140,9 @@ function ago(at) {
138
140
  // Teammates' text: one line, no control characters, so a branch name cannot redraw the terminal.
139
141
  const plain = (text) => String(text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 120);
140
142
 
141
- function describe(thread) {
142
- const what = [thread.repository, thread.branch].filter(Boolean).map(plain).join(" @ ") || "unknown checkout";
143
+ function describe(thread, withRepository = true) {
144
+ const parts = withRepository ? [thread.repository, thread.branch] : [thread.branch || "(no branch)"];
145
+ const what = parts.filter(Boolean).map(plain).join(" @ ") || "unknown checkout";
143
146
  const status =
144
147
  thread.status === "active"
145
148
  ? thread.working
@@ -157,14 +160,28 @@ async function listText(saved) {
157
160
  if (!list.people.length) {
158
161
  lines.push("", "Nobody shares a thread right now. Only threads where someone typed /peer appear here.");
159
162
  }
163
+ // One project per repository: threads from any clone, branch or worktree of it carry the same key.
164
+ const projects = new Map();
160
165
  for (const person of list.people) {
161
- const running = person.threads.filter((t) => t.status !== "stopped");
162
- const stopped = person.threads.filter((t) => t.status === "stopped");
163
- lines.push("", `${plain(person.email)}${person.email === saved.email ? " (you)" : ""}`);
164
- for (const thread of running) lines.push(` - ${describe(thread)}`);
165
- if (stopped.length) {
166
- lines.push(" recently stopped:");
167
- for (const thread of stopped) lines.push(` - ${describe(thread)}`);
166
+ for (const thread of person.threads) {
167
+ const key = thread.repoKey || `name:${thread.repository || "unknown"}`;
168
+ if (!projects.has(key)) projects.set(key, { name: thread.repository || "unknown", repoKey: thread.repoKey, people: new Map() });
169
+ const people = projects.get(key).people;
170
+ if (!people.has(person.email)) people.set(person.email, []);
171
+ people.get(person.email).push(thread);
172
+ }
173
+ }
174
+ for (const project of projects.values()) {
175
+ lines.push("", `${plain(project.name)} (${project.repoKey ? plain(project.repoKey) : "no repository key: an older Peer"})`);
176
+ for (const [email, threads] of project.people) {
177
+ const running = threads.filter((t) => t.status !== "stopped");
178
+ const stopped = threads.filter((t) => t.status === "stopped");
179
+ lines.push(` ${plain(email)}${email === saved.email ? " (you)" : ""}`);
180
+ for (const thread of running) lines.push(` - ${describe(thread, false)}`);
181
+ if (stopped.length) {
182
+ lines.push(" recently stopped:");
183
+ for (const thread of stopped) lines.push(` - ${describe(thread, false)}`);
184
+ }
168
185
  }
169
186
  }
170
187
  lines.push(
@@ -184,11 +201,22 @@ async function readStdin() {
184
201
 
185
202
  // A hook answers Claude Code with JSON on stdout; `block` keeps the prompt from reaching the model.
186
203
  const answer = (value) => process.stdout.write(`${JSON.stringify(value)}\n`);
187
- const block = (reason) => {
204
+ const block = (reason, sessionTitle) => {
188
205
  log(`answered ${reason.split("\n")[0].slice(0, 80)}`);
189
- answer({ decision: "block", reason });
206
+ answer({ decision: "block", reason, ...(sessionTitle ? { hookSpecificOutput: { hookEventName: "UserPromptSubmit", sessionTitle } } : {}) });
190
207
  };
191
208
 
209
+ // A shared thread's title starts with this mark, so it stands out in the session list; `/peer stop` takes it off.
210
+ const TITLE_MARK = "● Peer · ";
211
+
212
+ // The title the thread should have now, or undefined when it already has it (or Claude Code sent none to keep).
213
+ function retitle(input, isShared, fallback) {
214
+ const current = String(input.session_title ?? "");
215
+ const base = current.startsWith(TITLE_MARK) ? current.slice(TITLE_MARK.length) : current;
216
+ const wanted = isShared ? `${TITLE_MARK}${base || fallback || "shared thread"}` : base;
217
+ return wanted && wanted !== current ? wanted : undefined;
218
+ }
219
+
192
220
  function log(line) {
193
221
  try {
194
222
  fs.mkdirSync(HOME, { recursive: true, mode: 0o700 });
@@ -226,9 +254,9 @@ async function hook(event) {
226
254
  }
227
255
  if (argument === "stop") {
228
256
  fs.rmSync(marker, { force: true });
229
- if (!shared) return block("Peer: this thread was not shared.");
257
+ if (!shared) return block("Peer: this thread was not shared.", retitle(input, false));
230
258
  await report(saved, session, "stopped", input.cwd).catch(() => {});
231
- return block("Peer: this thread is no longer shared.");
259
+ return block("Peer: this thread is no longer shared.", retitle(input, false));
232
260
  }
233
261
  // `/peer <message>` shares the thread and lets the message through: the command hands it to the model.
234
262
  writeJson(marker, { workspace: saved.workspace, sharedAt: new Date().toISOString() });
@@ -238,11 +266,16 @@ async function hook(event) {
238
266
  let hubError;
239
267
  await report(saved, session, argument ? "working" : "idle", input.cwd).catch((error) => (hubError = error));
240
268
  const said = hubError ? `Peer: shared, but the hub did not answer yet (${hubError.message}). The next turn tries again.` : sharedText;
241
- if (!argument) return block(said);
269
+ const sessionTitle = retitle(input, true, what);
270
+ if (!argument) return block(said, sessionTitle);
242
271
  log("answered /peer with a message: shared, message passed on");
243
- return answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `${SHARED_NOTE}\n${said}` } });
272
+ return answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: `${SHARED_NOTE}\n${said}`, ...(sessionTitle ? { sessionTitle } : {}) } });
244
273
  }
245
274
  if (shared) await reportQuietly(session, "working", input.cwd);
275
+ // Claude Code may name a thread after its first prompts; the mark goes back on, or comes off a thread
276
+ // that is no longer shared.
277
+ const sessionTitle = input.session_title ? retitle(input, shared, shared ? workingOn(input.cwd).repository : "") : undefined;
278
+ if (sessionTitle) answer({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", sessionTitle } });
246
279
  return;
247
280
  }
248
281
  if (!shared) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webinson/peer",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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",