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