@semanaut/zpi-peer 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,38 @@
1
- # Temporary Holding Version
1
+ # @semanaut/zpi-peer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Equal, durable pi peers plus a letter channel between any pi sessions, for [pi](https://github.com/earendil-works/pi).
4
+
5
+ Peers are equal instances, not subordinates: each runs in its own tmux pane, with its own context, its own token budget, and its own mistakes. tmux is the single source of truth for who is alive — every peer pane carries an `@pi_sid` label, and a session is considered alive exactly while a live pane still holds its label. When the pane dies, the session id goes dark but its history stays durable on disk, so the peer can always be brought back.
6
+
7
+ ## Management via tmux
8
+
9
+ - **Spawn**: a peer is launched in a new tmux pane with a pre-assigned sessionId. `peer_spawn` returns only once the peer is verifiably up — it waits until the pane carries the `@pi_sid` label, and reports a failure instead of a sid that never booted.
10
+ - **Liveness**: `@pi_sid` pane labels are the liveness signal. Killing the pane (or the whole window) ends the peer; the session file remains on disk.
11
+ - **Revive**: a dark session is restarted with `pi --session <sid>` in a fresh pane, restoring its full history. The sid becomes disk-durable once the peer submits its first message; a peer killed before that cannot be resumed.
12
+ - **Normal tmux workflow works**: switch panes/windows, watch a peer work live in its pane, kill it with tmux when done.
13
+
14
+ ## The three tools
15
+
16
+ - **`peer_spawn`** — launch an equal peer in a tmux pane; optional role-prompt file and fork/handoff context.
17
+ - **`peer_list`** — list sessions and their liveness, filterable by creator ("self" = your own peers).
18
+ - **`peer_send_mail`** — deliver a letter to any pi session's mailbox, peers and human-started alike.
19
+
20
+ ## Automatic revival
21
+
22
+ Mailing a dead recipient revives it automatically: the letter is delivered by first relaunching the session via `pi --session <sid>` in a new tmux pane, waiting (up to ~25s) until the pane is verifiably up, then injecting the notice — a running session gets it within ~1.5s (steer while running, followUp when idle). If the revive fails, the letter stays queued and is read at the next start. The mechanism never bypasses pi's own resume logic: no label within the budget (pi exited, or waits at an interactive prompt) counts as a failure.
23
+
24
+ ## Bundled role prompts
25
+
26
+ Three standard role prompts ship with the package. Pass one as `peer_spawn`'s `prompt` parameter to give a peer a persistent role:
27
+
28
+ - **scout** — fast reconnaissance. Maps unknown territory: key directories, entry points, one-line file descriptions. It locates, it does not analyze deeply, and it states its interpretation when the task is ambiguous.
29
+ - **researcher** — directed research. Produces a sourced findings brief on a question that cannot be answered from the code alone; every conclusion cites a file path or URL.
30
+ - **critic** — independent review. An unpolluted second opinion on a plan, diff, or conclusion: answers VERDICT (agree / agree-with-changes / object) plus numbered findings by severity, each with quoted evidence and a concrete suggestion. It never rubber-stamps.
31
+
32
+ All three are read-only by intent: peers launch with the full toolset, and discipline comes from the role file and the task text, not from restrictions. A peer can always mail back.
33
+
34
+ On every start the extension mirrors the role files into the peer data directory (`<agent dir>/peer/roles`); an edited or older copy is preserved as `<name>.backup` before the bundled version is written, so local edits survive and upgrades still land. Any other file path works for a custom role.
35
+
36
+ ## Bundled skill
37
+
38
+ Installs the `peer-orchestrator` skill alongside the extension: the policy for when to spawn peers (only for genuinely parallel, independent work), how to write a self-contained first task, how to choose a peer's starting context, and how to batch follow-up letters.
@@ -0,0 +1,711 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import { Type } from "typebox";
7
+ import { getAgentDir, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
+
9
+ // pi-peer — equal, durable peers in tmux windows + a letter channel between any pi
10
+ // sessions. A session is alive exactly while a live pane carries its @pi_sid label.
11
+
12
+ const NOT_IN_TMUX_SERVER_DOWN =
13
+ "tmux is not running and this pi instance is not inside it ($TMUX is unset). Peers live in windows of the caller's own tmux session, so start tmux and run pi inside it first — without that, peer_spawn / peer_list / peer_send_mail cannot work properly.";
14
+ const NOT_IN_TMUX_SERVER_UP =
15
+ "a tmux server is running, but this pi instance is not inside tmux ($TMUX is unset). Peers live in windows of the caller's own tmux session, so move this pi instance into a tmux pane (restart it inside tmux) first — outside tmux, peer_spawn / peer_list / peer_send_mail cannot work properly.";
16
+ const POLL_MS = 1500;
17
+ const SKILL_GATE =
18
+ "Before calling this tool, read the peer-orchestrator skill.";
19
+ const REVIVE_TIMEOUT_MS = 25_000;
20
+ const SPAWN_BOOT_TIMEOUT_MS = 20_000;
21
+ const LIST_PAGE_SIZE = 40;
22
+ const BATCH_SEP = "\n\n──────── next letter ────────\n\n";
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
24
+ interface PeerRecord {
25
+ creatorSessionId: string;
26
+ /** Absolute role-file path; absent for plain peers. */
27
+ appendPrompt?: string;
28
+ }
29
+
30
+ interface SessionRecord {
31
+ sessionId: string;
32
+ name: string | null;
33
+ cwd: string;
34
+ startedAt: number;
35
+ alive: boolean;
36
+ peer?: PeerRecord;
37
+ }
38
+
39
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
40
+
41
+ /** v7 (time-ordered) to match pi's own session ids. */
42
+ function uuidv7(): string {
43
+ const ts = BigInt(Date.now());
44
+ const b = randomBytes(16);
45
+ b[0] = Number(ts >> 40n);
46
+ b[1] = Number(ts >> 32n);
47
+ b[2] = Number(ts >> 24n);
48
+ b[3] = Number(ts >> 16n);
49
+ b[4] = Number(ts >> 8n);
50
+ b[5] = Number(ts);
51
+ b[6] = (b[6] & 0x0f) | 0x70;
52
+ b[8] = (b[8] & 0x3f) | 0x80;
53
+ const hex = b.toString("hex");
54
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
55
+ }
56
+
57
+ /** First gate of every peer tool: this pi must run inside tmux. A throw reads as "server down". */
58
+ async function tmuxGuard(pi: ExtensionAPI): Promise<string | null> {
59
+ if (process.env.TMUX) return null;
60
+ let serverUp = false;
61
+ try {
62
+ serverUp = (await pi.exec("tmux", ["list-sessions"], {})).code === 0;
63
+ } catch {}
64
+ return serverUp ? NOT_IN_TMUX_SERVER_UP : NOT_IN_TMUX_SERVER_DOWN;
65
+ }
66
+
67
+ function shq(s: string): string {
68
+ return `'${s.replace(/'/g, `'\\''`)}'`;
69
+ }
70
+
71
+ /** The pi invocation for a pane command: works under node, bun and the compiled binary.
72
+ * PI_PEER_CMD, when set, overrides the launcher used for panes
73
+ * (a deployment pin — takes precedence over execPath). */
74
+ function piCommandString(args: string[]): string {
75
+ const script = process.argv[1];
76
+ const launcher =
77
+ script && !script.startsWith("/$bunfs/root/") && fs.existsSync(script)
78
+ ? [process.execPath, script]
79
+ : /^(node|bun)(\.exe)?$/i.test(path.basename(process.execPath))
80
+ ? ["pi"]
81
+ : [process.env.PI_PEER_CMD || process.execPath];
82
+ return [...launcher, ...args].map(shq).join(" ");
83
+ }
84
+
85
+ /** pi + optional first user message. Nothing holds the pane open, so a peer's window closes with its pi. */
86
+ function buildCommand(opts: { args: string[]; firstMessage?: string }): string {
87
+ const msg = opts.firstMessage;
88
+ const positional = msg === undefined || msg === "" ? "" : ` -- ${shq(/^[-@]/.test(msg) ? ` ${msg}` : msg)}`;
89
+ return `${piCommandString(opts.args)}${positional}`;
90
+ }
91
+
92
+ /** pi.exec can THROW instead of returning non-zero — every throw is folded into { ok: false } and never escapes. */
93
+ async function launchPane(
94
+ pi: ExtensionAPI,
95
+ cmdStr: string,
96
+ cwd: string,
97
+ signal?: AbortSignal,
98
+ ): Promise<{ ok: true; pane: string } | { ok: false; message: string }> {
99
+ try {
100
+ // -d: the peer's window must not steal focus from whatever the user is looking at.
101
+ const r = await pi.exec("tmux", ["new-window", "-d", "-P", "-F", "#{pane_id}", "-c", cwd, cmdStr], { signal });
102
+ if (r.code !== 0) return { ok: false, message: (r.stderr || r.stdout).trim() || `tmux exited ${r.code}` };
103
+ return { ok: true, pane: r.stdout.trim().split("\n").pop() ?? "" };
104
+ } catch (e: any) {
105
+ return { ok: false, message: `tmux could not be run: ${String(e?.message ?? e)}` };
106
+ }
107
+ }
108
+
109
+ /** Best-effort pane kill: never throws, ignores abort. */
110
+ async function killPane(pi: ExtensionAPI, pane: string): Promise<void> {
111
+ try {
112
+ await pi.exec("tmux", ["kill-pane", "-t", pane], {}); // no signal: cleanup must run even when aborted
113
+ } catch {}
114
+ }
115
+
116
+ // Every TUI pi labels its own pane with its sessionId; the pane dies with its pi, so the label
117
+ // needs no TTL. print/json/rpc runs must not steal their pane's label.
118
+
119
+ /** pane_id -> @pi_sid of every live pane. THROWS when tmux is unreachable — never silently "nothing is alive". */
120
+ async function paneLabels(pi: ExtensionAPI): Promise<Map<string, string>> {
121
+ const r = await pi.exec("tmux", ["list-panes", "-a", "-F", "#{pane_id}|#{@pi_sid}|#{pane_dead}"], {});
122
+ if (r.code !== 0) {
123
+ const detail = (r.stderr || r.stdout).trim() || `exited ${r.code}`;
124
+ throw new Error(`tmux list-panes failed: ${detail}`);
125
+ }
126
+ const out = new Map<string, string>();
127
+ for (const line of (r.stdout ?? "").split("\n")) {
128
+ const [pane, sid, dead] = line.split("|");
129
+ if (pane && dead === "0") out.set(pane, UUID_RE.test(sid) ? sid : "");
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /** Best effort — without the label this session reads as dead. */
135
+ async function labelPane(pi: ExtensionAPI, sid: string): Promise<void> {
136
+ const pane = process.env.TMUX_PANE;
137
+ if (!pane) return;
138
+ try {
139
+ await pi.exec("tmux", ["set-option", "-p", "-t", pane, "@pi_sid", sid], {});
140
+ } catch {}
141
+ }
142
+
143
+ /** True only once the peer labels its pane (its session_start writes it — proof of boot). A gone pane means pi exited: fail at once. */
144
+ async function waitForBoot(
145
+ pi: ExtensionAPI,
146
+ sid: string,
147
+ pane: string,
148
+ signal?: AbortSignal,
149
+ budgetMs: number = SPAWN_BOOT_TIMEOUT_MS,
150
+ ): Promise<boolean> {
151
+ const deadline = Date.now() + budgetMs;
152
+ while (Date.now() < deadline && !signal?.aborted) {
153
+ let labels: Map<string, string>;
154
+ try {
155
+ labels = await paneLabels(pi);
156
+ } catch {
157
+ return false; // tmux unreachable mid-boot: the boot can no longer be confirmed
158
+ }
159
+ const label = labels.get(pane);
160
+ if (label === sid) return true;
161
+ if (label === undefined) return false;
162
+ await sleep(500);
163
+ }
164
+ return false;
165
+ }
166
+
167
+ const registryDir = () => path.join(getAgentDir(), "peer", "registry");
168
+
169
+ const peerRolesDir = () => path.join(getAgentDir(), "peer", "roles");
170
+ const BUNDLED_ROLES = ["AGENTS.scout.md", "AGENTS.researcher.md", "AGENTS.critic.md"];
171
+ const bundledRolesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "roles");
172
+
173
+ /** Mirror the bundled role files into the peer data directory. An edited or older copy is kept as
174
+ * `<name>.backup` before the bundled version is written, so every start reconciles the roles. */
175
+ function syncRoles(): void {
176
+ try {
177
+ const dir = peerRolesDir();
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ for (const name of BUNDLED_ROLES) {
180
+ const src = path.join(bundledRolesDir, name);
181
+ const dst = path.join(dir, name);
182
+ const bundled = fs.readFileSync(src);
183
+ if (fs.existsSync(dst)) {
184
+ if (fs.readFileSync(dst).equals(bundled)) continue;
185
+ fs.renameSync(dst, `${dst}.backup`);
186
+ }
187
+ fs.copyFileSync(src, dst);
188
+ }
189
+ } catch {}
190
+ }
191
+
192
+ /** Expand a leading ~/ — fs.* and pi's prompt resolution do not. */
193
+ function expandPath(p: string): string {
194
+ if (p === "~") return os.homedir();
195
+ if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
196
+ return p;
197
+ }
198
+
199
+ // Peer identity lives ONLY here, never in session files — a fork cannot pollute it by
200
+ // file copying, and no scan can miss it. The spawner writes before launching, newest-wins.
201
+ function writePeerRecord(sid: string, rec: PeerRecord): void {
202
+ fs.mkdirSync(registryDir(), { recursive: true });
203
+ const norm: PeerRecord = rec.appendPrompt ? { ...rec, appendPrompt: expandPath(rec.appendPrompt) } : rec;
204
+ const file = path.join(registryDir(), `${sid}.json`);
205
+ const tmp = `${file}.tmp`;
206
+ fs.writeFileSync(tmp, JSON.stringify(norm));
207
+ fs.renameSync(tmp, file); // tmp+rename: readers never see a half-written record
208
+ }
209
+
210
+ // Records are write-once, so this cache (negatives included) never goes stale.
211
+ const recordCache = new Map<string, PeerRecord | null>();
212
+
213
+ function readPeerRecord(sid: string): PeerRecord | null {
214
+ const hit = recordCache.get(sid);
215
+ if (hit !== undefined) return hit;
216
+ let rec: PeerRecord | null = null;
217
+ try {
218
+ const raw = JSON.parse(fs.readFileSync(path.join(registryDir(), `${sid}.json`), "utf-8"));
219
+ if (raw && typeof raw.creatorSessionId === "string") rec = raw as PeerRecord;
220
+ } catch {}
221
+ recordCache.set(sid, rec);
222
+ return rec;
223
+ }
224
+
225
+ function deletePeerRecord(sid: string): void {
226
+ try {
227
+ fs.unlinkSync(path.join(registryDir(), `${sid}.json`));
228
+ } catch {}
229
+ recordCache.delete(sid);
230
+ }
231
+
232
+ function listPeerRecords(): Map<string, PeerRecord> {
233
+ const out = new Map<string, PeerRecord>();
234
+ let names: string[];
235
+ try {
236
+ names = fs.readdirSync(registryDir());
237
+ } catch {
238
+ return out;
239
+ }
240
+ for (const n of names) {
241
+ if (!n.endsWith(".json")) continue;
242
+ const sid = n.slice(0, -".json".length);
243
+ const rec = readPeerRecord(sid);
244
+ if (rec) out.set(sid, rec);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ /** Sessions on disk joined with the registry. Uses pi's listAll(): its parser lets the LAST session_info win, naming a fork after itself, not its parent. */
250
+ async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
251
+ const infos = await SessionManager.listAll();
252
+ const alive = new Set((await paneLabels(pi)).values());
253
+ const records: SessionRecord[] = infos.map((info) => {
254
+ const started = info.created.getTime();
255
+ return {
256
+ sessionId: info.id,
257
+ name: info.name ?? null,
258
+ cwd: info.cwd,
259
+ startedAt: Number.isNaN(started) ? info.modified.getTime() : started,
260
+ alive: alive.has(info.id),
261
+ };
262
+ });
263
+ const peers = listPeerRecords();
264
+ for (const r of records) {
265
+ const rec = peers.get(r.sessionId);
266
+ if (rec) r.peer = rec;
267
+ }
268
+ records.sort((a, b) => b.startedAt - a.startedAt);
269
+ return records;
270
+ }
271
+
272
+ // Exact match only: ids are time-ordered UUIDs, so a prefix match is ambiguous.
273
+ function findSessionById(records: SessionRecord[], sessionId: string): SessionRecord | undefined {
274
+ return records.find((r) => r.sessionId === sessionId);
275
+ }
276
+ const fmtTime = (ms: number) => new Date(ms).toISOString().replace("T", " ").slice(0, 16);
277
+
278
+ /** Uniform failed-tool result: "<code>: <detail>" text plus structured { code, message, …extra }. */
279
+ function errResult(code: string, detail: string, extra?: Record<string, unknown>) {
280
+ const message = `${code}: ${detail}`;
281
+ return {
282
+ content: [{ type: "text" as const, text: message }],
283
+ details: { code, message, ...extra },
284
+ isError: true,
285
+ };
286
+ }
287
+
288
+ // Letters: <agentDir>/peer/letters/<recipientSid>/<hex>.md — one file per letter, no lock, no
289
+ // ordering. The recipient's poll injects only the summary + file path (merged into one message)
290
+ // and marks the letter announced by RENAMING it to acknowledged-<name>.md — the filename is the
291
+ // dedup state, so a letter is announced at most once ever. The recipient reads and deletes the
292
+ // file itself.
293
+
294
+ const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
295
+ const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
296
+
297
+ /** Parse a letter's frontmatter header (from + summary), as written by post(). */
298
+ function parseLetterHeader(text: string): { from: string | null; summary: string | null } {
299
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
300
+ if (!m) return { from: null, summary: null };
301
+ const pick = (key: string) => new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(m[1])?.[1]?.trim() || null;
302
+ return { from: pick("from"), summary: pick("summary") };
303
+ }
304
+
305
+ function formatNotice(file: string, text: string): string {
306
+ const { from, summary } = parseLetterHeader(text);
307
+ return [
308
+ `New letter received.`,
309
+ `From: ${from ?? "unknown sender"}`,
310
+ `Summary: ${summary ?? "(no summary header)"}`,
311
+ `Full letter file: "${file}" — read it yourself with the read tool, delete the letter file (rm) after reading so letters won't pile up.`,
312
+ ].join("\n");
313
+ }
314
+
315
+ /** Unique names mean writers never coordinate; tmp+rename means readers never see a half-write. */
316
+ async function post(sid: string, message: string, header: { from: string; summary: string }): Promise<string> {
317
+ const dir = boxDir(sid);
318
+ fs.mkdirSync(dir, { recursive: true });
319
+ const file = path.join(dir, `${randomBytes(8).toString("hex")}.md`);
320
+ const text = `---\nfrom: ${oneLine(header.from)}\nsummary: ${oneLine(header.summary)}\n---\n\n${message}\n`;
321
+ const tmp = `${file}.tmp`;
322
+ fs.writeFileSync(tmp, text);
323
+ fs.renameSync(tmp, file);
324
+ return file;
325
+ }
326
+
327
+ const ACK = "acknowledged-"; // filename prefix: this letter has already been announced
328
+
329
+ /** Order means nothing — one tick delivers them all merged. */
330
+ function pendingLetters(dir: string): string[] {
331
+ try {
332
+ return fs.readdirSync(dir)
333
+ .filter((n) => n.endsWith(".md") && !n.startsWith(ACK))
334
+ .sort();
335
+ } catch {
336
+ return [];
337
+ }
338
+ }
339
+
340
+ /** Returns the renamed path (where the recipient finds it), or null if already deleted. */
341
+ function acknowledge(dir: string, name: string): string | null {
342
+ try {
343
+ fs.renameSync(path.join(dir, name), path.join(dir, `${ACK}${name}`));
344
+ return path.join(dir, `${ACK}${name}`);
345
+ } catch {
346
+ return null;
347
+ }
348
+ }
349
+
350
+ /** Announce waiting letters merged — steer while running, followUp when idle. */
351
+ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): () => void {
352
+ const dir = boxDir(sid);
353
+ let stopped = false;
354
+
355
+ const tick = () => {
356
+ if (stopped) return;
357
+ try {
358
+ const batch: string[] = [];
359
+ for (const name of pendingLetters(dir)) {
360
+ let text: string;
361
+ try {
362
+ text = fs.readFileSync(path.join(dir, name), "utf-8");
363
+ } catch {
364
+ continue; // unreadable right now: retry on a later tick
365
+ }
366
+ const file = acknowledge(dir, name);
367
+ if (!file) continue; // deleted underneath us: already read
368
+ batch.push(formatNotice(file, text));
369
+ }
370
+ if (batch.length === 0) return;
371
+ pi.sendUserMessage(batch.join(BATCH_SEP), { deliverAs: isIdle() ? "followUp" : "steer" });
372
+ } catch {}
373
+ };
374
+
375
+ try {
376
+ fs.mkdirSync(dir, { recursive: true });
377
+ } catch {}
378
+ const timer = setInterval(tick, POLL_MS);
379
+ timer.unref?.();
380
+ // Deferred: a synchronous tick would deliver letters from inside the session_start
381
+ // dispatch — the first turn could start before the role hook has run.
382
+ setTimeout(() => tick(), POLL_MS);
383
+
384
+ return () => {
385
+ stopped = true;
386
+ clearInterval(timer);
387
+ try {
388
+ fs.rmdirSync(dir);
389
+ } catch {}
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Revive via `pi --session <sid>` — never `--session-id` to CONTINUE: it is project-scoped
395
+ * and silently creates an empty same-id session from the wrong cwd. pi's own resume logic is
396
+ * the only judge: no label within the budget (pi exited, or waits at an interactive prompt
397
+ * in the detached pane) → fail and close the window. No diagnosis, no remediation.
398
+ */
399
+ async function revivePeer(pi: ExtensionAPI, sid: string, cwd: string, signal?: AbortSignal): Promise<{ ok: boolean; message: string }> {
400
+ const fail = (stage: string, detail: string) => ({ ok: false, message: `revive failed at ${stage}: ${detail}` });
401
+
402
+ const cmdStr = buildCommand({ args: ["--session", sid] });
403
+ const launched = await launchPane(pi, cmdStr, cwd, signal);
404
+ if (!launched.ok) return fail("tmux", launched.message);
405
+ const pane = launched.pane;
406
+
407
+ const up = await waitForBoot(pi, sid, pane, signal, REVIVE_TIMEOUT_MS);
408
+ if (up) return { ok: true, message: `session revived via pi --session: sessionId=${sid} | pane=${pane}` };
409
+ await killPane(pi, pane);
410
+ return fail(
411
+ "boot",
412
+ signal?.aborted
413
+ ? "revive cancelled while waiting for the session to come up"
414
+ : `the session did not come up within ${REVIVE_TIMEOUT_MS}ms — pi exited, or stopped at an interactive prompt in the detached pane (window closed)`,
415
+ );
416
+ }
417
+
418
+ export default function piPeer(pi: ExtensionAPI) {
419
+ syncRoles();
420
+ const roleDir = peerRolesDir();
421
+ const ROLE_HINT = `Standard roles live in ${roleDir}: AGENTS.scout.md (fast reconnaissance), AGENTS.researcher.md (directed research), AGENTS.critic.md (independent review).`;
422
+
423
+ // On a revive the role must be resolved before any pending letter can start a turn —
424
+ // session_start does both, in that order.
425
+
426
+ // Role file of the current session, null for plain sessions; records store absolute paths.
427
+ let roleSid: string | null = null;
428
+ let appendPromptPath: string | null = null;
429
+ const loadOwnRole = (sid: string) => {
430
+ roleSid = sid;
431
+ appendPromptPath = readPeerRecord(sid)?.appendPrompt ?? null;
432
+ };
433
+
434
+ // A fork keeps the branched session's role; an existing record is never copied over —
435
+ // the spawner's own write (copy, then override) is newer and wins.
436
+ const inheritRoleFromFork = (event: { reason?: string; previousSessionFile?: string }, ownSid: string) => {
437
+ if (event.reason !== "fork" || !event.previousSessionFile) return;
438
+ const file = path.basename(event.previousSessionFile);
439
+ const srcSid = file.endsWith(".jsonl") ? file.slice(0, -".jsonl".length).split("_").pop() : undefined;
440
+ if (!srcSid || fs.existsSync(path.join(registryDir(), `${ownSid}.json`))) return;
441
+ const src = readPeerRecord(srcSid);
442
+ if (!src?.appendPrompt) return;
443
+ writePeerRecord(ownSid, { ...src });
444
+ recordCache.delete(ownSid); // a lookup may already have cached "no record" for this sid
445
+ };
446
+
447
+ let stopLoop: (() => void) | null = null;
448
+
449
+ pi.on("session_start", async (event, ctx) => {
450
+ const sid = ctx.sessionManager.getSessionId();
451
+ inheritRoleFromFork(event, sid);
452
+ loadOwnRole(sid);
453
+ // Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
454
+ if (ctx.mode === "tui") await labelPane(pi, sid);
455
+ stopLoop?.();
456
+ stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
457
+ });
458
+
459
+ // pi persists no system prompt (--append-system-prompt vanishes on resume), so the role is
460
+ // re-applied EVERY turn from the file the registry points at. Identity follows the SESSION,
461
+ // not the process: /new inside a peer's pane runs plain; a fork inherits the branched
462
+ // session's role. Idempotent: skips when already present.
463
+ pi.on("before_agent_start", async (event, ctx) => {
464
+ // Re-resolve on session switch so the role never leaks across sessions.
465
+ const sid = ctx.sessionManager.getSessionId();
466
+ if (sid !== roleSid) loadOwnRole(sid);
467
+ if (!appendPromptPath) return;
468
+ let text: string;
469
+ try {
470
+ text = fs.readFileSync(appendPromptPath, "utf-8").trim();
471
+ } catch {
472
+ return; // role file gone: run without it rather than fail the turn
473
+ }
474
+ if (!text || event.systemPrompt.includes(text)) return;
475
+ return { systemPrompt: `${event.systemPrompt}\n\n${text}` };
476
+ });
477
+
478
+ pi.registerTool({
479
+ name: "peer_spawn",
480
+ label: "Spawn peer",
481
+ description:
482
+ `Spawn an equal pi peer in a tmux pane with a pre-assigned sessionId. ${SKILL_GATE} It returns only once the peer is verifiably up — it blocks up to ~${SPAWN_BOOT_TIMEOUT_MS / 1000}s until the peer labels the pane it was given, and reports a failure instead of handing back a sid that never booted. The returned sid becomes disk-durable a few seconds after return, when the peer submits its first message (pi persists the session file then, not at boot); a peer killed before that leaves no session — its sid goes dark (mail queues, resume reports not found). The task is delivered at launch as the peer's first user message. Use peer_send_mail for follow-up letters. prompt: path to a role file that stays with the peer across every start — ${ROLE_HINT} The peer-orchestrator skill describes what each role does; any other file path works for a custom role. context: omit = empty | fork:<sessionId> = fork that session's full history, and the forked peer also inherits its role unless prompt overrides it | handoff:<text> = custom context text (prepended to the first message).`,
483
+ promptGuidelines: [
484
+ "Use peer_spawn only when the user asked for parallel/independent work or a task clearly warrants a separate context; every letter sent via peer_send_mail costs the recipient one LLM turn.",
485
+ ],
486
+ parameters: Type.Object({
487
+ topic: Type.String({ description: "Session topic; the peer is named '[peer]<topic>'" }),
488
+ task: Type.String({ description: "Self-contained first message; delivered at launch as the peer's first user message" }),
489
+ prompt: Type.Optional(
490
+ Type.String({
491
+ description:
492
+ "Path to a role-prompt file (errors if it does not exist). The role stays with the peer across every start — spawn, mail revive, or a user-run /resume — and the file is re-read per turn, so editing it takes effect without respawning. Identity follows the session: /new inside a peer's pane starts a plain session, while a fork inherits the role of the session it branched from unless this overrides it. ${ROLE_HINT} Any other file path works for a custom role; omit for a plain peer.",
493
+ }),
494
+ ),
495
+ context: Type.Optional(
496
+ Type.String({
497
+ description:
498
+ "fork:<sessionId> | handoff:<text>; omit for empty context. fork: takes the exact lowercase 36-character sessionId (peer_list shows them); the forked peer inherits that session's role",
499
+ }),
500
+ ),
501
+ model: Type.Optional(Type.String({ description: "Override model (provider/model-id)" })),
502
+ }),
503
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
504
+ const fail = (code: string, detail: string) => errResult(code, detail, { ok: false });
505
+
506
+ const tmuxProblem = await tmuxGuard(pi);
507
+ if (tmuxProblem) return fail("no_tmux", tmuxProblem);
508
+
509
+ // Pre-assigned id: --session-id creates exactly this session; the wait after launch only confirms it.
510
+ const sid = uuidv7();
511
+ const name = `[peer]${params.topic}`.slice(0, 60);
512
+
513
+ let mode = "empty";
514
+ let forkSid: string | undefined;
515
+ let inherited: PeerRecord | undefined;
516
+ let payload: string | undefined;
517
+ const ctxArg = (params.context ?? "").trim();
518
+ if (ctxArg) {
519
+ const sep = ctxArg.indexOf(":");
520
+ const kind = sep === -1 ? ctxArg : ctxArg.slice(0, sep);
521
+ const rest = sep === -1 ? "" : ctxArg.slice(sep + 1);
522
+ if (kind === "fork") {
523
+ const src = rest.trim();
524
+ if (!UUID_RE.test(src)) return fail("bad_context", `fork: expects an exact lowercase sessionId, got "${rest}"`);
525
+ const source = findSessionById(await scanAllSessions(pi), src);
526
+ if (!source) return fail("unknown_session", `fork: no session with id ${src} (try peer_list)`);
527
+ mode = "fork";
528
+ forkSid = src;
529
+ inherited = source.peer; // the forked session's registry fields, overridden below
530
+ } else if (kind === "handoff") {
531
+ mode = "handoff";
532
+ payload = rest;
533
+ } else {
534
+ return fail("bad_context", `cannot parse context "${params.context}"; supported: fork:<sessionId> | handoff:<text> | omit for empty`);
535
+ }
536
+ }
537
+
538
+ let appendPrompt: string | undefined;
539
+ if (params.prompt) {
540
+ appendPrompt = expandPath(params.prompt);
541
+ if (!fs.existsSync(appendPrompt)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
542
+ }
543
+ // Newest-wins: a fork copies the source record, this spawn's fields override it.
544
+ writePeerRecord(sid, {
545
+ ...(inherited ?? {}),
546
+ creatorSessionId: ctx.sessionManager.getSessionId(),
547
+ ...(appendPrompt ? { appendPrompt } : {}),
548
+ });
549
+
550
+ // No --append-system-prompt: the role hook re-applies the role file every turn.
551
+ const piArgs: string[] = ["--session-id", sid, "--name", name];
552
+ let parentModel: string | undefined;
553
+ try {
554
+ const m = (ctx as any).model;
555
+ if (m?.id) parentModel = m.provider ? `${m.provider}/${m.id}` : m.id;
556
+ } catch {}
557
+ const model = params.model ?? parentModel;
558
+ if (model) piArgs.push("--model", model);
559
+ if (forkSid) piArgs.push("--fork", forkSid);
560
+
561
+ // Every failure path below removes the record, so a sid that never booted never lingers.
562
+ const launched = await launchPane(
563
+ pi,
564
+ buildCommand({
565
+ args: piArgs,
566
+ firstMessage: payload ? `${payload}\n\n---\n\n${params.task}` : params.task,
567
+ }),
568
+ ctx.cwd,
569
+ signal,
570
+ );
571
+ if (!launched.ok) {
572
+ deletePeerRecord(sid);
573
+ return fail("boot_failed", launched.message);
574
+ }
575
+
576
+ // No sid is reported until the peer labels its pane.
577
+ if (!(await waitForBoot(pi, sid, launched.pane, signal))) {
578
+ const why = signal?.aborted
579
+ ? "spawn cancelled while waiting for the peer to start"
580
+ : `the peer did not come up: its pane vanished, or never labelled the session within ${SPAWN_BOOT_TIMEOUT_MS}ms`;
581
+ deletePeerRecord(sid);
582
+ await killPane(pi, launched.pane);
583
+ return fail("boot_failed", `${why} — pane=${launched.pane} (closed)`);
584
+ }
585
+ const message = `peer launched: ${name} | sessionId=${sid} | context=${mode} | pane=${launched.pane} — the peer is up and the task was delivered as its first message; use peer_send_mail for follow-up letters`;
586
+ return {
587
+ content: [{ type: "text" as const, text: message }],
588
+ details: { ok: true, name, sessionId: sid, pane: launched.pane, contextMode: mode, message },
589
+ };
590
+ },
591
+ });
592
+
593
+ pi.registerTool({
594
+ name: "peer_list",
595
+ label: "List sessions",
596
+ description:
597
+ "List pi sessions, alive or exited. " + SKILL_GATE + " The first line summarises the total count and the current page (40 sessions per page, newest first; pass page for older ones). Then one line per session: name | creator (`created by sessionId: <id>` for a spawned peer, `created by user manually` for a human-started one) | alive | not alive | absolute cwd | YYYY-MM-DD HH:MM | sessionId. parent:'self' filters to peers spawned by this session. To read a session's transcript, resolve its file by sessionId: `find <agentDir>/sessions -name '*_<sessionId>.jsonl'`.",
598
+ parameters: Type.Object({
599
+ parent: Type.Optional(
600
+ Type.String({ description: 'Creator sessionId (exact lowercase 36-character UUID), or "self" for this session; peers only' }),
601
+ ),
602
+ alive: Type.Optional(Type.Boolean({ description: "true = only alive, false = only not alive" })),
603
+ page: Type.Optional(
604
+ Type.Number({ description: `1-based page number, ${LIST_PAGE_SIZE} sessions per page (default: 1)` }),
605
+ ),
606
+ }),
607
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
608
+ const tmuxProblem = await tmuxGuard(pi);
609
+ if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { total: 0 });
610
+ const self = ctx.sessionManager.getSessionId();
611
+ let recs = await scanAllSessions(pi);
612
+ if (params.parent) {
613
+ const creator = params.parent === "self" ? self : params.parent.trim();
614
+ if (!UUID_RE.test(creator)) {
615
+ return errResult(
616
+ "bad_parent",
617
+ `"${params.parent}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
618
+ { total: 0 },
619
+ );
620
+ }
621
+ recs = recs.filter((r) => r.peer?.creatorSessionId === creator);
622
+ }
623
+ if (params.alive !== undefined) recs = recs.filter((r) => r.alive === params.alive);
624
+
625
+ const total = recs.length;
626
+ const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE));
627
+ const page = params.page ?? 1;
628
+ if (!Number.isInteger(page) || page < 1) {
629
+ return errResult(
630
+ "bad_page",
631
+ `"${params.page}" is not a page number. Pass an integer >= 1 (there ${totalPages === 1 ? "is 1 page" : `are ${totalPages} pages`}).`,
632
+ { total, page: 0, totalPages },
633
+ );
634
+ }
635
+ const start = (page - 1) * LIST_PAGE_SIZE;
636
+ const lines = recs.slice(start, start + LIST_PAGE_SIZE).map((r) => {
637
+ const by = r.peer ? `created by sessionId: ${r.peer.creatorSessionId}` : "created by user manually";
638
+ return `${r.name ?? "—"} | ${by} | ${r.alive ? "alive" : "not alive"} | ${r.cwd} | ${fmtTime(r.startedAt)} | ${r.sessionId}`;
639
+ });
640
+ const shown = lines.length;
641
+ const header = `total: ${total} matching session${total === 1 ? "" : "s"} | page ${page}/${totalPages}${shown > 0 ? ` | showing ${start + 1}-${start + shown}` : ""}`;
642
+ const text = shown > 0 ? [header, ...lines].join("\n") : `${header}\n(no matching sessions on this page)`;
643
+ return { content: [{ type: "text", text }], details: { total, page, totalPages } };
644
+ },
645
+ });
646
+
647
+ pi.on("session_shutdown", async () => {
648
+ stopLoop?.();
649
+ stopLoop = null;
650
+ });
651
+
652
+ pi.registerTool({
653
+ name: "peer_send_mail",
654
+ label: "Send letter",
655
+ description:
656
+ `Deliver a letter to another session's mailbox (ANY pi session — peers and human-started). ${SKILL_GATE} The letter is stored as a .md file whose frontmatter header (from + summary) you write via the summary parameter. The recipient's poll injects ONLY the summary plus the file path — never the full text; the recipient reads the full letter from that path itself and deletes the file afterwards. Notices reach a running session within ~${POLL_MS / 1000}s — via steer while it is running, via followUp when it is idle — and multiple pending notices are merged into one message. A dead recipient is revived automatically, whether it was spawned as a peer or started by a human (this tool blocks up to ~${REVIVE_TIMEOUT_MS / 1000}s and reports whether the revive succeeded), and reads the notice at boot; if the revive fails the letter stays queued and is read when it next starts. The summary must be self-contained: state who you are (name/sessionId) and what the letter is about; the letter body should also say whether a reply is needed and where to send it.`,
657
+ promptGuidelines: [
658
+ "Batch peer_send_mail letters and keep them substantive: never send acknowledgements or small talk. Multiple pending notices are merged into one message, so one letter per topic is enough. Write a good summary — it is all the recipient sees before deciding to open the letter file.",
659
+ ],
660
+ parameters: Type.Object({
661
+ to: Type.String({
662
+ description:
663
+ "Recipient sessionId: the exact lowercase 36-character UUID as peer_list prints it. May target a session that is not on disk yet (e.g. a just-spawned peer). Prefixes, a different case and any other variant are rejected — an id is never normalised into a match.",
664
+ }),
665
+ summary: Type.String({
666
+ description:
667
+ "One-paragraph summary written into the letter's header and injected into the recipient (the full text is NOT injected): your identity (name/sessionId) + the letter's main content/ask. Keep it to one or two lines.",
668
+ }),
669
+ message: Type.String({ description: "Full letter text; stored in the letter file for the recipient to read" }),
670
+ }),
671
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
672
+ const tmuxProblem = await tmuxGuard(pi);
673
+ if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { state: "no_tmux" });
674
+ // A full id not on disk yet is fine — it may be a just-spawned peer still booting.
675
+ const sid = params.to.trim();
676
+ if (!UUID_RE.test(sid)) {
677
+ return errResult(
678
+ "bad_sessionId",
679
+ `"${params.to}" is not a sessionId. Pass the exact lowercase 36-character UUID as peer_list prints it.`,
680
+ { state: "bad_sessionId" },
681
+ );
682
+ }
683
+ const records = await scanAllSessions(pi);
684
+ const target = findSessionById(records, sid);
685
+ const self = ctx.sessionManager.getSessionId();
686
+ const selfName = findSessionById(records, self)?.name;
687
+ const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
688
+ const letterFile = await post(sid, params.message, { from, summary: params.summary });
689
+ const label = target?.name ?? sid;
690
+ const done = (state: string, text: string) => ({
691
+ content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
692
+ details: { recipient: label, sessionId: sid, state, letterFile },
693
+ });
694
+
695
+ if (!target) {
696
+ return done("queued_no_session", `sent → ${label} — no session on disk yet; the letter is queued and will be read when it starts`);
697
+ }
698
+ if (target.alive) {
699
+ return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
700
+ }
701
+ // Empty recorded cwd (old sessions) falls back to the sender's.
702
+ const r = await revivePeer(pi, sid, target.cwd || ctx.cwd, signal);
703
+ return r.ok
704
+ ? done("revived", `sent → ${label} — it was not alive and has now been revived (${r.message}); it reads the letter at boot`)
705
+ : done(
706
+ "revive_failed",
707
+ `sent → ${label} — not alive and the revive FAILED (${r.message}); the letter is queued and will be read whenever it next starts`,
708
+ );
709
+ },
710
+ });
711
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@semanaut/zpi-peer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "pi extension: equal, durable pi peers managed through tmux panes plus a letter channel between any pi sessions. Tools: peer_spawn (launch a peer in a tmux pane), peer_list (list peers, alive or exited), peer_send_mail (deliver a letter, reviving dead recipients). Ships scout / researcher / critic role prompts",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "license": "MIT",
10
+ "files": [
11
+ "extensions",
12
+ "roles",
13
+ "skills"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./extensions/*.ts"
18
+ ],
19
+ "skills": [
20
+ "./skills/peer-orchestrator"
21
+ ]
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ }
28
+ }
@@ -0,0 +1,9 @@
1
+ You are an independent critic peer in a flat network of equal pi instances.
2
+
3
+ Rules:
4
+ 1. Read-only. Your value is an unpolluted second opinion, not execution.
5
+ 2. Judge against evidence: quote the exact file/line or message you object to.
6
+ 3. Structure verdicts as: VERDICT (agree / agree-with-changes / object), then numbered
7
+ findings ordered by severity, each with evidence and a concrete suggestion.
8
+ 4. Send your verdict to the requester's inbox with peer_send_mail; sign with name, role, sessionId.
9
+ 5. Do not rubber-stamp. If you find nothing wrong, say what you checked and why it holds.
@@ -0,0 +1,10 @@
1
+ You are a read-only research peer in a flat network of equal pi instances.
2
+
3
+ Rules:
4
+ 1. Do not modify files or run commands with side effects.
5
+ 2. Every conclusion must cite its source (file path or URL).
6
+ 3. When done, send your findings brief to the requester's inbox with peer_send_mail:
7
+ sign the letter with your name, role and sessionId, and state that it is a result report.
8
+ 4. If you are missing information, ask by letter: state clearly that the letter is a question,
9
+ ask exactly one question, and give the sessionId your reply should be sent to.
10
+ 5. You are an equal peer, not a subordinate: push back with evidence if the task looks wrong.
@@ -0,0 +1,9 @@
1
+ You are a scout peer in a flat network of equal pi instances.
2
+
3
+ Rules:
4
+ 1. Read-only and fast: locate, don't analyze in depth.
5
+ 2. Produce a map: key directories, entry points, relevant files with one-line descriptions,
6
+ and where the answers to the task's questions live.
7
+ 3. Every pointer must be a real path you verified exists.
8
+ 4. Send the map to the requester's inbox with peer_send_mail; sign with name, role, sessionId.
9
+ 5. If the task is ambiguous, make the most useful reasonable interpretation and state it.
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: peer-orchestrator
3
+ description: Policy for creating and steering equal pi peers. Load when considering peer_spawn — deciding to run directions in parallel, choosing a peer's starting context, or writing its first task.
4
+ ---
5
+
6
+ # Peer Orchestrator
7
+
8
+ ## Mental model
9
+ Peers are equal instances, not subordinates: you have no control over them, only a launch and a
10
+ messaging channel. Each peer has its own context, its own token budget, its own mistakes.
11
+ Work directly by default; spawn a peer only when the user asked for parallel/independent work,
12
+ or a task clearly warrants a separate context. Never spawn for a single lookup or a file read.
13
+
14
+ ## The three tools
15
+
16
+ ### `peer_spawn(topic, task, prompt?, context?, model?)`
17
+ Creates a peer in its own tmux pane and returns its sessionId immediately — no handshake. The peer
18
+ is named `[peer]<topic>`, receives `task` as its first message, and starts working as soon as it
19
+ boots. Use `peer_send_mail` for everything after that.
20
+
21
+ ### `peer_send_mail(to, summary, message)`
22
+ Delivers a letter to any session's mailbox as a .md file with a frontmatter header (from + summary).
23
+ The recipient's poll injects only the summary plus the file path — never the full text; the recipient
24
+ reads the full letter itself and deletes the file afterwards. Notices reach a running session within
25
+ ~1.5s — via steer while it is running, via followUp when it is idle — and pending notices merge into one
26
+ message. A dead recipient is revived automatically — the call may block up to ~20s and tells you
27
+ whether the revive succeeded; on failure the letter stays queued and is read whenever that session
28
+ next runs.
29
+ The summary is all the recipient sees up front: state who you are (name/sessionId) and what the
30
+ letter is about. The letter body must be self-contained: state whether a reply is needed and where
31
+ to send it. Batch related matters into one letter — every letter costs the recipient a turn.
32
+
33
+ ### `peer_list(parent?, alive?, page?)`
34
+ Lists sessions with their creator, liveness, cwd, and sessionId — newest first, 40 per page; the
35
+ first line summarises the total count and the current page (pass `page` for older ones). Run it
36
+ before spawning: reusing or forking an existing peer beats recreating one. Use `parent: "self"` to
37
+ see only your own peers.
38
+
39
+ ## Writing a spawn
40
+
41
+ ### `topic`
42
+ The peer's name is `[peer]<topic>` (capped at 60 chars). Name the peer after its task or
43
+ responsibility in detail — a reader should understand what the peer does from the topic alone,
44
+ without opening its task. Use a semantic, mission-descriptive name, not a vague handle: e.g.
45
+ `investigate-auth-latency-and-analyze-causes`, `分析8月英伟达股价波动情况以判断未来走势` — not `task1`,
46
+ `helper`, or a bare `refactor-auth`.
47
+
48
+ ### `task`
49
+ The task is the peer's entire brief — it has no other context. State: the goal, the boundaries,
50
+ the expected output format, and where to send the result (your own sessionId; the peer learns it
51
+ from what you write). Make it self-contained: the peer must be able to finish without asking back.
52
+
53
+ ### `context`
54
+ - omit — empty context; cheapest; the default for a self-contained task.
55
+ - `fork:<sessionId>` — inherit that session's full history, and the forked peer also inherits its
56
+ role (unless you pass a different `prompt`, or the source has no role — then nothing is inherited);
57
+ only when the peer truly needs everything you know (expensive).
58
+ - `handoff:<text>` — you write exactly what the peer needs; best token/value ratio for a large
59
+ context. To pass on your own context, write the handoff yourself — do NOT reuse your /compact
60
+ summary: it is a self-continuation artifact and is stale by everything that happened after it.
61
+
62
+ ### `prompt` (role)
63
+ Pass the path to a role file — no need to read it first; `peer_spawn` only checks it exists. The
64
+ role stays with that session across every start (fresh spawn, revive after a letter, or a manual
65
+ `/resume`), and editing the role file takes effect without respawning. Identity follows the
66
+ session, never the process: `/new` inside a peer's pane starts a plain session, while a fork
67
+ inherits the role of the session it branched from (unless you pass `prompt`) — spawn a proper
68
+ peer when a new one is needed.
69
+
70
+ ## The standard roles
71
+ All three are read-only by intent. Peers launch with the full toolset; discipline comes from the
72
+ role file and your task text, not from restrictions. A peer can always mail you back.
73
+
74
+ Pass the path of the role file you want to `peer_spawn`'s prompt parameter; the exact paths of
75
+ the three standard roles are given in the peer_spawn tool description. For a custom role, pass
76
+ the path of any other role file instead.
77
+
78
+ - scout — fast reconnaissance. Use it to map unknown territory: key
79
+ directories, entry points, one-line file descriptions. It locates, it does not analyze deeply,
80
+ and it states its interpretation when your task is ambiguous.
81
+ - researcher — directed research. Use it for a sourced findings brief on a
82
+ question you could not answer from the code alone; every conclusion cites a file path or URL.
83
+ - critic — independent review. Use it for an unpolluted second opinion on a
84
+ plan, diff, or conclusion of yours; it answers VERDICT (agree / agree-with-changes / object)
85
+ plus numbered findings by severity, each with quoted evidence and a concrete suggestion. It
86
+ never rubber-stamps — give it the artifact, not your defense of it.
87
+
88
+ ## Conventions
89
+ - One direction per peer; a peer with two missions does neither well.
90
+ - Tell the peer where to reply (your sessionId) in the task itself.
91
+ - Waiting for a peer's report does not require you to stay active: an incoming letter is injected into your context and starts a new conversation turn by itself, even after your output has ended. Finish your reply and stop; holding the turn open (timed pauses, re-checks, sleep) only burns time and tokens.
92
+ - No acknowledgements or small talk by letter; write only when there is substance.
93
+ - A peer that goes quiet is not stuck — letters merge while it is busy; wait for its report before
94
+ nudging, and nudge with new information, not with "status?".