@semanaut/zpi-peer 2.0.3 → 2.5.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 CHANGED
@@ -11,11 +11,18 @@ Peers are equal instances, not subordinates: each runs in its own tmux pane, wit
11
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
12
  - **Normal tmux workflow works**: switch panes/windows, watch a peer work live in its pane, kill it with tmux when done.
13
13
 
14
- ## The three tools
14
+ ## The four tools
15
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.
16
+ - **`peer_spawn(name, task, prompt?, context?, model?)`** — launch an equal peer in a tmux pane. The peer is named `[peer]<name>`; the name is the peer's identity carrier, so make it declare function, responsibility, and state. Optional role-prompt file and fork/handoff context.
17
+ - **`peer_list`** — list sessions and their contact counts, filterable by contact ("self" = the sessions you have spawned or exchanged letters with; any sessionId = who that session communicated with).
18
+ - **`peer_send_mail`** — deliver a letter to any pi session's mailbox, peers and human-started alike; a dead recipient is revived automatically.
19
+ - **`peer_name`** — read or set a session's own name. Omit the argument to read the name as it exists on disk (the last `session_info` entry — the same value every external reader sees); pass a name to set it, effective immediately. The tool never touches another session: renaming another peer goes through a letter, so the peer also learns why its name changed.
20
+
21
+ ## Data layout
22
+
23
+ - `<agent dir>/peer/registry/<sessionId>/role.json` — role record (exists only for sessions with a role; plain peers carry none).
24
+ - `<agent dir>/peer/registry/<sessionId>/contacts.txt` — append-only contact log, one sessionId per line.
25
+ - `<agent dir>/peer/letters/<sessionId>/` — the session's mailbox (`pending-*.md` letters awaiting delivery).
19
26
 
20
27
  ## Compatibility
21
28
 
@@ -41,4 +48,4 @@ On every start the extension mirrors the bundled role files into the peer data d
41
48
 
42
49
  ## Bundled skill
43
50
 
44
- 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.
51
+ Installs the `peer-orchestrator` skill alongside the extension: the policy for when to spawn peers (only for genuinely parallel, independent work), how to name them (the name is the identity carrier — function, responsibility, state — and must be kept in sync as the peer's state changes), how to write a self-contained first task, how to choose a peer's starting context, and how to batch follow-up letters.
@@ -18,13 +18,11 @@ const SKILL_GATE =
18
18
  "Before calling this tool, read the peer-orchestrator skill.";
19
19
  const REVIVE_TIMEOUT_MS = 25_000;
20
20
  const SPAWN_BOOT_TIMEOUT_MS = 20_000;
21
- const LIST_PAGE_SIZE = 40;
22
- const BATCH_SEP = "\n\n──────── next letter ────────\n\n";
21
+ const LIST_PAGE_SIZE = 15;
23
22
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
24
23
  interface PeerRecord {
25
- creatorSessionId: string;
26
- /** Absolute role-file path; absent for plain peers. */
27
- appendPrompt?: string;
24
+ /** Absolute role-file path — a record exists only for sessions WITH a role. */
25
+ appendPrompt: string;
28
26
  }
29
27
 
30
28
  interface SessionRecord {
@@ -32,12 +30,11 @@ interface SessionRecord {
32
30
  name: string | null;
33
31
  cwd: string;
34
32
  startedAt: number;
35
- alive: boolean;
36
33
  peer?: PeerRecord;
34
+ /** Sessions it spawned or exchanged letters with (both directions); ghost ids filtered out. */
35
+ contactIds: string[];
37
36
  }
38
37
 
39
- const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
40
-
41
38
  /** v7 (time-ordered) to match pi's own session ids. */
42
39
  function uuidv7(): string {
43
40
  const ts = BigInt(Date.now());
@@ -54,7 +51,7 @@ function uuidv7(): string {
54
51
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
55
52
  }
56
53
 
57
- /** First gate of every peer tool: this pi must run inside tmux. A throw reads as "server down". */
54
+ /** First gate of the tools that touch tmux (spawn / send_mail): this pi must run inside tmux. A throw reads as "server down". */
58
55
  async function tmuxGuard(pi: ExtensionAPI): Promise<string | null> {
59
56
  if (process.env.TMUX) return null;
60
57
  let serverUp = false;
@@ -68,10 +65,10 @@ function shq(s: string): string {
68
65
  return `'${s.replace(/'/g, `'\\''`)}'`;
69
66
  }
70
67
 
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 {
68
+ /** pi + optional first user message. Works under node, bun and the compiled binary; PI_PEER_CMD,
69
+ * when set, overrides the launcher (a deployment pin). Nothing holds the pane open, so a
70
+ * peer's window closes with its pi. */
71
+ function buildCommand(opts: { args: string[]; firstMessage?: string }): string {
75
72
  const script = process.argv[1];
76
73
  const launcher =
77
74
  script && !script.startsWith("/$bunfs/root/") && fs.existsSync(script)
@@ -79,14 +76,9 @@ function piCommandString(args: string[]): string {
79
76
  : /^(node|bun)(\.exe)?$/i.test(path.basename(process.execPath))
80
77
  ? ["pi"]
81
78
  : [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
79
  const msg = opts.firstMessage;
88
80
  const positional = msg === undefined || msg === "" ? "" : ` -- ${shq(/^[-@]/.test(msg) ? ` ${msg}` : msg)}`;
89
- return `${piCommandString(opts.args)}${positional}`;
81
+ return `${[...launcher, ...opts.args].map(shq).join(" ")}${positional}`;
90
82
  }
91
83
 
92
84
  /** pi.exec can THROW instead of returning non-zero — every throw is folded into { ok: false } and never escapes. */
@@ -113,8 +105,7 @@ async function killPane(pi: ExtensionAPI, pane: string): Promise<void> {
113
105
  } catch {}
114
106
  }
115
107
 
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.
108
+ // A pane's @pi_sid is written by the TUI pi that owns it and dies with it — no TTL needed.
118
109
 
119
110
  /** pane_id -> @pi_sid of every live pane. THROWS when tmux is unreachable — never silently "nothing is alive". */
120
111
  async function paneLabels(pi: ExtensionAPI): Promise<Map<string, string>> {
@@ -131,15 +122,6 @@ async function paneLabels(pi: ExtensionAPI): Promise<Map<string, string>> {
131
122
  return out;
132
123
  }
133
124
 
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
125
  /** 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
126
  async function waitForBoot(
145
127
  pi: ExtensionAPI,
@@ -159,7 +141,7 @@ async function waitForBoot(
159
141
  const label = labels.get(pane);
160
142
  if (label === sid) return true;
161
143
  if (label === undefined) return false;
162
- await sleep(500);
144
+ await new Promise((r) => setTimeout(r, 500));
163
145
  }
164
146
  return false;
165
147
  }
@@ -169,7 +151,6 @@ const registryDir = () => path.join(getAgentDir(), "peer", "registry");
169
151
  const peerRolesDir = () => path.join(getAgentDir(), "peer");
170
152
  const BUNDLED_ROLES = ["AGENTS.investigator.md", "AGENTS.strategist.md", "AGENTS.auditor.md"];
171
153
  const RETIRED_ROLES = ["AGENTS.scout.md", "AGENTS.researcher.md", "AGENTS.critic.md"];
172
- const bundledRolesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
173
154
 
174
155
  /** Mirror the bundled role files (which sit next to the extension's parent directory) directly
175
156
  * into <agentDir>/peer. An edited or older copy is kept as `<name>.backup` before the bundled
@@ -186,7 +167,7 @@ function syncRoles(): void {
186
167
  }
187
168
  for (const name of BUNDLED_ROLES) {
188
169
  try {
189
- const src = path.join(bundledRolesDir, name);
170
+ const src = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", name);
190
171
  const dst = path.join(dir, name);
191
172
  const bundled = fs.readFileSync(src);
192
173
  if (fs.existsSync(dst)) {
@@ -206,18 +187,20 @@ function expandPath(p: string): string {
206
187
  return p;
207
188
  }
208
189
 
209
- // Peer identity lives ONLY here, never in session files — a fork cannot pollute it by
210
- // file copying, and no scan can miss it. The spawner writes before launching, newest-wins.
190
+ // Peer role identity lives ONLY here, never in session files — a fork cannot pollute it by
191
+ // file copying, and no scan can miss it. A record exists only for sessions WITH a role;
192
+ // plain peers carry no role.json at all.
193
+ const sessionDir = (sid: string) => path.join(registryDir(), sid);
194
+ const roleFile = (sid: string) => path.join(sessionDir(sid), "role.json");
195
+
211
196
  function writePeerRecord(sid: string, rec: PeerRecord): void {
212
- fs.mkdirSync(registryDir(), { recursive: true });
213
- const norm: PeerRecord = rec.appendPrompt ? { ...rec, appendPrompt: expandPath(rec.appendPrompt) } : rec;
214
- const file = path.join(registryDir(), `${sid}.json`);
215
- const tmp = `${file}.tmp`;
216
- fs.writeFileSync(tmp, JSON.stringify(norm));
217
- fs.renameSync(tmp, file); // tmp+rename: readers never see a half-written record
197
+ fs.mkdirSync(sessionDir(sid), { recursive: true });
198
+ const tmp = `${roleFile(sid)}.tmp`;
199
+ fs.writeFileSync(tmp, JSON.stringify({ appendPrompt: expandPath(rec.appendPrompt) }));
200
+ fs.renameSync(tmp, roleFile(sid)); // tmp+rename: readers never see a half-written record
218
201
  }
219
202
 
220
- // Records are write-once, so this cache (negatives included) never goes stale.
203
+ // Role records are write-once (the role path never changes), so this cache (negatives included) never goes stale.
221
204
  const recordCache = new Map<string, PeerRecord | null>();
222
205
 
223
206
  function readPeerRecord(sid: string): PeerRecord | null {
@@ -225,41 +208,53 @@ function readPeerRecord(sid: string): PeerRecord | null {
225
208
  if (hit !== undefined) return hit;
226
209
  let rec: PeerRecord | null = null;
227
210
  try {
228
- const raw = JSON.parse(fs.readFileSync(path.join(registryDir(), `${sid}.json`), "utf-8"));
229
- if (raw && typeof raw.creatorSessionId === "string") rec = raw as PeerRecord;
211
+ const raw = JSON.parse(fs.readFileSync(roleFile(sid), "utf-8"));
212
+ if (raw && typeof raw.appendPrompt === "string") rec = { appendPrompt: raw.appendPrompt };
230
213
  } catch {}
231
214
  recordCache.set(sid, rec);
232
215
  return rec;
233
216
  }
234
217
 
235
- function deletePeerRecord(sid: string): void {
218
+ const contactsFile = (sid: string) => path.join(sessionDir(sid), "contacts.txt");
219
+
220
+ /** Append-only contact log: one sessionId per line, both directions written by whoever acts
221
+ * (spawn or letter). A single short O_APPEND write is atomic under the kernel's inode lock, so
222
+ * concurrent writers never overwrite each other — there is no read-modify-write to race. A
223
+ * crash mid-write leaves at most a partial line, which readContacts drops via the UUID check;
224
+ * duplicates are harmless because reads dedupe. */
225
+ function addContact(a: string, b: string): void {
236
226
  try {
237
- fs.unlinkSync(path.join(registryDir(), `${sid}.json`));
238
- } catch {}
239
- recordCache.delete(sid);
227
+ fs.mkdirSync(sessionDir(a), { recursive: true });
228
+ fs.mkdirSync(sessionDir(b), { recursive: true });
229
+ fs.appendFileSync(contactsFile(a), `${b}\n`);
230
+ fs.appendFileSync(contactsFile(b), `${a}\n`);
231
+ } catch {} // best effort: a lost line only narrows a future peer_list filter
240
232
  }
241
233
 
242
- function listPeerRecords(): Map<string, PeerRecord> {
243
- const out = new Map<string, PeerRecord>();
244
- let names: string[];
234
+ function readContacts(sid: string): string[] {
235
+ let raw: string;
245
236
  try {
246
- names = fs.readdirSync(registryDir());
237
+ raw = fs.readFileSync(contactsFile(sid), "utf-8");
247
238
  } catch {
248
- return out;
249
- }
250
- for (const n of names) {
251
- if (!n.endsWith(".json")) continue;
252
- const sid = n.slice(0, -".json".length);
253
- const rec = readPeerRecord(sid);
254
- if (rec) out.set(sid, rec);
239
+ return [];
255
240
  }
256
- return out;
241
+ return [...new Set(raw.split("\n").filter((l) => UUID_RE.test(l)))];
257
242
  }
258
243
 
259
- /** 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. */
244
+ // Removes the whole session folder: spawn-failure paths call this before any contact exists,
245
+ // so the folder holds at most role.json — a sid that never booted never lingers.
246
+ function deletePeerRecord(sid: string): void {
247
+ try {
248
+ fs.rmSync(sessionDir(sid), { recursive: true, force: true });
249
+ } catch {}
250
+ recordCache.delete(sid);
251
+ }
252
+
253
+ /** Sessions on disk joined with the registry — a pure disk read, no tmux. pi's listAll() parser
254
+ * lets the LAST session_info win, naming a fork after itself, not its parent. Liveness is
255
+ * deliberately absent: a pane-level fact, queried per sid only where it matters (send_mail). */
260
256
  async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
261
257
  const infos = await SessionManager.listAll();
262
- const alive = new Set((await paneLabels(pi)).values());
263
258
  const records: SessionRecord[] = infos.map((info) => {
264
259
  const started = info.created.getTime();
265
260
  return {
@@ -267,13 +262,14 @@ async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
267
262
  name: info.name ?? null,
268
263
  cwd: info.cwd,
269
264
  startedAt: Number.isNaN(started) ? info.modified.getTime() : started,
270
- alive: alive.has(info.id),
265
+ contactIds: [],
271
266
  };
272
267
  });
273
- const peers = listPeerRecords();
268
+ const known = new Set(records.map((r) => r.sessionId));
274
269
  for (const r of records) {
275
- const rec = peers.get(r.sessionId);
270
+ const rec = readPeerRecord(r.sessionId);
276
271
  if (rec) r.peer = rec;
272
+ r.contactIds = readContacts(r.sessionId).filter((id) => known.has(id));
277
273
  }
278
274
  records.sort((a, b) => b.startedAt - a.startedAt);
279
275
  return records;
@@ -283,7 +279,40 @@ async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
283
279
  function findSessionById(records: SessionRecord[], sessionId: string): SessionRecord | undefined {
284
280
  return records.find((r) => r.sessionId === sessionId);
285
281
  }
286
- const fmtTime = (ms: number) => new Date(ms).toISOString().replace("T", " ").slice(0, 16);
282
+
283
+ /** This session's file, located the way the tools tell models to: the <timestamp>_<sid>.jsonl file somewhere under <agentDir>/sessions/. */
284
+ function findSessionFile(sid: string): string | null {
285
+ const root = path.join(getAgentDir(), "sessions");
286
+ try {
287
+ for (const dir of fs.readdirSync(root, { withFileTypes: true })) {
288
+ if (!dir.isDirectory()) continue;
289
+ for (const f of fs.readdirSync(path.join(root, dir.name))) {
290
+ if (f.endsWith(`_${sid}.jsonl`)) return path.join(root, dir.name, f);
291
+ }
292
+ }
293
+ } catch {}
294
+ return null;
295
+ }
296
+
297
+ /** The name as every external reader sees it: the LAST session_info entry of the session file.
298
+ * The file is the single source of truth — pi's in-memory copy is deliberately never consulted. */
299
+ function readNameFromFile(sid: string): { name: string | null; file: string | null } {
300
+ const file = findSessionFile(sid);
301
+ if (!file) return { name: null, file: null };
302
+ let name: string | null = null;
303
+ try {
304
+ for (const line of fs.readFileSync(file, "utf-8").split("\n")) {
305
+ if (!line) continue;
306
+ try {
307
+ const entry = JSON.parse(line);
308
+ if (entry?.type === "session_info") name = typeof entry.name === "string" ? entry.name : null;
309
+ } catch {} // a torn trailing line is not a rename; the only writer of the file is pi itself
310
+ }
311
+ } catch {
312
+ return { name: null, file };
313
+ }
314
+ return { name, file };
315
+ }
287
316
 
288
317
  /** Uniform failed-tool result: "<code>: <detail>" text plus structured { code, message, …extra }. */
289
318
  function errResult(code: string, detail: string, extra?: Record<string, unknown>) {
@@ -308,20 +337,14 @@ function errResult(code: string, detail: string, extra?: Record<string, unknown>
308
337
  const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
309
338
  const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
310
339
 
311
- /** Parse a letter's frontmatter header (from + summary), as written by post(). */
312
- function parseLetterHeader(text: string): { from: string | null; summary: string | null } {
313
- const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
314
- if (!m) return { from: null, summary: null };
315
- const pick = (key: string) => new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(m[1])?.[1]?.trim() || null;
316
- return { from: pick("from"), summary: pick("summary") };
317
- }
318
-
319
340
  function formatNotice(file: string, text: string): string {
320
- const { from, summary } = parseLetterHeader(text);
341
+ // frontmatter (from + summary) as written by post()
342
+ const header = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
343
+ const pick = (key: string) => new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(header)?.[1]?.trim() || null;
321
344
  return [
322
345
  `New letter received.`,
323
- `From: ${from ?? "unknown sender"}`,
324
- `Summary: ${summary ?? "(no summary header)"}`,
346
+ `From: ${pick("from") ?? "unknown sender"}`,
347
+ `Summary: ${pick("summary") ?? "(no summary header)"}`,
325
348
  `Full letter file: "${file}" — read it yourself with the read tool, delete the letter file (rm) after reading so letters won't pile up.`,
326
349
  ].join("\n");
327
350
  }
@@ -394,7 +417,7 @@ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): (
394
417
  }
395
418
  if (batch.length === 0) return;
396
419
  pi.sendUserMessage(
397
- batch.map((b) => formatNotice(b.finalPath, b.text)).join(BATCH_SEP),
420
+ batch.map((b) => formatNotice(b.finalPath, b.text)).join("\n\n──────── next letter ────────\n\n"),
398
421
  { deliverAs: isIdle() ? "followUp" : "steer" },
399
422
  );
400
423
  // Strip the pending- prefix — the one-way "notice issued" transition. Reached
@@ -468,16 +491,15 @@ export default function piPeer(pi: ExtensionAPI) {
468
491
  appendPromptPath = readPeerRecord(sid)?.appendPrompt ?? null;
469
492
  };
470
493
 
471
- // A fork keeps the branched session's role; an existing record is never copied over —
472
- // the spawner's own write (copy, then override) is newer and wins.
494
+ // A fork keeps the branched session's role; an existing record (the spawner's write) is never overwritten.
473
495
  const inheritRoleFromFork = (event: { reason?: string; previousSessionFile?: string }, ownSid: string) => {
474
496
  if (event.reason !== "fork" || !event.previousSessionFile) return;
475
497
  const file = path.basename(event.previousSessionFile);
476
498
  const srcSid = file.endsWith(".jsonl") ? file.slice(0, -".jsonl".length).split("_").pop() : undefined;
477
- if (!srcSid || fs.existsSync(path.join(registryDir(), `${ownSid}.json`))) return;
499
+ if (!srcSid || fs.existsSync(roleFile(ownSid))) return;
478
500
  const src = readPeerRecord(srcSid);
479
- if (!src?.appendPrompt) return;
480
- writePeerRecord(ownSid, { ...src });
501
+ if (!src) return;
502
+ writePeerRecord(ownSid, { appendPrompt: src.appendPrompt }); // role only — contacts are never inherited
481
503
  recordCache.delete(ownSid); // a lookup may already have cached "no record" for this sid
482
504
  };
483
505
 
@@ -487,9 +509,14 @@ export default function piPeer(pi: ExtensionAPI) {
487
509
  const sid = ctx.sessionManager.getSessionId();
488
510
  inheritRoleFromFork(event, sid);
489
511
  loadOwnRole(sid);
490
- compactionActive = false; // the fresh session is not compacting
512
+ compactionActive = false;
491
513
  // Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
492
- if (ctx.mode === "tui") await labelPane(pi, sid);
514
+ // Best effort — without the label this session reads as dead.
515
+ if (ctx.mode === "tui" && process.env.TMUX_PANE) {
516
+ try {
517
+ await pi.exec("tmux", ["set-option", "-p", "-t", process.env.TMUX_PANE, "@pi_sid", sid], {});
518
+ } catch {}
519
+ }
493
520
  stopLoop?.();
494
521
  stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
495
522
  });
@@ -539,7 +566,7 @@ export default function piPeer(pi: ExtensionAPI) {
539
566
  "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.",
540
567
  ],
541
568
  parameters: Type.Object({
542
- topic: Type.String({ description: "Session topic; the peer is named '[peer]<topic>'" }),
569
+ name: Type.String({ description: "Session name seed; the peer is named '[peer]<name>'" }),
543
570
  task: Type.String({ description: "Self-contained first message; delivered at launch as the peer's first user message" }),
544
571
  prompt: Type.Optional(
545
572
  Type.String({
@@ -563,11 +590,11 @@ export default function piPeer(pi: ExtensionAPI) {
563
590
 
564
591
  // Pre-assigned id: --session-id creates exactly this session; the wait after launch only confirms it.
565
592
  const sid = uuidv7();
566
- const name = `[peer]${params.topic}`.slice(0, 60);
593
+ const name = `[peer]${params.name}`;
567
594
 
568
595
  let mode = "empty";
569
596
  let forkSid: string | undefined;
570
- let inherited: PeerRecord | undefined;
597
+ let inheritedRole: string | undefined;
571
598
  let payload: string | undefined;
572
599
  const ctxArg = (params.context ?? "").trim();
573
600
  if (ctxArg) {
@@ -581,7 +608,7 @@ export default function piPeer(pi: ExtensionAPI) {
581
608
  if (!source) return fail("unknown_session", `fork: no session with id ${src} (try peer_list)`);
582
609
  mode = "fork";
583
610
  forkSid = src;
584
- inherited = source.peer; // the forked session's registry fields, overridden below
611
+ inheritedRole = source.peer?.appendPrompt;
585
612
  } else if (kind === "handoff") {
586
613
  mode = "handoff";
587
614
  payload = rest;
@@ -590,17 +617,13 @@ export default function piPeer(pi: ExtensionAPI) {
590
617
  }
591
618
  }
592
619
 
593
- let appendPrompt: string | undefined;
620
+ let role: string | undefined;
594
621
  if (params.prompt) {
595
- appendPrompt = expandPath(params.prompt);
596
- if (!fs.existsSync(appendPrompt)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
622
+ role = expandPath(params.prompt);
623
+ if (!fs.existsSync(role)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
597
624
  }
598
- // Newest-wins: a fork copies the source record, this spawn's fields override it.
599
- writePeerRecord(sid, {
600
- ...(inherited ?? {}),
601
- creatorSessionId: ctx.sessionManager.getSessionId(),
602
- ...(appendPrompt ? { appendPrompt } : {}),
603
- });
625
+ if (!role) role = inheritedRole;
626
+ if (role) writePeerRecord(sid, { appendPrompt: role });
604
627
 
605
628
  // No --append-system-prompt: the role hook re-applies the role file every turn.
606
629
  const piArgs: string[] = ["--session-id", sid, "--name", name];
@@ -628,7 +651,6 @@ export default function piPeer(pi: ExtensionAPI) {
628
651
  return fail("boot_failed", launched.message);
629
652
  }
630
653
 
631
- // No sid is reported until the peer labels its pane.
632
654
  if (!(await waitForBoot(pi, sid, launched.pane, signal))) {
633
655
  const why = signal?.aborted
634
656
  ? "spawn cancelled while waiting for the peer to start"
@@ -637,6 +659,7 @@ export default function piPeer(pi: ExtensionAPI) {
637
659
  await killPane(pi, launched.pane);
638
660
  return fail("boot_failed", `${why} — pane=${launched.pane} (closed)`);
639
661
  }
662
+ addContact(ctx.sessionManager.getSessionId(), sid); // a spawn counts as contact
640
663
  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`;
641
664
  return {
642
665
  content: [{ type: "text" as const, text: message }],
@@ -649,33 +672,33 @@ export default function piPeer(pi: ExtensionAPI) {
649
672
  name: "peer_list",
650
673
  label: "List sessions",
651
674
  description:
652
- "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'`.",
675
+ "List pi sessions. " + SKILL_GATE + " The first line summarises the total count and the current page (15 sessions per page, newest first; pass page for older ones). Then one line per session: name | contacts (how many sessions it has spawned or exchanged letters with, either direction) | absolute cwd | YYYY-MM-DD HH:MM | sessionId. contact filters to the sessions that have communicated with one session — pass \"self\" or any exact lowercase 36-character sessionId; a spawn and an exchanged letter both count. To read a session's transcript, resolve its file by sessionId: `find <agentDir>/sessions -name '*_<sessionId>.jsonl'`.",
653
676
  parameters: Type.Object({
654
- parent: Type.Optional(
655
- Type.String({ description: 'Creator sessionId (exact lowercase 36-character UUID), or "self" for this session; peers only' }),
677
+ contact: Type.Optional(
678
+ Type.String({
679
+ description:
680
+ 'Filter to sessions that have communicated with one session: "self" for this session, or the exact lowercase 36-character UUID of any session. A spawn or an exchanged letter counts, in either direction.',
681
+ }),
656
682
  ),
657
- alive: Type.Optional(Type.Boolean({ description: "true = only alive, false = only not alive" })),
658
683
  page: Type.Optional(
659
684
  Type.Number({ description: `1-based page number, ${LIST_PAGE_SIZE} sessions per page (default: 1)` }),
660
685
  ),
661
686
  }),
662
687
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
663
- const tmuxProblem = await tmuxGuard(pi);
664
- if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { total: 0 });
665
688
  const self = ctx.sessionManager.getSessionId();
666
689
  let recs = await scanAllSessions(pi);
667
- if (params.parent) {
668
- const creator = params.parent === "self" ? self : params.parent.trim();
669
- if (!UUID_RE.test(creator)) {
690
+ if (params.contact) {
691
+ const target = params.contact === "self" ? self : params.contact.trim();
692
+ if (!UUID_RE.test(target)) {
670
693
  return errResult(
671
- "bad_parent",
672
- `"${params.parent}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
694
+ "bad_contact",
695
+ `"${params.contact}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
673
696
  { total: 0 },
674
697
  );
675
698
  }
676
- recs = recs.filter((r) => r.peer?.creatorSessionId === creator);
699
+ const ids = new Set(readContacts(target));
700
+ recs = recs.filter((r) => ids.has(r.sessionId));
677
701
  }
678
- if (params.alive !== undefined) recs = recs.filter((r) => r.alive === params.alive);
679
702
 
680
703
  const total = recs.length;
681
704
  const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE));
@@ -689,8 +712,8 @@ export default function piPeer(pi: ExtensionAPI) {
689
712
  }
690
713
  const start = (page - 1) * LIST_PAGE_SIZE;
691
714
  const lines = recs.slice(start, start + LIST_PAGE_SIZE).map((r) => {
692
- const by = r.peer ? `created by sessionId: ${r.peer.creatorSessionId}` : "created by user manually";
693
- return `${r.name ?? "—"} | ${by} | ${r.alive ? "alive" : "not alive"} | ${r.cwd} | ${fmtTime(r.startedAt)} | ${r.sessionId}`;
715
+ const time = new Date(r.startedAt).toISOString().replace("T", " ").slice(0, 16);
716
+ return `${r.name ?? "—"} | contacts: ${r.contactIds.length || "none"} | ${r.cwd} | ${time} | ${r.sessionId}`;
694
717
  });
695
718
  const shown = lines.length;
696
719
  const header = `total: ${total} matching session${total === 1 ? "" : "s"} | page ${page}/${totalPages}${shown > 0 ? ` | showing ${start + 1}-${start + shown}` : ""}`;
@@ -750,13 +773,16 @@ export default function piPeer(pi: ExtensionAPI) {
750
773
  const selfName = findSessionById(records, self)?.name;
751
774
  const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
752
775
  const letterFile = await post(sid, params.message, { from, summary: params.summary });
776
+ addContact(self, sid); // an exchanged letter counts as contact
753
777
  const label = target.name ?? sid;
754
778
  const done = (state: string, text: string) => ({
755
779
  content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
756
780
  details: { recipient: label, sessionId: sid, state, letterFile },
757
781
  });
758
782
 
759
- if (target.alive) {
783
+ // paneLabels throws if tmux died mid-call (we are behind tmuxGuard) — an honest error
784
+ // beats a false "not alive" that would push a live peer down the revive path.
785
+ if ([...(await paneLabels(pi)).values()].includes(sid)) {
760
786
  return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
761
787
  }
762
788
  // Empty recorded cwd (old sessions) falls back to the sender's.
@@ -769,4 +795,49 @@ export default function piPeer(pi: ExtensionAPI) {
769
795
  );
770
796
  },
771
797
  });
798
+
799
+ // Self-only by design: a rename of ANOTHER session is a request, not a write — it goes through a
800
+ // letter (peer_send_mail), so the recipient also learns WHY its name changed. The read path is
801
+ // the file tail on purpose: it is the same value every external reader (peer_list) sees.
802
+ pi.registerTool({
803
+ name: "peer_name",
804
+ label: "Read or set own session name",
805
+ description:
806
+ "Read or set THIS session's name — a peer's identity carrier: it declares what this session does, what it is responsible for, and its current state; peer_list shows it first, and it is often the only thing a reader sees before deciding to mail, fork, or skip. " + SKILL_GATE + " Omit name to read the current name exactly as it exists on disk — the last session_info entry of this session's file, the same value every external reader sees; a session with no name entry reads as unnamed. Pass name to set it — effective immediately for this process and on disk. This tool never touches another session: to change another session's name (dead or alive — peer_send_mail revives a dead recipient), send a letter stating the reason and the new name; the recipient renames itself.",
807
+ promptGuidelines: [
808
+ "A name change is almost always a state change: update your own name with peer_name in the same run that changes your state (retirement, redirection, reassignment) — never rename another session, ask it by letter.",
809
+ ],
810
+ parameters: Type.Object({
811
+ name: Type.Optional(
812
+ Type.String({
813
+ description: "New name for this session — function, responsibility, and state. Omit to read the current name.",
814
+ }),
815
+ ),
816
+ }),
817
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
818
+ const sid = ctx.sessionManager.getSessionId();
819
+ if (params.name === undefined) {
820
+ const { name, file } = readNameFromFile(sid);
821
+ const text = name
822
+ ? `this session's name: ${name}`
823
+ : file
824
+ ? "this session has no name on disk yet — set one with peer_name (function, responsibility, state)."
825
+ : "this session's file is not on disk yet (pi persists it with this session's first submitted message); a name set now takes effect in this process and lands on disk with the file.";
826
+ return { content: [{ type: "text" as const, text }], details: { name, sessionId: sid, file } };
827
+ }
828
+ const name = params.name.trim();
829
+ if (!name) {
830
+ return errResult(
831
+ "bad_name",
832
+ "a name cannot be empty or whitespace-only — pass a non-empty name, or omit it to read the current name.",
833
+ );
834
+ }
835
+ pi.setSessionName(name);
836
+ const message = `session renamed: ${name} — effective immediately for this process and on disk; peer_list and every file reader see it from now on`;
837
+ return {
838
+ content: [{ type: "text" as const, text: message }],
839
+ details: { name, sessionId: sid, message },
840
+ };
841
+ },
842
+ });
772
843
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@semanaut/zpi-peer",
3
- "version": "2.0.3",
3
+ "version": "2.5.0",
4
4
  "piVersion": "1.3.0",
5
- "description": "pi extension: equal, durable pi peers managed through tmux panes plus a letter channel between any pi sessions. tmux runs on Unix-like systems (Linux, macOS, BSD; Windows via WSL/Cygwin); native Windows is not supported. 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 investigator / strategist / auditor role prompts; developed against the pi version given by the piVersion field",
5
+ "description": "pi extension: equal, durable pi peers managed through tmux panes plus a letter channel between any pi sessions. tmux runs on Unix-like systems (Linux, macOS, BSD; Windows via WSL/Cygwin); native Windows is not supported. Tools: peer_spawn (launch a peer in a tmux pane), peer_list (list sessions with their contact counts), peer_send_mail (deliver a letter, reviving dead recipients), peer_name (read or set a session's name — the peer's identity carrier). Ships investigator / strategist / auditor role prompts; developed against the pi version given by the piVersion field",
6
6
  "type": "module",
7
7
  "keywords": [
8
8
  "pi-package"
@@ -1,115 +1,63 @@
1
1
  ---
2
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.
3
+ description: Policy for creating and steering equal pi peers. Load when considering peer_spawn, peer_list, peer_send_mail, or peer_name — deciding to run directions in parallel, choosing a peer's starting context, writing its first task, or reading/changing a session's name.
4
4
  ---
5
5
 
6
6
  # Peer Orchestrator
7
7
 
8
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.
9
+ Peers are equal instances, not subordinates: you have no control over them, only a launch and a messaging channel. Each peer has its own context, its own token budget, its own mistakes.
10
+ Work directly by default; spawn a peer only when the user asked for parallel/independent work, or a task clearly warrants a separate context. Never spawn for a single lookup or a file read.
13
11
 
14
- ## The three tools
12
+ ## The four tools
15
13
 
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.
14
+ ### `peer_spawn(name, task, prompt?, context?, model?)`
15
+ Creates a peer in its own tmux pane and returns its sessionId immediately — no handshake. You name it at spawn — the peer is named `[peer]<name>` — it receives `task` as its first message, and starts working as soon as it boots. Use `peer_send_mail` for everything after that.
20
16
 
21
17
  ### `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.
18
+ Delivers a letter to any session's mailbox as a .md file with a frontmatter header (from + summary). The recipient's poll injects only the summary plus the file path; the recipient reads the full letter itself and deletes the file afterwards.
19
+ The summary is all the recipient sees up front: state who you are (name/sessionId) and what the letter is about. The letter body must be self-contained: state whether a reply is needed and where to send it. Batch related matters into one letter.
20
+ Notices reach a running session within seconds and pending notices merge into one message. A dead recipient is revived automatically — the call may block up to ~25s and tells you whether the revive succeeded; on failure the letter stays queued and is read whenever that session next runs.
21
+
22
+ ### `peer_list(contact?, page?)`
23
+ Lists sessions with their contact count, cwd, and sessionId — newest first, 15 per page; the first line summarises the total count and the current page (pass `page` for older ones).
24
+ Use `contact: "self"` to see the sessions you have spawned or exchanged letters with (either direction counts); pass any other sessionId to inspect who that session has communicated with.
25
+ Run it before spawning: reusing or forking an existing peer beats recreating one — a session's whole history is on disk whether it is running or not, so mail it instead of spawning a duplicate.
26
+
27
+ ### `peer_name(name?)`
28
+ Reads or sets THIS session's name. Omit `name` to read: the value returned is the name as it exists on disk; a session with no name entry reads as unnamed. Pass `name` to set: effective immediately.
29
+ Name is shown in `peer_list` as the first field and is the only thing a reader sees before deciding to mail, so the name should declare and stay consistent with exact function, responsibility, and state.
30
+ Your name is your own responsibility. Whenever your state changes — retirement, a new direction, a changed task — update your name with `peer_name` in the same run, so that other peers are not misled.
38
31
 
39
32
  ## Writing a spawn
40
33
 
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`.
34
+ ### `name`
35
+ The peer's name is `[peer]<name>`. The name is the peer's identity carrier — see `peer_name`. A compliant name declares function, responsibility, and state, separated by `|`: e.g. `investigate-auth-latency-and-root-causes | owns the auth-latency diagnosis report for the main session | active, one-shot mission`, `宏观与全球央行跟踪 | 负责 economy 日报的宏观板块 | 活跃,每日例行`, `英伟达股价走势分析 | 已交付一次性走势判断报告 | 已退役 2026-10-19` — not `task1`, `helper`, a bare `refactor-auth`, or a function-only name like `investigate-auth-latency-and-analyze-causes` (no responsibility, no state).
47
36
 
48
37
  ### `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.
38
+ The task is the peer's entire brief — it has no other context. State: the goal, the boundaries, the expected output format, and where to send the result (your own sessionId; the peer learns it from what you write). Make it self-contained: the peer must be able to finish without asking back.
52
39
 
53
40
  ### `context`
54
41
  - 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.
42
+ - `fork:<sessionId>` — inherit that session's full history, and the forked peer also inherits its role (unless you pass a different `prompt`, or the source has no role — then nothing is inherited); only when the peer truly needs everything you know (expensive).
43
+ - `handoff:<text>` — you write exactly what the peer needs; best token/value ratio for a large context. To pass on your own context, write the handoff yourself — do NOT reuse your /compact summary: it is a self-continuation artifact and is stale by everything that happened after it.
61
44
 
62
45
  ### `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
- **Pass a role whenever one fits the mission.** It is the main lever you have for shaping a peer's
71
- stance, and it costs one path string. Leave it empty only when no standard role fits and the task
72
- text alone can carry the discipline you need — a plain peer has no role discipline at all.
73
-
74
- ## What a role is here
75
-
76
- Peers are flat and equal: same toolset, same permissions, no hierarchy. A role therefore cannot be
77
- a permission slice — it is a **responsibility and a stance**, expressed as what the peer produces
78
- and what it refuses to do. Two consequences:
79
-
80
- - A role must change the output contract or the discipline, not the subject matter. "Review the
81
- frontend" is a task; "verify, audit, then criticize" is a role. Subject variation belongs in `task`.
82
- - A role never implies rank. No role is an overseer of another, and a role file must not tell a
83
- peer to direct, schedule, or supervise other peers. Every peer can spawn and mail every other.
84
-
85
- Peers launch with the full toolset: whatever discipline a role shows comes from its stance and its
86
- task text, never from restrictions. A peer can always mail you back.
46
+ Path to a role file. Its text is appended to the peer's system prompt on every turn — it survives every restart (spawn, revive, manual resume), and editing the file takes effect without respawning. Pass one whenever it fits the mission; omitted, the peer runs with no discipline beyond its task.
87
47
 
88
48
  ## The standard roles
89
49
 
90
- Pass the path of the role file you want to `peer_spawn`'s prompt parameter; the exact paths are
91
- given in the peer_spawn tool description. For a custom role, pass the path of any other role file.
92
-
93
- - **investigator** — find out what is true. Merges codebase reconnaissance with outside research:
94
- a map of where things are, or a sourced brief on what the evidence says. It cites every path and
95
- URL it actually checked, and marks the boundary between what it verified and what it infers. Use
96
- it whenever you need facts you do not have.
97
- - **strategist** — make the call. Given a problem, or given alternatives someone else produced, it
98
- weighs them and commits: real options with what each buys and costs, the axis being balanced, one
99
- recommendation definite enough to be wrong, and the fact that would flip it. Decisive, not
100
- analytic — it decides, then says what would change its mind. It proposes; it does not plan
101
- execution steps or direct other peers. Use it before committing to an approach.
102
- - **auditor** — expose what does not hold, and dare to say so. It verifies by actually doing:
103
- re-runs, reproduces, re-derives — then audits the path actually taken (including what was
104
- skipped), names the risks, and only then criticizes: VERDICT (agree / agree-with-changes /
105
- object) plus numbered findings by severity, each with evidence and a concrete suggestion. It
106
- pours cold water on warm consensus and never rubber-stamps — give it the artifact, not your
107
- defense of it.
50
+ A role is a stance, not a rank and not permissions. It changes how the peer works — what it produces and what it refuses — never what it works on: "review the frontend" is a task, "verify, audit, then criticize" is a role. It never grants control over other peers: every peer stays equal.
51
+ Pass the path of the role file you want to `peer_spawn`'s prompt parameter; the exact paths are given in the peer_spawn tool description. For a custom role, pass the path of any other role file.
52
+
53
+ - **investigator** — find out what is true. Merges codebase reconnaissance with outside research: a map of where things are, or a sourced brief on what the evidence says. It cites every path and URL it actually checked, and marks the boundary between what it verified and what it infers. Use it whenever you need facts you do not have.
54
+ - **strategist** — make the call. Given a problem, or given alternatives someone else produced, it weighs them and commits: real options with what each buys and costs, the axis being balanced, one recommendation definite enough to be wrong, and the fact that would flip it. Decisive, not analytic — it decides, then says what would change its mind. It proposes; it does not plan execution steps or direct other peers. Use it before committing to an approach.
55
+ - **auditor** — expose what does not hold, and dare to say so. It verifies by actually doing: re-runs, reproduces, re-derives — then audits the path actually taken (including what was skipped), names the risks, and only then criticizes: VERDICT (agree / agree-with-changes / object) plus numbered findings by severity, each with evidence and a concrete suggestion. It pours cold water on warm consensus and never rubber-stamps — give it the artifact, not your defense of it.
108
56
 
109
57
  ## Conventions
110
58
  - One direction per peer; a peer with two missions does neither well.
111
59
  - Tell the peer where to reply (your sessionId) in the task itself.
112
60
  - 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.
113
61
  - No acknowledgements or small talk by letter; write only when there is substance.
114
- - A peer that goes quiet is not stuck — letters merge while it is busy; wait for its report before
115
- nudging, and nudge with new information, not with "status?".
62
+ - Keep the name truthful: a name that has drifted from the peer's real state is a bug — update it with `peer_name` in the same run that changes the state.
63
+ - A peer that goes quiet is not stuck — letters merge while it is busy; wait for its report before nudging, and nudge with new information, not with "status?".