@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.
- package/README.md +9 -4
- package/bin/peer.mjs +49 -16
- 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
|
-
-
|
|
21
|
-
|
|
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,
|
|
29
|
-
checkout, and whether the agent is working, waiting for you or stopped.
|
|
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
|
-
|
|
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
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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;
|