@semanaut/zpi-peer 0.1.0 → 2.0.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.
@@ -0,0 +1,26 @@
1
+ You are an auditor peer in a flat network of equal pi instances. You examine a claim, an artifact,
2
+ or a piece of work and expose what does not hold. You are the cold water: the unpolluted,
3
+ skeptical second opinion.
4
+
5
+ Work in this order — verification first, criticism last. Criticism aimed at unverified ground is
6
+ just noise.
7
+
8
+ Rules:
9
+ 1. Verify by actually doing it, not by reading it again: re-run the command, reproduce the
10
+ behavior, re-derive the number. "Reproduced" and "could not reproduce" are both findings —
11
+ report which one you got, and how you tested.
12
+ 2. Audit the path, not only the result: what was actually done end to end, what was skipped or
13
+ silently assumed, what was never checked. An omission is a finding.
14
+ 3. Name the risks the work creates or leaves open — including ones nobody asked about.
15
+ 4. Only then reason and criticize. Quote the exact file/line, command output, or message you object
16
+ to; a finding without its evidence is an accusation.
17
+ 5. Structure the report as VERDICT (agree / agree-with-changes / object), then numbered findings
18
+ ordered by severity, each with its evidence and a concrete suggestion.
19
+ 6. When the work is impressive, the requester is confident, and everyone agrees — that is exactly
20
+ when to look hardest. Pouring cold water on a warm consensus is the job; comfortable agreement
21
+ is failure. If nothing is wrong, say what you checked and why it holds.
22
+ 7. Send the report to the requester's inbox with peer_send_mail; sign with name, role, sessionId,
23
+ and state that it is a result report.
24
+ 8. You are an equal peer, not a subordinate: the artifact may be the requester's own work — judge
25
+ it against the evidence, not against their defense of it, and take their pushback only with
26
+ evidence.
@@ -0,0 +1,19 @@
1
+ You are an investigator peer in a flat network of equal pi instances: you find out what is
2
+ true and report it. Two inputs meet here — the codebase in front of you and the world outside
3
+ it — and you decide which one the task needs, or both.
4
+
5
+ Rules:
6
+ 1. Report, do not conclude beyond your evidence. Separate what you verified from what you infer,
7
+ and mark the boundary explicitly.
8
+ 2. Every claim carries its source: a real file path with line numbers you actually opened, or a URL
9
+ you actually fetched. Never cite a path or link you did not check.
10
+ 3. Match depth to the task: a map (key directories, entry points, where the answers live) when the
11
+ requester needs orientation; a sourced brief (each conclusion backed by evidence) when the
12
+ requester needs an answer. Say which one you produced.
13
+ 4. Be fast before being thorough: locate and read the critical sections rather than whole files.
14
+ Report what you did not look at — silence about a gap reads as coverage.
15
+ 5. Send your report to the requester's inbox with peer_send_mail; sign with name, role, sessionId,
16
+ and state that it is a result report.
17
+ 6. If you are missing information, ask by letter: state clearly that the letter is a question,
18
+ ask exactly one question, and give the sessionId your reply should be sent to.
19
+ 7. You are an equal peer, not a subordinate: push back with evidence if the task looks wrong.
@@ -0,0 +1,27 @@
1
+ You are a strategist peer in a flat network of equal pi instances. You work on direction, not
2
+ execution: what a system should become, and which route there is worth its cost.
3
+
4
+ You are given either a problem to design for, or alternatives someone else produced to weigh.
5
+ Both are the same job — the deliverable is a choice, not a comparison.
6
+
7
+ Rules:
8
+ 1. Decide. Come back with one recommendation, not a menu. The comparison is your work, not your
9
+ deliverable — you were asked precisely because the decision was hard, so make it and own it.
10
+ An analyst lists trade-offs; a strategist commits to one.
11
+ 2. Present real options, never one. For each: what it buys, what it costs, what it forecloses, and
12
+ the conditions under which it is the right choice.
13
+ 3. There is rarely a correct route, only trade-offs. Name the axis you are balancing on (simplicity
14
+ vs. flexibility, speed vs. reversibility, coupling vs. duplication) and say where you put the
15
+ balance and why.
16
+ 4. Make the recommendation definite enough to be wrong: state the fact that would flip it. A
17
+ recommendation without its failure condition is an opinion, not a decision.
18
+ 5. Ground every option in the actual system: cite the file paths, constraints, or measurements you
19
+ relied on. An option that ignores a real constraint is not an option.
20
+ 6. Say what you are not deciding: unknowns, deferred questions, and what must be measured before
21
+ the choice can be final.
22
+ 7. You propose; whoever holds the task decides to act. Do not plan other peers' execution steps:
23
+ a plan of *what the thing should be*, not of *which files to edit in which order*.
24
+ 8. Send your recommendation to the requester's inbox with peer_send_mail; sign with name, role,
25
+ sessionId, and state that it is a result report.
26
+ 9. You are an equal peer, not a subordinate: push back with evidence if the framing of the task
27
+ itself is wrong.
package/README.md CHANGED
@@ -1,19 +1,40 @@
1
1
  # @semanaut/zpi-peer
2
2
 
3
- Equal, durable peers in tmux windows plus a letter channel between any pi sessions, for [pi](https://github.com/earendil-works/pi).
3
+ Equal, durable pi peers plus a letter channel between any pi sessions, for [pi](https://github.com/earendil-works/pi).
4
4
 
5
- A session is alive exactly while a live pane carries its `@pi_sid` label. Peers can be spawned, listed, mailed, and revived.
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
6
 
7
- ## Install
7
+ ## Management via tmux
8
8
 
9
- ```bash
10
- pi install npm:@semanaut/zpi-peer
11
- ```
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.
12
13
 
13
- ## Requires
14
+ ## The three tools
14
15
 
15
- tmux.
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 — a role is the main lever for shaping a peer's stance, so pass one whenever it fits the mission:
27
+
28
+ - **investigator** — find out what is true. Codebase reconnaissance and outside research in one role: 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 line between verified facts and inference.
29
+ - **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.
30
+ - **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.
31
+
32
+ Roles here are responsibilities, not permissions. Peers are flat and equal — same toolset, same permissions, no hierarchy — so a role cannot be a permission slice: it is a stance, defined by what the peer produces and what it refuses to do. Subject variation belongs in the task text, and no role ever supervises another peer.
33
+
34
+ Peers launch with the full toolset: whatever discipline a role shows comes from its stance and its task text, never from restrictions. A peer can always mail back.
35
+
36
+ On every start the extension mirrors the bundled role files into the peer data directory (`<agent dir>/peer`); the bundled files sit next to the extension's parent directory, so when the extension is loaded from `<agent dir>/extensions` they are `<agent dir>/AGENTS.investigator.md` and so on. An edited or older copy is preserved as `<name>.backup` before the bundled version is written, so local edits survive and upgrades still land — each role reconciles independently: a missing source file skips only that role. Retired role files (`AGENTS.scout.md`, `AGENTS.researcher.md`, `AGENTS.critic.md` from 0.1.x) are moved aside as `.backup` too. Any other file path works for a custom role.
16
37
 
17
38
  ## Bundled skill
18
39
 
19
- Installs the `peer-orchestrator` skill (policy for spawning and steering equal pi peers) alongside the extension.
40
+ 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.
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { randomBytes } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { Type } from "typebox";
6
7
  import { getAgentDir, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
8
 
@@ -165,6 +166,39 @@ async function waitForBoot(
165
166
 
166
167
  const registryDir = () => path.join(getAgentDir(), "peer", "registry");
167
168
 
169
+ const peerRolesDir = () => path.join(getAgentDir(), "peer");
170
+ const BUNDLED_ROLES = ["AGENTS.investigator.md", "AGENTS.strategist.md", "AGENTS.auditor.md"];
171
+ const RETIRED_ROLES = ["AGENTS.scout.md", "AGENTS.researcher.md", "AGENTS.critic.md"];
172
+ const bundledRolesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
173
+
174
+ /** Mirror the bundled role files (which sit next to the extension's parent directory) directly
175
+ * into <agentDir>/peer. An edited or older copy is kept as `<name>.backup` before the bundled
176
+ * version is written, so every start reconciles the roles — each role independently: a missing
177
+ * source file skips only that role. Retired role files are moved aside the same way — a stale
178
+ * role must not linger as a usable role. */
179
+ function syncRoles(): void {
180
+ try {
181
+ const dir = peerRolesDir();
182
+ fs.mkdirSync(dir, { recursive: true });
183
+ for (const name of RETIRED_ROLES) {
184
+ const stale = path.join(dir, name);
185
+ if (fs.existsSync(stale)) fs.renameSync(stale, `${stale}.backup`);
186
+ }
187
+ for (const name of BUNDLED_ROLES) {
188
+ try {
189
+ const src = path.join(bundledRolesDir, name);
190
+ const dst = path.join(dir, name);
191
+ const bundled = fs.readFileSync(src);
192
+ if (fs.existsSync(dst)) {
193
+ if (fs.readFileSync(dst).equals(bundled)) continue;
194
+ fs.renameSync(dst, `${dst}.backup`);
195
+ }
196
+ fs.copyFileSync(src, dst);
197
+ } catch {} // one role's missing file must not block the other two
198
+ }
199
+ } catch {}
200
+ }
201
+
168
202
  /** Expand a leading ~/ — fs.* and pi's prompt resolution do not. */
169
203
  function expandPath(p: string): string {
170
204
  if (p === "~") return os.homedir();
@@ -261,11 +295,15 @@ function errResult(code: string, detail: string, extra?: Record<string, unknown>
261
295
  };
262
296
  }
263
297
 
264
- // Letters: <agentDir>/peer/letters/<recipientSid>/<hex>.md — one file per letter, no lock, no
265
- // ordering. The recipient's poll injects only the summary + file path (merged into one message)
266
- // and marks the letter announced by RENAMING it to acknowledged-<name>.md — the filename is the
267
- // dedup state, so a letter is announced at most once ever. The recipient reads and deletes the
268
- // file itself.
298
+ // Letters: <agentDir>/peer/letters/<recipientSid>/pending-<hex>.md — one file per letter,
299
+ // no lock, no ordering. The recipient's poll injects only the summary + file path (merged into
300
+ // one message) and delivers by REMOVING the pending- prefix after issuing the notice — a
301
+ // one-way valve: the prefix is only ever removed, never re-added; a new letter carries a
302
+ // fresh name. pending- is the retry state: a letter still carrying it is re-considered on
303
+ // every poll. The notice announces the prefix-less final path, which exists before any
304
+ // consumer can look (the strip is the synchronous statement right after the send call — no
305
+ // consumer sees the notice before this tick yields). The recipient reads and deletes the file
306
+ // itself.
269
307
 
270
308
  const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
271
309
  const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
@@ -292,7 +330,7 @@ function formatNotice(file: string, text: string): string {
292
330
  async function post(sid: string, message: string, header: { from: string; summary: string }): Promise<string> {
293
331
  const dir = boxDir(sid);
294
332
  fs.mkdirSync(dir, { recursive: true });
295
- const file = path.join(dir, `${randomBytes(8).toString("hex")}.md`);
333
+ const file = path.join(dir, `${PENDING}${randomBytes(8).toString("hex")}.md`);
296
334
  const text = `---\nfrom: ${oneLine(header.from)}\nsummary: ${oneLine(header.summary)}\n---\n\n${message}\n`;
297
335
  const tmp = `${file}.tmp`;
298
336
  fs.writeFileSync(tmp, text);
@@ -300,38 +338,47 @@ async function post(sid: string, message: string, header: { from: string; summar
300
338
  return file;
301
339
  }
302
340
 
303
- const ACK = "acknowledged-"; // filename prefix: this letter has already been announced
341
+ const PENDING = "pending-"; // filename prefix: not yet announced; removed once, never re-added
304
342
 
305
343
  /** Order means nothing — one tick delivers them all merged. */
306
344
  function pendingLetters(dir: string): string[] {
307
345
  try {
308
346
  return fs.readdirSync(dir)
309
- .filter((n) => n.endsWith(".md") && !n.startsWith(ACK))
347
+ .filter((n) => n.startsWith(PENDING) && n.endsWith(".md"))
310
348
  .sort();
311
349
  } catch {
312
350
  return [];
313
351
  }
314
352
  }
315
353
 
316
- /** Returns the renamed path (where the recipient finds it), or null if already deleted. */
317
- function acknowledge(dir: string, name: string): string | null {
318
- try {
319
- fs.renameSync(path.join(dir, name), path.join(dir, `${ACK}${name}`));
320
- return path.join(dir, `${ACK}${name}`);
321
- } catch {
322
- return null;
323
- }
324
- }
325
-
326
- /** Announce waiting letters merged — steer while running, followUp when idle. */
354
+ // Compaction state, tracked from session events. pi rejects every prompt submission
355
+ // while compaction runs ("Cannot submit a prompt while compaction is in progress"), and
356
+ // pi.sendUserMessage's binding swallows that rejection (fire-and-forget; the failure is
357
+ // only reported as a runtime extension error), so the extension cannot learn a send
358
+ // failed — it must simply not send during the window.
359
+ let compactionActive = false;
360
+
361
+ /** Announce waiting letters merged — steer while running or starting (pi 1.2.0 counts the
362
+ * session busy from run start, so a letter landing there queues into the starting run
363
+ * instead of racing it), followUp when idle. While compaction runs the tick does nothing:
364
+ * pi rejects prompt submission then, the rejection is not observable through
365
+ * pi.sendUserMessage, and letters stay pending — the prefix IS the retry state — for the
366
+ * first tick after it ends.
367
+ *
368
+ * Delivery is one-way on the pending- prefix: it is stripped only AFTER the notice has been
369
+ * issued, and never re-added. If the send call itself throws, the strip loop never runs and
370
+ * the letters retry. Residual windows: a send that fails silently (the rejection is swallowed
371
+ * by pi's binding) inside the check-to-send gap leaves an un-announced, un-pended letter —
372
+ * at-most-once loss, as before; a crash after an effective send leaves the prefix on, so the
373
+ * letter is re-announced — a benign duplicate instead of a loss. */
327
374
  function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): () => void {
328
375
  const dir = boxDir(sid);
329
376
  let stopped = false;
330
377
 
331
378
  const tick = () => {
332
- if (stopped) return;
379
+ if (stopped || compactionActive) return;
333
380
  try {
334
- const batch: string[] = [];
381
+ const batch: Array<{ pendingPath: string; finalPath: string; text: string }> = [];
335
382
  for (const name of pendingLetters(dir)) {
336
383
  let text: string;
337
384
  try {
@@ -339,12 +386,26 @@ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): (
339
386
  } catch {
340
387
  continue; // unreadable right now: retry on a later tick
341
388
  }
342
- const file = acknowledge(dir, name);
343
- if (!file) continue; // deleted underneath us: already read
344
- batch.push(formatNotice(file, text));
389
+ batch.push({
390
+ pendingPath: path.join(dir, name),
391
+ finalPath: path.join(dir, name.slice(PENDING.length)),
392
+ text,
393
+ });
345
394
  }
346
395
  if (batch.length === 0) return;
347
- pi.sendUserMessage(batch.join(BATCH_SEP), { deliverAs: isIdle() ? "followUp" : "steer" });
396
+ pi.sendUserMessage(
397
+ batch.map((b) => formatNotice(b.finalPath, b.text)).join(BATCH_SEP),
398
+ { deliverAs: isIdle() ? "followUp" : "steer" },
399
+ );
400
+ // Strip the pending- prefix — the one-way "notice issued" transition. Reached
401
+ // only if the send call did not throw; no consumer can observe the notice before
402
+ // this synchronous rename, so the announced (prefix-less) path already exists
403
+ // when anyone looks.
404
+ for (const b of batch) {
405
+ try {
406
+ fs.renameSync(b.pendingPath, b.finalPath);
407
+ } catch {} // vanished underneath us: nothing to deliver
408
+ }
348
409
  } catch {}
349
410
  };
350
411
 
@@ -392,6 +453,10 @@ async function revivePeer(pi: ExtensionAPI, sid: string, cwd: string, signal?: A
392
453
  }
393
454
 
394
455
  export default function piPeer(pi: ExtensionAPI) {
456
+ syncRoles();
457
+ const roleDir = peerRolesDir();
458
+ const ROLE_HINT = `Standard roles live in ${roleDir}: AGENTS.investigator.md (find out what is true, in the codebase or outside it), AGENTS.strategist.md (make the call: options, trade-offs, a committed recommendation and what would flip it), AGENTS.auditor.md (verify by reproducing, audit the path taken, then criticize — pours cold water on warm consensus).`;
459
+
395
460
  // On a revive the role must be resolved before any pending letter can start a turn —
396
461
  // session_start does both, in that order.
397
462
 
@@ -422,16 +487,34 @@ export default function piPeer(pi: ExtensionAPI) {
422
487
  const sid = ctx.sessionManager.getSessionId();
423
488
  inheritRoleFromFork(event, sid);
424
489
  loadOwnRole(sid);
490
+ compactionActive = false; // the fresh session is not compacting
425
491
  // Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
426
492
  if (ctx.mode === "tui") await labelPane(pi, sid);
427
493
  stopLoop?.();
428
494
  stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
429
495
  });
430
496
 
497
+ // Track the compaction window so the inbox tick can wait it out (see startInboxLoop):
498
+ // session_compact_failed fires on failure AND abort — including an extension cancelling
499
+ // in session_before_compact — so the flag always has a reset path.
500
+ pi.on("session_before_compact", async () => {
501
+ compactionActive = true;
502
+ });
503
+ const compactionOver = async () => {
504
+ compactionActive = false;
505
+ };
506
+ pi.on("session_compact", compactionOver);
507
+ pi.on("session_compact_failed", compactionOver);
508
+
431
509
  // pi persists no system prompt (--append-system-prompt vanishes on resume), so the role is
432
510
  // re-applied EVERY turn from the file the registry points at. Identity follows the SESSION,
433
511
  // not the process: /new inside a peer's pane runs plain; a fork inherits the branched
434
512
  // session's role. Idempotent: skips when already present.
513
+ // pi 1.2.0: the event is a union on event.messageType — "user" runs (prompts,
514
+ // sendUserMessage) carry prompt/images, "custom" runs (sendMessage with triggerTurn)
515
+ // carry the starting message. The role deliberately applies to BOTH kinds: any turn the
516
+ // peer runs carries its stance. The forced system prompt now also survives the run's
517
+ // tool calls (1.2.0 fix), so the prompt cache no longer breaks mid-run over the role.
435
518
  pi.on("before_agent_start", async (event, ctx) => {
436
519
  // Re-resolve on session switch so the role never leaks across sessions.
437
520
  const sid = ctx.sessionManager.getSessionId();
@@ -451,7 +534,7 @@ export default function piPeer(pi: ExtensionAPI) {
451
534
  name: "peer_spawn",
452
535
  label: "Spawn peer",
453
536
  description:
454
- `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 — standard role files: ~/.pi/agent/AGENTS.critic.md, ~/.pi/agent/AGENTS.researcher.md, ~/.pi/agent/AGENTS.scout.md (the peer-orchestrator skill describes what each role does). 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).`,
537
+ `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 to it is rejected as unknown_session, 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} Pass a role whenever one fits the mission — it is the main way to shape a peer's stance. 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).`,
455
538
  promptGuidelines: [
456
539
  "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.",
457
540
  ],
@@ -461,7 +544,7 @@ export default function piPeer(pi: ExtensionAPI) {
461
544
  prompt: Type.Optional(
462
545
  Type.String({
463
546
  description:
464
- "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. Standard roles: ~/.pi/agent/AGENTS.critic.md, ~/.pi/agent/AGENTS.researcher.md, ~/.pi/agent/AGENTS.scout.md; omit for a plain peer.",
547
+ "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} Omit only when no role fits — a plain peer has no role discipline. Any other file path works for a custom role.",
465
548
  }),
466
549
  ),
467
550
  context: Type.Optional(
@@ -625,14 +708,14 @@ export default function piPeer(pi: ExtensionAPI) {
625
708
  name: "peer_send_mail",
626
709
  label: "Send letter",
627
710
  description:
628
- `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.`,
711
+ `Deliver a letter to another session's mailbox (ANY pi session — peers and human-started). ${SKILL_GATE} The recipient must already exist on disk: an id with no session file is rejected with an error and nothing is written or sent (a just-spawned peer becomes addressable a few seconds after peer_spawn returns, once it persists its session file). 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.`,
629
712
  promptGuidelines: [
630
713
  "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.",
631
714
  ],
632
715
  parameters: Type.Object({
633
716
  to: Type.String({
634
717
  description:
635
- "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.",
718
+ "Recipient sessionId: the exact lowercase 36-character UUID as peer_list prints it. The session must already exist on disk — an id with no session file is rejected and nothing is sent. Prefixes, a different case and any other variant are rejected — an id is never normalised into a match.",
636
719
  }),
637
720
  summary: Type.String({
638
721
  description:
@@ -643,7 +726,9 @@ export default function piPeer(pi: ExtensionAPI) {
643
726
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
644
727
  const tmuxProblem = await tmuxGuard(pi);
645
728
  if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { state: "no_tmux" });
646
- // A full id not on disk yet is fine — it may be a just-spawned peer still booting.
729
+ // The letter is composed but not yet on disk: a recipient that exists nowhere on disk is a typo (or a
730
+ // peer that never booted), so refuse it before writing anything — an unreadable letter in a dead
731
+ // mailbox is invisible to both sides.
647
732
  const sid = params.to.trim();
648
733
  if (!UUID_RE.test(sid)) {
649
734
  return errResult(
@@ -654,19 +739,23 @@ export default function piPeer(pi: ExtensionAPI) {
654
739
  }
655
740
  const records = await scanAllSessions(pi);
656
741
  const target = findSessionById(records, sid);
742
+ if (!target) {
743
+ return errResult(
744
+ "unknown_session",
745
+ `no session with id ${sid} exists on disk; nothing was sent. Pass the exact sessionId of an existing session (peer_list shows them).`,
746
+ { state: "unknown_session", sessionId: sid },
747
+ );
748
+ }
657
749
  const self = ctx.sessionManager.getSessionId();
658
750
  const selfName = findSessionById(records, self)?.name;
659
751
  const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
660
752
  const letterFile = await post(sid, params.message, { from, summary: params.summary });
661
- const label = target?.name ?? sid;
753
+ const label = target.name ?? sid;
662
754
  const done = (state: string, text: string) => ({
663
755
  content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
664
756
  details: { recipient: label, sessionId: sid, state, letterFile },
665
757
  });
666
758
 
667
- if (!target) {
668
- return done("queued_no_session", `sent → ${label} — no session on disk yet; the letter is queued and will be read when it starts`);
669
- }
670
759
  if (target.alive) {
671
760
  return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
672
761
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@semanaut/zpi-peer",
3
- "version": "0.1.0",
4
- "description": "pi extension: equal, durable peers in tmux windows plus a letter channel between any pi sessions",
3
+ "version": "2.0.0",
4
+ "piVersion": "1.2.0",
5
+ "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 investigator / strategist / auditor role prompts; developed against the pi version given by the piVersion field",
5
6
  "type": "module",
6
7
  "keywords": [
7
8
  "pi-package"
@@ -9,7 +10,10 @@
9
10
  "license": "MIT",
10
11
  "files": [
11
12
  "extensions",
12
- "skills"
13
+ "skills",
14
+ "AGENTS.investigator.md",
15
+ "AGENTS.strategist.md",
16
+ "AGENTS.auditor.md"
13
17
  ],
14
18
  "pi": {
15
19
  "extensions": [
@@ -67,19 +67,44 @@ session, never the process: `/new` inside a peer's pane starts a plain session,
67
67
  inherits the role of the session it branched from (unless you pass `prompt`) — spawn a proper
68
68
  peer when a new one is needed.
69
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.
87
+
70
88
  ## 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
- - `~/.pi/agent/AGENTS.scout.md` — fast reconnaissance. Use it to map unknown territory: key
75
- directories, entry points, one-line file descriptions. It locates, it does not analyze deeply,
76
- and it states its interpretation when your task is ambiguous.
77
- - `~/.pi/agent/AGENTS.researcher.md` — directed research. Use it for a sourced findings brief on a
78
- question you could not answer from the code alone; every conclusion cites a file path or URL.
79
- - `~/.pi/agent/AGENTS.critic.md` — independent review. Use it for an unpolluted second opinion on a
80
- plan, diff, or conclusion of yours; it answers VERDICT (agree / agree-with-changes / object)
81
- plus numbered findings by severity, each with quoted evidence and a concrete suggestion. It
82
- never rubber-stamps — give it the artifact, not your defense of it.
89
+
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.
83
108
 
84
109
  ## Conventions
85
110
  - One direction per peer; a peer with two missions does neither well.