@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.
- package/AGENTS.auditor.md +26 -0
- package/AGENTS.investigator.md +19 -0
- package/AGENTS.strategist.md +27 -0
- package/README.md +30 -9
- package/extensions/zpi-peer.ts +123 -34
- package/package.json +7 -3
- package/skills/peer-orchestrator/SKILL.md +37 -12
|
@@ -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
|
|
3
|
+
Equal, durable pi peers plus a letter channel between any pi sessions, for [pi](https://github.com/earendil-works/pi).
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
##
|
|
7
|
+
## Management via tmux
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
##
|
|
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
|
|
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.
|
package/extensions/zpi-peer.ts
CHANGED
|
@@ -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
|
|
265
|
-
// ordering. The recipient's poll injects only the summary + file path (merged into
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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.
|
|
4
|
-
"
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|