@semanaut/zpi-peer 0.1.1 → 2.0.3

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
@@ -17,21 +17,27 @@ Peers are equal instances, not subordinates: each runs in its own tmux pane, wit
17
17
  - **`peer_list`** — list sessions and their liveness, filterable by creator ("self" = your own peers).
18
18
  - **`peer_send_mail`** — deliver a letter to any pi session's mailbox, peers and human-started alike.
19
19
 
20
+ ## Compatibility
21
+
22
+ zpi-peer only adds tools and session-scoped handlers and patches no pi internals, so it does not affect subagent or other orchestration extensions and can be used alongside them.
23
+
20
24
  ## Automatic revival
21
25
 
22
26
  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
27
 
24
28
  ## Bundled role prompts
25
29
 
26
- Three standard role prompts ship with the package. Pass one as `peer_spawn`'s `prompt` parameter to give a peer a persistent role:
30
+ 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:
31
+
32
+ - **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.
33
+ - **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.
34
+ - **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.
27
35
 
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.
36
+ 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.
31
37
 
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.
38
+ 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.
33
39
 
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.
40
+ 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.
35
41
 
36
42
  ## Bundled skill
37
43
 
@@ -166,25 +166,35 @@ async function waitForBoot(
166
166
 
167
167
  const registryDir = () => path.join(getAgentDir(), "peer", "registry");
168
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. */
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. */
175
179
  function syncRoles(): void {
176
180
  try {
177
181
  const dir = peerRolesDir();
178
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
+ }
179
187
  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
+ 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
188
198
  }
189
199
  } catch {}
190
200
  }
@@ -285,11 +295,15 @@ function errResult(code: string, detail: string, extra?: Record<string, unknown>
285
295
  };
286
296
  }
287
297
 
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.
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.
293
307
 
294
308
  const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
295
309
  const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
@@ -316,7 +330,7 @@ function formatNotice(file: string, text: string): string {
316
330
  async function post(sid: string, message: string, header: { from: string; summary: string }): Promise<string> {
317
331
  const dir = boxDir(sid);
318
332
  fs.mkdirSync(dir, { recursive: true });
319
- const file = path.join(dir, `${randomBytes(8).toString("hex")}.md`);
333
+ const file = path.join(dir, `${PENDING}${randomBytes(8).toString("hex")}.md`);
320
334
  const text = `---\nfrom: ${oneLine(header.from)}\nsummary: ${oneLine(header.summary)}\n---\n\n${message}\n`;
321
335
  const tmp = `${file}.tmp`;
322
336
  fs.writeFileSync(tmp, text);
@@ -324,38 +338,47 @@ async function post(sid: string, message: string, header: { from: string; summar
324
338
  return file;
325
339
  }
326
340
 
327
- 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
328
342
 
329
343
  /** Order means nothing — one tick delivers them all merged. */
330
344
  function pendingLetters(dir: string): string[] {
331
345
  try {
332
346
  return fs.readdirSync(dir)
333
- .filter((n) => n.endsWith(".md") && !n.startsWith(ACK))
347
+ .filter((n) => n.startsWith(PENDING) && n.endsWith(".md"))
334
348
  .sort();
335
349
  } catch {
336
350
  return [];
337
351
  }
338
352
  }
339
353
 
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. */
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
+ * an at-most-once loss, the only such window left; a crash after an effective send leaves the
373
+ * prefix on, so the letter is re-announced — a benign duplicate instead of a loss. */
351
374
  function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): () => void {
352
375
  const dir = boxDir(sid);
353
376
  let stopped = false;
354
377
 
355
378
  const tick = () => {
356
- if (stopped) return;
379
+ if (stopped || compactionActive) return;
357
380
  try {
358
- const batch: string[] = [];
381
+ const batch: Array<{ pendingPath: string; finalPath: string; text: string }> = [];
359
382
  for (const name of pendingLetters(dir)) {
360
383
  let text: string;
361
384
  try {
@@ -363,12 +386,26 @@ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): (
363
386
  } catch {
364
387
  continue; // unreadable right now: retry on a later tick
365
388
  }
366
- const file = acknowledge(dir, name);
367
- if (!file) continue; // deleted underneath us: already read
368
- 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
+ });
369
394
  }
370
395
  if (batch.length === 0) return;
371
- 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
+ }
372
409
  } catch {}
373
410
  };
374
411
 
@@ -418,7 +455,7 @@ async function revivePeer(pi: ExtensionAPI, sid: string, cwd: string, signal?: A
418
455
  export default function piPeer(pi: ExtensionAPI) {
419
456
  syncRoles();
420
457
  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).`;
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).`;
422
459
 
423
460
  // On a revive the role must be resolved before any pending letter can start a turn —
424
461
  // session_start does both, in that order.
@@ -450,16 +487,34 @@ export default function piPeer(pi: ExtensionAPI) {
450
487
  const sid = ctx.sessionManager.getSessionId();
451
488
  inheritRoleFromFork(event, sid);
452
489
  loadOwnRole(sid);
490
+ compactionActive = false; // the fresh session is not compacting
453
491
  // Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
454
492
  if (ctx.mode === "tui") await labelPane(pi, sid);
455
493
  stopLoop?.();
456
494
  stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
457
495
  });
458
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
+
459
509
  // pi persists no system prompt (--append-system-prompt vanishes on resume), so the role is
460
510
  // re-applied EVERY turn from the file the registry points at. Identity follows the SESSION,
461
511
  // not the process: /new inside a peer's pane runs plain; a fork inherits the branched
462
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.
463
518
  pi.on("before_agent_start", async (event, ctx) => {
464
519
  // Re-resolve on session switch so the role never leaks across sessions.
465
520
  const sid = ctx.sessionManager.getSessionId();
@@ -479,7 +534,7 @@ export default function piPeer(pi: ExtensionAPI) {
479
534
  name: "peer_spawn",
480
535
  label: "Spawn peer",
481
536
  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).`,
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).`,
483
538
  promptGuidelines: [
484
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.",
485
540
  ],
@@ -489,7 +544,7 @@ export default function piPeer(pi: ExtensionAPI) {
489
544
  prompt: Type.Optional(
490
545
  Type.String({
491
546
  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.",
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.",
493
548
  }),
494
549
  ),
495
550
  context: Type.Optional(
@@ -653,14 +708,14 @@ export default function piPeer(pi: ExtensionAPI) {
653
708
  name: "peer_send_mail",
654
709
  label: "Send letter",
655
710
  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.`,
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.`,
657
712
  promptGuidelines: [
658
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.",
659
714
  ],
660
715
  parameters: Type.Object({
661
716
  to: Type.String({
662
717
  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.",
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.",
664
719
  }),
665
720
  summary: Type.String({
666
721
  description:
@@ -671,7 +726,9 @@ export default function piPeer(pi: ExtensionAPI) {
671
726
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
672
727
  const tmuxProblem = await tmuxGuard(pi);
673
728
  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.
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.
675
732
  const sid = params.to.trim();
676
733
  if (!UUID_RE.test(sid)) {
677
734
  return errResult(
@@ -682,19 +739,23 @@ export default function piPeer(pi: ExtensionAPI) {
682
739
  }
683
740
  const records = await scanAllSessions(pi);
684
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
+ }
685
749
  const self = ctx.sessionManager.getSessionId();
686
750
  const selfName = findSessionById(records, self)?.name;
687
751
  const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
688
752
  const letterFile = await post(sid, params.message, { from, summary: params.summary });
689
- const label = target?.name ?? sid;
753
+ const label = target.name ?? sid;
690
754
  const done = (state: string, text: string) => ({
691
755
  content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
692
756
  details: { recipient: label, sessionId: sid, state, letterFile },
693
757
  });
694
758
 
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
759
  if (target.alive) {
699
760
  return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
700
761
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@semanaut/zpi-peer",
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",
3
+ "version": "2.0.3",
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
6
  "type": "module",
6
7
  "keywords": [
7
8
  "pi-package"
@@ -9,8 +10,10 @@
9
10
  "license": "MIT",
10
11
  "files": [
11
12
  "extensions",
12
- "roles",
13
- "skills"
13
+ "skills",
14
+ "AGENTS.investigator.md",
15
+ "AGENTS.strategist.md",
16
+ "AGENTS.auditor.md"
14
17
  ],
15
18
  "pi": {
16
19
  "extensions": [
@@ -67,23 +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
- 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.
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.
87
108
 
88
109
  ## Conventions
89
110
  - One direction per peer; a peer with two missions does neither well.
@@ -1,9 +0,0 @@
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.
@@ -1,10 +0,0 @@
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.
@@ -1,9 +0,0 @@
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.