@webinson/peer 0.3.0 → 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 +11 -5
- package/bin/peer.mjs +64 -17
- 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
|
|
@@ -70,7 +75,8 @@ printf '%s' "$YOUR_TEAM_KEY" | peer team connect https://memory.example.com --as
|
|
|
70
75
|
The key comes on stdin and is saved in `~/.peer/team-memory/member.json` (mode 0600). After that, the first
|
|
71
76
|
prompt of every thread in that repository (and the first after a resume or compaction) gets at most five
|
|
72
77
|
teammates' published findings that match it: verified or observed, not your own, about 1,500 tokens,
|
|
73
|
-
fenced as data from colleagues
|
|
78
|
+
fenced as data from colleagues; a page whose paths changed in three or more commits since it was last
|
|
79
|
+
confirmed comes marked "may be stale". The prompt's first 400 characters go to the team server as the search
|
|
74
80
|
query. Nothing else of the thread is sent. `peer team status` shows the connection and the last recalls.
|
|
75
81
|
|
|
76
82
|
## Other commands
|
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;
|
|
@@ -552,6 +585,17 @@ function teamLog(line) {
|
|
|
552
585
|
} catch {}
|
|
553
586
|
}
|
|
554
587
|
|
|
588
|
+
// How much the code a page is about has changed since the page was last confirmed: commits in this checkout
|
|
589
|
+
// that touched its paths after that date. Pages without paths, or outside a git checkout, count as 0.
|
|
590
|
+
const STALE_AFTER = 3;
|
|
591
|
+
function changesSince(cwd, paths, date) {
|
|
592
|
+
if (!paths.length || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return 0;
|
|
593
|
+
const top = git(cwd, "rev-parse", "--show-toplevel");
|
|
594
|
+
if (!top) return 0;
|
|
595
|
+
const log = git(top, "log", `--since=${date}T23:59:59`, "--format=%h", "--", ...paths);
|
|
596
|
+
return log ? log.split("\n").filter(Boolean).length : 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
555
599
|
async function teamHook(event) {
|
|
556
600
|
const input = await readStdin();
|
|
557
601
|
const team = readJson(TEAM_FILE);
|
|
@@ -594,7 +638,10 @@ async function teamHook(event) {
|
|
|
594
638
|
const date = text.match(/last_confirmed: (\d{4}-\d{2}-\d{2})/)?.[1] ?? "";
|
|
595
639
|
const body = text.split("\n").find((line, i) => i > 2 && line.trim() && !/^(kind|paths|contributors):/.test(line)) ?? "";
|
|
596
640
|
const by = author === "team" ? contributors.join(", ") : author;
|
|
597
|
-
const
|
|
641
|
+
const paths = (text.match(/^paths: (.+)$/m)?.[1] ?? "").split(/,\s*/).filter((p) => p && !p.includes(".."));
|
|
642
|
+
const changed = changesSince(cwd, paths, date);
|
|
643
|
+
const stale = changed >= STALE_AFTER ? ` · may be stale: ${changed} commits touched its paths since ${date}, check before relying on it` : "";
|
|
644
|
+
const entry = `- [${status} ${kind}${stale}] ${title}\n ${body.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, 600)}\n (${plain(by)}, ${date}, ${hit.uri})`;
|
|
598
645
|
if (used + entry.length > 6000) break;
|
|
599
646
|
used += entry.length;
|
|
600
647
|
entries.push(entry);
|