@semanaut/zpi-peer 0.0.0-stage → 0.1.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 +18 -2
- package/extensions/zpi-peer.ts +683 -0
- package/package.json +25 -4
- package/skills/peer-orchestrator/SKILL.md +90 -0
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @semanaut/zpi-peer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Equal, durable peers in tmux windows plus a letter channel between any pi sessions, for [pi](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
A session is alive exactly while a live pane carries its `@pi_sid` label. Peers can be spawned, listed, mailed, and revived.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@semanaut/zpi-peer
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Requires
|
|
14
|
+
|
|
15
|
+
tmux.
|
|
16
|
+
|
|
17
|
+
## Bundled skill
|
|
18
|
+
|
|
19
|
+
Installs the `peer-orchestrator` skill (policy for spawning and steering equal pi peers) alongside the extension.
|
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { getAgentDir, SessionManager, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
// pi-peer — equal, durable peers in tmux windows + a letter channel between any pi
|
|
9
|
+
// sessions. A session is alive exactly while a live pane carries its @pi_sid label.
|
|
10
|
+
|
|
11
|
+
const NOT_IN_TMUX_SERVER_DOWN =
|
|
12
|
+
"tmux is not running and this pi instance is not inside it ($TMUX is unset). Peers live in windows of the caller's own tmux session, so start tmux and run pi inside it first — without that, peer_spawn / peer_list / peer_send_mail cannot work properly.";
|
|
13
|
+
const NOT_IN_TMUX_SERVER_UP =
|
|
14
|
+
"a tmux server is running, but this pi instance is not inside tmux ($TMUX is unset). Peers live in windows of the caller's own tmux session, so move this pi instance into a tmux pane (restart it inside tmux) first — outside tmux, peer_spawn / peer_list / peer_send_mail cannot work properly.";
|
|
15
|
+
const POLL_MS = 1500;
|
|
16
|
+
const SKILL_GATE =
|
|
17
|
+
"Before calling this tool, read the peer-orchestrator skill.";
|
|
18
|
+
const REVIVE_TIMEOUT_MS = 25_000;
|
|
19
|
+
const SPAWN_BOOT_TIMEOUT_MS = 20_000;
|
|
20
|
+
const LIST_PAGE_SIZE = 40;
|
|
21
|
+
const BATCH_SEP = "\n\n──────── next letter ────────\n\n";
|
|
22
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
23
|
+
interface PeerRecord {
|
|
24
|
+
creatorSessionId: string;
|
|
25
|
+
/** Absolute role-file path; absent for plain peers. */
|
|
26
|
+
appendPrompt?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SessionRecord {
|
|
30
|
+
sessionId: string;
|
|
31
|
+
name: string | null;
|
|
32
|
+
cwd: string;
|
|
33
|
+
startedAt: number;
|
|
34
|
+
alive: boolean;
|
|
35
|
+
peer?: PeerRecord;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
39
|
+
|
|
40
|
+
/** v7 (time-ordered) to match pi's own session ids. */
|
|
41
|
+
function uuidv7(): string {
|
|
42
|
+
const ts = BigInt(Date.now());
|
|
43
|
+
const b = randomBytes(16);
|
|
44
|
+
b[0] = Number(ts >> 40n);
|
|
45
|
+
b[1] = Number(ts >> 32n);
|
|
46
|
+
b[2] = Number(ts >> 24n);
|
|
47
|
+
b[3] = Number(ts >> 16n);
|
|
48
|
+
b[4] = Number(ts >> 8n);
|
|
49
|
+
b[5] = Number(ts);
|
|
50
|
+
b[6] = (b[6] & 0x0f) | 0x70;
|
|
51
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
52
|
+
const hex = b.toString("hex");
|
|
53
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** First gate of every peer tool: this pi must run inside tmux. A throw reads as "server down". */
|
|
57
|
+
async function tmuxGuard(pi: ExtensionAPI): Promise<string | null> {
|
|
58
|
+
if (process.env.TMUX) return null;
|
|
59
|
+
let serverUp = false;
|
|
60
|
+
try {
|
|
61
|
+
serverUp = (await pi.exec("tmux", ["list-sessions"], {})).code === 0;
|
|
62
|
+
} catch {}
|
|
63
|
+
return serverUp ? NOT_IN_TMUX_SERVER_UP : NOT_IN_TMUX_SERVER_DOWN;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shq(s: string): string {
|
|
67
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The pi invocation for a pane command: works under node, bun and the compiled binary.
|
|
71
|
+
* PI_PEER_CMD, when set, overrides the launcher used for panes
|
|
72
|
+
* (a deployment pin — takes precedence over execPath). */
|
|
73
|
+
function piCommandString(args: string[]): string {
|
|
74
|
+
const script = process.argv[1];
|
|
75
|
+
const launcher =
|
|
76
|
+
script && !script.startsWith("/$bunfs/root/") && fs.existsSync(script)
|
|
77
|
+
? [process.execPath, script]
|
|
78
|
+
: /^(node|bun)(\.exe)?$/i.test(path.basename(process.execPath))
|
|
79
|
+
? ["pi"]
|
|
80
|
+
: [process.env.PI_PEER_CMD || process.execPath];
|
|
81
|
+
return [...launcher, ...args].map(shq).join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** pi + optional first user message. Nothing holds the pane open, so a peer's window closes with its pi. */
|
|
85
|
+
function buildCommand(opts: { args: string[]; firstMessage?: string }): string {
|
|
86
|
+
const msg = opts.firstMessage;
|
|
87
|
+
const positional = msg === undefined || msg === "" ? "" : ` -- ${shq(/^[-@]/.test(msg) ? ` ${msg}` : msg)}`;
|
|
88
|
+
return `${piCommandString(opts.args)}${positional}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** pi.exec can THROW instead of returning non-zero — every throw is folded into { ok: false } and never escapes. */
|
|
92
|
+
async function launchPane(
|
|
93
|
+
pi: ExtensionAPI,
|
|
94
|
+
cmdStr: string,
|
|
95
|
+
cwd: string,
|
|
96
|
+
signal?: AbortSignal,
|
|
97
|
+
): Promise<{ ok: true; pane: string } | { ok: false; message: string }> {
|
|
98
|
+
try {
|
|
99
|
+
// -d: the peer's window must not steal focus from whatever the user is looking at.
|
|
100
|
+
const r = await pi.exec("tmux", ["new-window", "-d", "-P", "-F", "#{pane_id}", "-c", cwd, cmdStr], { signal });
|
|
101
|
+
if (r.code !== 0) return { ok: false, message: (r.stderr || r.stdout).trim() || `tmux exited ${r.code}` };
|
|
102
|
+
return { ok: true, pane: r.stdout.trim().split("\n").pop() ?? "" };
|
|
103
|
+
} catch (e: any) {
|
|
104
|
+
return { ok: false, message: `tmux could not be run: ${String(e?.message ?? e)}` };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Best-effort pane kill: never throws, ignores abort. */
|
|
109
|
+
async function killPane(pi: ExtensionAPI, pane: string): Promise<void> {
|
|
110
|
+
try {
|
|
111
|
+
await pi.exec("tmux", ["kill-pane", "-t", pane], {}); // no signal: cleanup must run even when aborted
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Every TUI pi labels its own pane with its sessionId; the pane dies with its pi, so the label
|
|
116
|
+
// needs no TTL. print/json/rpc runs must not steal their pane's label.
|
|
117
|
+
|
|
118
|
+
/** pane_id -> @pi_sid of every live pane. THROWS when tmux is unreachable — never silently "nothing is alive". */
|
|
119
|
+
async function paneLabels(pi: ExtensionAPI): Promise<Map<string, string>> {
|
|
120
|
+
const r = await pi.exec("tmux", ["list-panes", "-a", "-F", "#{pane_id}|#{@pi_sid}|#{pane_dead}"], {});
|
|
121
|
+
if (r.code !== 0) {
|
|
122
|
+
const detail = (r.stderr || r.stdout).trim() || `exited ${r.code}`;
|
|
123
|
+
throw new Error(`tmux list-panes failed: ${detail}`);
|
|
124
|
+
}
|
|
125
|
+
const out = new Map<string, string>();
|
|
126
|
+
for (const line of (r.stdout ?? "").split("\n")) {
|
|
127
|
+
const [pane, sid, dead] = line.split("|");
|
|
128
|
+
if (pane && dead === "0") out.set(pane, UUID_RE.test(sid) ? sid : "");
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Best effort — without the label this session reads as dead. */
|
|
134
|
+
async function labelPane(pi: ExtensionAPI, sid: string): Promise<void> {
|
|
135
|
+
const pane = process.env.TMUX_PANE;
|
|
136
|
+
if (!pane) return;
|
|
137
|
+
try {
|
|
138
|
+
await pi.exec("tmux", ["set-option", "-p", "-t", pane, "@pi_sid", sid], {});
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 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. */
|
|
143
|
+
async function waitForBoot(
|
|
144
|
+
pi: ExtensionAPI,
|
|
145
|
+
sid: string,
|
|
146
|
+
pane: string,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
budgetMs: number = SPAWN_BOOT_TIMEOUT_MS,
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
const deadline = Date.now() + budgetMs;
|
|
151
|
+
while (Date.now() < deadline && !signal?.aborted) {
|
|
152
|
+
let labels: Map<string, string>;
|
|
153
|
+
try {
|
|
154
|
+
labels = await paneLabels(pi);
|
|
155
|
+
} catch {
|
|
156
|
+
return false; // tmux unreachable mid-boot: the boot can no longer be confirmed
|
|
157
|
+
}
|
|
158
|
+
const label = labels.get(pane);
|
|
159
|
+
if (label === sid) return true;
|
|
160
|
+
if (label === undefined) return false;
|
|
161
|
+
await sleep(500);
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const registryDir = () => path.join(getAgentDir(), "peer", "registry");
|
|
167
|
+
|
|
168
|
+
/** Expand a leading ~/ — fs.* and pi's prompt resolution do not. */
|
|
169
|
+
function expandPath(p: string): string {
|
|
170
|
+
if (p === "~") return os.homedir();
|
|
171
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
172
|
+
return p;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Peer identity lives ONLY here, never in session files — a fork cannot pollute it by
|
|
176
|
+
// file copying, and no scan can miss it. The spawner writes before launching, newest-wins.
|
|
177
|
+
function writePeerRecord(sid: string, rec: PeerRecord): void {
|
|
178
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
179
|
+
const norm: PeerRecord = rec.appendPrompt ? { ...rec, appendPrompt: expandPath(rec.appendPrompt) } : rec;
|
|
180
|
+
const file = path.join(registryDir(), `${sid}.json`);
|
|
181
|
+
const tmp = `${file}.tmp`;
|
|
182
|
+
fs.writeFileSync(tmp, JSON.stringify(norm));
|
|
183
|
+
fs.renameSync(tmp, file); // tmp+rename: readers never see a half-written record
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Records are write-once, so this cache (negatives included) never goes stale.
|
|
187
|
+
const recordCache = new Map<string, PeerRecord | null>();
|
|
188
|
+
|
|
189
|
+
function readPeerRecord(sid: string): PeerRecord | null {
|
|
190
|
+
const hit = recordCache.get(sid);
|
|
191
|
+
if (hit !== undefined) return hit;
|
|
192
|
+
let rec: PeerRecord | null = null;
|
|
193
|
+
try {
|
|
194
|
+
const raw = JSON.parse(fs.readFileSync(path.join(registryDir(), `${sid}.json`), "utf-8"));
|
|
195
|
+
if (raw && typeof raw.creatorSessionId === "string") rec = raw as PeerRecord;
|
|
196
|
+
} catch {}
|
|
197
|
+
recordCache.set(sid, rec);
|
|
198
|
+
return rec;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function deletePeerRecord(sid: string): void {
|
|
202
|
+
try {
|
|
203
|
+
fs.unlinkSync(path.join(registryDir(), `${sid}.json`));
|
|
204
|
+
} catch {}
|
|
205
|
+
recordCache.delete(sid);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function listPeerRecords(): Map<string, PeerRecord> {
|
|
209
|
+
const out = new Map<string, PeerRecord>();
|
|
210
|
+
let names: string[];
|
|
211
|
+
try {
|
|
212
|
+
names = fs.readdirSync(registryDir());
|
|
213
|
+
} catch {
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
for (const n of names) {
|
|
217
|
+
if (!n.endsWith(".json")) continue;
|
|
218
|
+
const sid = n.slice(0, -".json".length);
|
|
219
|
+
const rec = readPeerRecord(sid);
|
|
220
|
+
if (rec) out.set(sid, rec);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Sessions on disk joined with the registry. Uses pi's listAll(): its parser lets the LAST session_info win, naming a fork after itself, not its parent. */
|
|
226
|
+
async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
|
|
227
|
+
const infos = await SessionManager.listAll();
|
|
228
|
+
const alive = new Set((await paneLabels(pi)).values());
|
|
229
|
+
const records: SessionRecord[] = infos.map((info) => {
|
|
230
|
+
const started = info.created.getTime();
|
|
231
|
+
return {
|
|
232
|
+
sessionId: info.id,
|
|
233
|
+
name: info.name ?? null,
|
|
234
|
+
cwd: info.cwd,
|
|
235
|
+
startedAt: Number.isNaN(started) ? info.modified.getTime() : started,
|
|
236
|
+
alive: alive.has(info.id),
|
|
237
|
+
};
|
|
238
|
+
});
|
|
239
|
+
const peers = listPeerRecords();
|
|
240
|
+
for (const r of records) {
|
|
241
|
+
const rec = peers.get(r.sessionId);
|
|
242
|
+
if (rec) r.peer = rec;
|
|
243
|
+
}
|
|
244
|
+
records.sort((a, b) => b.startedAt - a.startedAt);
|
|
245
|
+
return records;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Exact match only: ids are time-ordered UUIDs, so a prefix match is ambiguous.
|
|
249
|
+
function findSessionById(records: SessionRecord[], sessionId: string): SessionRecord | undefined {
|
|
250
|
+
return records.find((r) => r.sessionId === sessionId);
|
|
251
|
+
}
|
|
252
|
+
const fmtTime = (ms: number) => new Date(ms).toISOString().replace("T", " ").slice(0, 16);
|
|
253
|
+
|
|
254
|
+
/** Uniform failed-tool result: "<code>: <detail>" text plus structured { code, message, …extra }. */
|
|
255
|
+
function errResult(code: string, detail: string, extra?: Record<string, unknown>) {
|
|
256
|
+
const message = `${code}: ${detail}`;
|
|
257
|
+
return {
|
|
258
|
+
content: [{ type: "text" as const, text: message }],
|
|
259
|
+
details: { code, message, ...extra },
|
|
260
|
+
isError: true,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Letters: <agentDir>/peer/letters/<recipientSid>/<hex>.md — one file per letter, no lock, no
|
|
265
|
+
// ordering. The recipient's poll injects only the summary + file path (merged into one message)
|
|
266
|
+
// and marks the letter announced by RENAMING it to acknowledged-<name>.md — the filename is the
|
|
267
|
+
// dedup state, so a letter is announced at most once ever. The recipient reads and deletes the
|
|
268
|
+
// file itself.
|
|
269
|
+
|
|
270
|
+
const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
|
|
271
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
272
|
+
|
|
273
|
+
/** Parse a letter's frontmatter header (from + summary), as written by post(). */
|
|
274
|
+
function parseLetterHeader(text: string): { from: string | null; summary: string | null } {
|
|
275
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
276
|
+
if (!m) return { from: null, summary: null };
|
|
277
|
+
const pick = (key: string) => new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(m[1])?.[1]?.trim() || null;
|
|
278
|
+
return { from: pick("from"), summary: pick("summary") };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function formatNotice(file: string, text: string): string {
|
|
282
|
+
const { from, summary } = parseLetterHeader(text);
|
|
283
|
+
return [
|
|
284
|
+
`New letter received.`,
|
|
285
|
+
`From: ${from ?? "unknown sender"}`,
|
|
286
|
+
`Summary: ${summary ?? "(no summary header)"}`,
|
|
287
|
+
`Full letter file: "${file}" — read it yourself with the read tool, delete the letter file (rm) after reading so letters won't pile up.`,
|
|
288
|
+
].join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Unique names mean writers never coordinate; tmp+rename means readers never see a half-write. */
|
|
292
|
+
async function post(sid: string, message: string, header: { from: string; summary: string }): Promise<string> {
|
|
293
|
+
const dir = boxDir(sid);
|
|
294
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
295
|
+
const file = path.join(dir, `${randomBytes(8).toString("hex")}.md`);
|
|
296
|
+
const text = `---\nfrom: ${oneLine(header.from)}\nsummary: ${oneLine(header.summary)}\n---\n\n${message}\n`;
|
|
297
|
+
const tmp = `${file}.tmp`;
|
|
298
|
+
fs.writeFileSync(tmp, text);
|
|
299
|
+
fs.renameSync(tmp, file);
|
|
300
|
+
return file;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const ACK = "acknowledged-"; // filename prefix: this letter has already been announced
|
|
304
|
+
|
|
305
|
+
/** Order means nothing — one tick delivers them all merged. */
|
|
306
|
+
function pendingLetters(dir: string): string[] {
|
|
307
|
+
try {
|
|
308
|
+
return fs.readdirSync(dir)
|
|
309
|
+
.filter((n) => n.endsWith(".md") && !n.startsWith(ACK))
|
|
310
|
+
.sort();
|
|
311
|
+
} catch {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Returns the renamed path (where the recipient finds it), or null if already deleted. */
|
|
317
|
+
function acknowledge(dir: string, name: string): string | null {
|
|
318
|
+
try {
|
|
319
|
+
fs.renameSync(path.join(dir, name), path.join(dir, `${ACK}${name}`));
|
|
320
|
+
return path.join(dir, `${ACK}${name}`);
|
|
321
|
+
} catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Announce waiting letters merged — steer while running, followUp when idle. */
|
|
327
|
+
function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): () => void {
|
|
328
|
+
const dir = boxDir(sid);
|
|
329
|
+
let stopped = false;
|
|
330
|
+
|
|
331
|
+
const tick = () => {
|
|
332
|
+
if (stopped) return;
|
|
333
|
+
try {
|
|
334
|
+
const batch: string[] = [];
|
|
335
|
+
for (const name of pendingLetters(dir)) {
|
|
336
|
+
let text: string;
|
|
337
|
+
try {
|
|
338
|
+
text = fs.readFileSync(path.join(dir, name), "utf-8");
|
|
339
|
+
} catch {
|
|
340
|
+
continue; // unreadable right now: retry on a later tick
|
|
341
|
+
}
|
|
342
|
+
const file = acknowledge(dir, name);
|
|
343
|
+
if (!file) continue; // deleted underneath us: already read
|
|
344
|
+
batch.push(formatNotice(file, text));
|
|
345
|
+
}
|
|
346
|
+
if (batch.length === 0) return;
|
|
347
|
+
pi.sendUserMessage(batch.join(BATCH_SEP), { deliverAs: isIdle() ? "followUp" : "steer" });
|
|
348
|
+
} catch {}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
353
|
+
} catch {}
|
|
354
|
+
const timer = setInterval(tick, POLL_MS);
|
|
355
|
+
timer.unref?.();
|
|
356
|
+
// Deferred: a synchronous tick would deliver letters from inside the session_start
|
|
357
|
+
// dispatch — the first turn could start before the role hook has run.
|
|
358
|
+
setTimeout(() => tick(), POLL_MS);
|
|
359
|
+
|
|
360
|
+
return () => {
|
|
361
|
+
stopped = true;
|
|
362
|
+
clearInterval(timer);
|
|
363
|
+
try {
|
|
364
|
+
fs.rmdirSync(dir);
|
|
365
|
+
} catch {}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Revive via `pi --session <sid>` — never `--session-id` to CONTINUE: it is project-scoped
|
|
371
|
+
* and silently creates an empty same-id session from the wrong cwd. pi's own resume logic is
|
|
372
|
+
* the only judge: no label within the budget (pi exited, or waits at an interactive prompt
|
|
373
|
+
* in the detached pane) → fail and close the window. No diagnosis, no remediation.
|
|
374
|
+
*/
|
|
375
|
+
async function revivePeer(pi: ExtensionAPI, sid: string, cwd: string, signal?: AbortSignal): Promise<{ ok: boolean; message: string }> {
|
|
376
|
+
const fail = (stage: string, detail: string) => ({ ok: false, message: `revive failed at ${stage}: ${detail}` });
|
|
377
|
+
|
|
378
|
+
const cmdStr = buildCommand({ args: ["--session", sid] });
|
|
379
|
+
const launched = await launchPane(pi, cmdStr, cwd, signal);
|
|
380
|
+
if (!launched.ok) return fail("tmux", launched.message);
|
|
381
|
+
const pane = launched.pane;
|
|
382
|
+
|
|
383
|
+
const up = await waitForBoot(pi, sid, pane, signal, REVIVE_TIMEOUT_MS);
|
|
384
|
+
if (up) return { ok: true, message: `session revived via pi --session: sessionId=${sid} | pane=${pane}` };
|
|
385
|
+
await killPane(pi, pane);
|
|
386
|
+
return fail(
|
|
387
|
+
"boot",
|
|
388
|
+
signal?.aborted
|
|
389
|
+
? "revive cancelled while waiting for the session to come up"
|
|
390
|
+
: `the session did not come up within ${REVIVE_TIMEOUT_MS}ms — pi exited, or stopped at an interactive prompt in the detached pane (window closed)`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export default function piPeer(pi: ExtensionAPI) {
|
|
395
|
+
// On a revive the role must be resolved before any pending letter can start a turn —
|
|
396
|
+
// session_start does both, in that order.
|
|
397
|
+
|
|
398
|
+
// Role file of the current session, null for plain sessions; records store absolute paths.
|
|
399
|
+
let roleSid: string | null = null;
|
|
400
|
+
let appendPromptPath: string | null = null;
|
|
401
|
+
const loadOwnRole = (sid: string) => {
|
|
402
|
+
roleSid = sid;
|
|
403
|
+
appendPromptPath = readPeerRecord(sid)?.appendPrompt ?? null;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
// A fork keeps the branched session's role; an existing record is never copied over —
|
|
407
|
+
// the spawner's own write (copy, then override) is newer and wins.
|
|
408
|
+
const inheritRoleFromFork = (event: { reason?: string; previousSessionFile?: string }, ownSid: string) => {
|
|
409
|
+
if (event.reason !== "fork" || !event.previousSessionFile) return;
|
|
410
|
+
const file = path.basename(event.previousSessionFile);
|
|
411
|
+
const srcSid = file.endsWith(".jsonl") ? file.slice(0, -".jsonl".length).split("_").pop() : undefined;
|
|
412
|
+
if (!srcSid || fs.existsSync(path.join(registryDir(), `${ownSid}.json`))) return;
|
|
413
|
+
const src = readPeerRecord(srcSid);
|
|
414
|
+
if (!src?.appendPrompt) return;
|
|
415
|
+
writePeerRecord(ownSid, { ...src });
|
|
416
|
+
recordCache.delete(ownSid); // a lookup may already have cached "no record" for this sid
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
let stopLoop: (() => void) | null = null;
|
|
420
|
+
|
|
421
|
+
pi.on("session_start", async (event, ctx) => {
|
|
422
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
423
|
+
inheritRoleFromFork(event, sid);
|
|
424
|
+
loadOwnRole(sid);
|
|
425
|
+
// Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
|
|
426
|
+
if (ctx.mode === "tui") await labelPane(pi, sid);
|
|
427
|
+
stopLoop?.();
|
|
428
|
+
stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// pi persists no system prompt (--append-system-prompt vanishes on resume), so the role is
|
|
432
|
+
// re-applied EVERY turn from the file the registry points at. Identity follows the SESSION,
|
|
433
|
+
// not the process: /new inside a peer's pane runs plain; a fork inherits the branched
|
|
434
|
+
// session's role. Idempotent: skips when already present.
|
|
435
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
436
|
+
// Re-resolve on session switch so the role never leaks across sessions.
|
|
437
|
+
const sid = ctx.sessionManager.getSessionId();
|
|
438
|
+
if (sid !== roleSid) loadOwnRole(sid);
|
|
439
|
+
if (!appendPromptPath) return;
|
|
440
|
+
let text: string;
|
|
441
|
+
try {
|
|
442
|
+
text = fs.readFileSync(appendPromptPath, "utf-8").trim();
|
|
443
|
+
} catch {
|
|
444
|
+
return; // role file gone: run without it rather than fail the turn
|
|
445
|
+
}
|
|
446
|
+
if (!text || event.systemPrompt.includes(text)) return;
|
|
447
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${text}` };
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
pi.registerTool({
|
|
451
|
+
name: "peer_spawn",
|
|
452
|
+
label: "Spawn peer",
|
|
453
|
+
description:
|
|
454
|
+
`Spawn an equal pi peer in a tmux pane with a pre-assigned sessionId. ${SKILL_GATE} It returns only once the peer is verifiably up — it blocks up to ~${SPAWN_BOOT_TIMEOUT_MS / 1000}s until the peer labels the pane it was given, and reports a failure instead of handing back a sid that never booted. The returned sid becomes disk-durable a few seconds after return, when the peer submits its first message (pi persists the session file then, not at boot); a peer killed before that leaves no session — its sid goes dark (mail queues, resume reports not found). The task is delivered at launch as the peer's first user message. Use peer_send_mail for follow-up letters. prompt: path to a role file that stays with the peer across every start — standard role files: ~/.pi/agent/AGENTS.critic.md, ~/.pi/agent/AGENTS.researcher.md, ~/.pi/agent/AGENTS.scout.md (the peer-orchestrator skill describes what each role does). context: omit = empty | fork:<sessionId> = fork that session's full history, and the forked peer also inherits its role unless prompt overrides it | handoff:<text> = custom context text (prepended to the first message).`,
|
|
455
|
+
promptGuidelines: [
|
|
456
|
+
"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
|
+
],
|
|
458
|
+
parameters: Type.Object({
|
|
459
|
+
topic: Type.String({ description: "Session topic; the peer is named '[peer]<topic>'" }),
|
|
460
|
+
task: Type.String({ description: "Self-contained first message; delivered at launch as the peer's first user message" }),
|
|
461
|
+
prompt: Type.Optional(
|
|
462
|
+
Type.String({
|
|
463
|
+
description:
|
|
464
|
+
"Path to a role-prompt file (errors if it does not exist). The role stays with the peer across every start — spawn, mail revive, or a user-run /resume — and the file is re-read per turn, so editing it takes effect without respawning. Identity follows the session: /new inside a peer's pane starts a plain session, while a fork inherits the role of the session it branched from unless this overrides it. Standard roles: ~/.pi/agent/AGENTS.critic.md, ~/.pi/agent/AGENTS.researcher.md, ~/.pi/agent/AGENTS.scout.md; omit for a plain peer.",
|
|
465
|
+
}),
|
|
466
|
+
),
|
|
467
|
+
context: Type.Optional(
|
|
468
|
+
Type.String({
|
|
469
|
+
description:
|
|
470
|
+
"fork:<sessionId> | handoff:<text>; omit for empty context. fork: takes the exact lowercase 36-character sessionId (peer_list shows them); the forked peer inherits that session's role",
|
|
471
|
+
}),
|
|
472
|
+
),
|
|
473
|
+
model: Type.Optional(Type.String({ description: "Override model (provider/model-id)" })),
|
|
474
|
+
}),
|
|
475
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
476
|
+
const fail = (code: string, detail: string) => errResult(code, detail, { ok: false });
|
|
477
|
+
|
|
478
|
+
const tmuxProblem = await tmuxGuard(pi);
|
|
479
|
+
if (tmuxProblem) return fail("no_tmux", tmuxProblem);
|
|
480
|
+
|
|
481
|
+
// Pre-assigned id: --session-id creates exactly this session; the wait after launch only confirms it.
|
|
482
|
+
const sid = uuidv7();
|
|
483
|
+
const name = `[peer]${params.topic}`.slice(0, 60);
|
|
484
|
+
|
|
485
|
+
let mode = "empty";
|
|
486
|
+
let forkSid: string | undefined;
|
|
487
|
+
let inherited: PeerRecord | undefined;
|
|
488
|
+
let payload: string | undefined;
|
|
489
|
+
const ctxArg = (params.context ?? "").trim();
|
|
490
|
+
if (ctxArg) {
|
|
491
|
+
const sep = ctxArg.indexOf(":");
|
|
492
|
+
const kind = sep === -1 ? ctxArg : ctxArg.slice(0, sep);
|
|
493
|
+
const rest = sep === -1 ? "" : ctxArg.slice(sep + 1);
|
|
494
|
+
if (kind === "fork") {
|
|
495
|
+
const src = rest.trim();
|
|
496
|
+
if (!UUID_RE.test(src)) return fail("bad_context", `fork: expects an exact lowercase sessionId, got "${rest}"`);
|
|
497
|
+
const source = findSessionById(await scanAllSessions(pi), src);
|
|
498
|
+
if (!source) return fail("unknown_session", `fork: no session with id ${src} (try peer_list)`);
|
|
499
|
+
mode = "fork";
|
|
500
|
+
forkSid = src;
|
|
501
|
+
inherited = source.peer; // the forked session's registry fields, overridden below
|
|
502
|
+
} else if (kind === "handoff") {
|
|
503
|
+
mode = "handoff";
|
|
504
|
+
payload = rest;
|
|
505
|
+
} else {
|
|
506
|
+
return fail("bad_context", `cannot parse context "${params.context}"; supported: fork:<sessionId> | handoff:<text> | omit for empty`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let appendPrompt: string | undefined;
|
|
511
|
+
if (params.prompt) {
|
|
512
|
+
appendPrompt = expandPath(params.prompt);
|
|
513
|
+
if (!fs.existsSync(appendPrompt)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
|
|
514
|
+
}
|
|
515
|
+
// Newest-wins: a fork copies the source record, this spawn's fields override it.
|
|
516
|
+
writePeerRecord(sid, {
|
|
517
|
+
...(inherited ?? {}),
|
|
518
|
+
creatorSessionId: ctx.sessionManager.getSessionId(),
|
|
519
|
+
...(appendPrompt ? { appendPrompt } : {}),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// No --append-system-prompt: the role hook re-applies the role file every turn.
|
|
523
|
+
const piArgs: string[] = ["--session-id", sid, "--name", name];
|
|
524
|
+
let parentModel: string | undefined;
|
|
525
|
+
try {
|
|
526
|
+
const m = (ctx as any).model;
|
|
527
|
+
if (m?.id) parentModel = m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
528
|
+
} catch {}
|
|
529
|
+
const model = params.model ?? parentModel;
|
|
530
|
+
if (model) piArgs.push("--model", model);
|
|
531
|
+
if (forkSid) piArgs.push("--fork", forkSid);
|
|
532
|
+
|
|
533
|
+
// Every failure path below removes the record, so a sid that never booted never lingers.
|
|
534
|
+
const launched = await launchPane(
|
|
535
|
+
pi,
|
|
536
|
+
buildCommand({
|
|
537
|
+
args: piArgs,
|
|
538
|
+
firstMessage: payload ? `${payload}\n\n---\n\n${params.task}` : params.task,
|
|
539
|
+
}),
|
|
540
|
+
ctx.cwd,
|
|
541
|
+
signal,
|
|
542
|
+
);
|
|
543
|
+
if (!launched.ok) {
|
|
544
|
+
deletePeerRecord(sid);
|
|
545
|
+
return fail("boot_failed", launched.message);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// No sid is reported until the peer labels its pane.
|
|
549
|
+
if (!(await waitForBoot(pi, sid, launched.pane, signal))) {
|
|
550
|
+
const why = signal?.aborted
|
|
551
|
+
? "spawn cancelled while waiting for the peer to start"
|
|
552
|
+
: `the peer did not come up: its pane vanished, or never labelled the session within ${SPAWN_BOOT_TIMEOUT_MS}ms`;
|
|
553
|
+
deletePeerRecord(sid);
|
|
554
|
+
await killPane(pi, launched.pane);
|
|
555
|
+
return fail("boot_failed", `${why} — pane=${launched.pane} (closed)`);
|
|
556
|
+
}
|
|
557
|
+
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`;
|
|
558
|
+
return {
|
|
559
|
+
content: [{ type: "text" as const, text: message }],
|
|
560
|
+
details: { ok: true, name, sessionId: sid, pane: launched.pane, contextMode: mode, message },
|
|
561
|
+
};
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
pi.registerTool({
|
|
566
|
+
name: "peer_list",
|
|
567
|
+
label: "List sessions",
|
|
568
|
+
description:
|
|
569
|
+
"List pi sessions, alive or exited. " + SKILL_GATE + " The first line summarises the total count and the current page (40 sessions per page, newest first; pass page for older ones). Then one line per session: name | creator (`created by sessionId: <id>` for a spawned peer, `created by user manually` for a human-started one) | alive | not alive | absolute cwd | YYYY-MM-DD HH:MM | sessionId. parent:'self' filters to peers spawned by this session. To read a session's transcript, resolve its file by sessionId: `find <agentDir>/sessions -name '*_<sessionId>.jsonl'`.",
|
|
570
|
+
parameters: Type.Object({
|
|
571
|
+
parent: Type.Optional(
|
|
572
|
+
Type.String({ description: 'Creator sessionId (exact lowercase 36-character UUID), or "self" for this session; peers only' }),
|
|
573
|
+
),
|
|
574
|
+
alive: Type.Optional(Type.Boolean({ description: "true = only alive, false = only not alive" })),
|
|
575
|
+
page: Type.Optional(
|
|
576
|
+
Type.Number({ description: `1-based page number, ${LIST_PAGE_SIZE} sessions per page (default: 1)` }),
|
|
577
|
+
),
|
|
578
|
+
}),
|
|
579
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
580
|
+
const tmuxProblem = await tmuxGuard(pi);
|
|
581
|
+
if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { total: 0 });
|
|
582
|
+
const self = ctx.sessionManager.getSessionId();
|
|
583
|
+
let recs = await scanAllSessions(pi);
|
|
584
|
+
if (params.parent) {
|
|
585
|
+
const creator = params.parent === "self" ? self : params.parent.trim();
|
|
586
|
+
if (!UUID_RE.test(creator)) {
|
|
587
|
+
return errResult(
|
|
588
|
+
"bad_parent",
|
|
589
|
+
`"${params.parent}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
|
|
590
|
+
{ total: 0 },
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
recs = recs.filter((r) => r.peer?.creatorSessionId === creator);
|
|
594
|
+
}
|
|
595
|
+
if (params.alive !== undefined) recs = recs.filter((r) => r.alive === params.alive);
|
|
596
|
+
|
|
597
|
+
const total = recs.length;
|
|
598
|
+
const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE));
|
|
599
|
+
const page = params.page ?? 1;
|
|
600
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
601
|
+
return errResult(
|
|
602
|
+
"bad_page",
|
|
603
|
+
`"${params.page}" is not a page number. Pass an integer >= 1 (there ${totalPages === 1 ? "is 1 page" : `are ${totalPages} pages`}).`,
|
|
604
|
+
{ total, page: 0, totalPages },
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
const start = (page - 1) * LIST_PAGE_SIZE;
|
|
608
|
+
const lines = recs.slice(start, start + LIST_PAGE_SIZE).map((r) => {
|
|
609
|
+
const by = r.peer ? `created by sessionId: ${r.peer.creatorSessionId}` : "created by user manually";
|
|
610
|
+
return `${r.name ?? "—"} | ${by} | ${r.alive ? "alive" : "not alive"} | ${r.cwd} | ${fmtTime(r.startedAt)} | ${r.sessionId}`;
|
|
611
|
+
});
|
|
612
|
+
const shown = lines.length;
|
|
613
|
+
const header = `total: ${total} matching session${total === 1 ? "" : "s"} | page ${page}/${totalPages}${shown > 0 ? ` | showing ${start + 1}-${start + shown}` : ""}`;
|
|
614
|
+
const text = shown > 0 ? [header, ...lines].join("\n") : `${header}\n(no matching sessions on this page)`;
|
|
615
|
+
return { content: [{ type: "text", text }], details: { total, page, totalPages } };
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
pi.on("session_shutdown", async () => {
|
|
620
|
+
stopLoop?.();
|
|
621
|
+
stopLoop = null;
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
pi.registerTool({
|
|
625
|
+
name: "peer_send_mail",
|
|
626
|
+
label: "Send letter",
|
|
627
|
+
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.`,
|
|
629
|
+
promptGuidelines: [
|
|
630
|
+
"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
|
+
],
|
|
632
|
+
parameters: Type.Object({
|
|
633
|
+
to: Type.String({
|
|
634
|
+
description:
|
|
635
|
+
"Recipient sessionId: the exact lowercase 36-character UUID as peer_list prints it. May target a session that is not on disk yet (e.g. a just-spawned peer). Prefixes, a different case and any other variant are rejected — an id is never normalised into a match.",
|
|
636
|
+
}),
|
|
637
|
+
summary: Type.String({
|
|
638
|
+
description:
|
|
639
|
+
"One-paragraph summary written into the letter's header and injected into the recipient (the full text is NOT injected): your identity (name/sessionId) + the letter's main content/ask. Keep it to one or two lines.",
|
|
640
|
+
}),
|
|
641
|
+
message: Type.String({ description: "Full letter text; stored in the letter file for the recipient to read" }),
|
|
642
|
+
}),
|
|
643
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
644
|
+
const tmuxProblem = await tmuxGuard(pi);
|
|
645
|
+
if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { state: "no_tmux" });
|
|
646
|
+
// A full id not on disk yet is fine — it may be a just-spawned peer still booting.
|
|
647
|
+
const sid = params.to.trim();
|
|
648
|
+
if (!UUID_RE.test(sid)) {
|
|
649
|
+
return errResult(
|
|
650
|
+
"bad_sessionId",
|
|
651
|
+
`"${params.to}" is not a sessionId. Pass the exact lowercase 36-character UUID as peer_list prints it.`,
|
|
652
|
+
{ state: "bad_sessionId" },
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const records = await scanAllSessions(pi);
|
|
656
|
+
const target = findSessionById(records, sid);
|
|
657
|
+
const self = ctx.sessionManager.getSessionId();
|
|
658
|
+
const selfName = findSessionById(records, self)?.name;
|
|
659
|
+
const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
|
|
660
|
+
const letterFile = await post(sid, params.message, { from, summary: params.summary });
|
|
661
|
+
const label = target?.name ?? sid;
|
|
662
|
+
const done = (state: string, text: string) => ({
|
|
663
|
+
content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
|
|
664
|
+
details: { recipient: label, sessionId: sid, state, letterFile },
|
|
665
|
+
});
|
|
666
|
+
|
|
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
|
+
if (target.alive) {
|
|
671
|
+
return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
|
|
672
|
+
}
|
|
673
|
+
// Empty recorded cwd (old sessions) falls back to the sender's.
|
|
674
|
+
const r = await revivePeer(pi, sid, target.cwd || ctx.cwd, signal);
|
|
675
|
+
return r.ok
|
|
676
|
+
? done("revived", `sent → ${label} — it was not alive and has now been revived (${r.message}); it reads the letter at boot`)
|
|
677
|
+
: done(
|
|
678
|
+
"revive_failed",
|
|
679
|
+
`sent → ${label} — not alive and the revive FAILED (${r.message}); the letter is queued and will be read whenever it next starts`,
|
|
680
|
+
);
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semanaut/zpi-peer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "pi extension: equal, durable peers in tmux windows plus a letter channel between any pi sessions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"extensions",
|
|
12
|
+
"skills"
|
|
13
|
+
],
|
|
14
|
+
"pi": {
|
|
15
|
+
"extensions": [
|
|
16
|
+
"./extensions/*.ts"
|
|
17
|
+
],
|
|
18
|
+
"skills": [
|
|
19
|
+
"./skills/peer-orchestrator"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
24
|
+
"@earendil-works/pi-tui": "*",
|
|
25
|
+
"typebox": "*"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: peer-orchestrator
|
|
3
|
+
description: Policy for creating and steering equal pi peers. Load when considering peer_spawn — deciding to run directions in parallel, choosing a peer's starting context, or writing its first task.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Peer Orchestrator
|
|
7
|
+
|
|
8
|
+
## Mental model
|
|
9
|
+
Peers are equal instances, not subordinates: you have no control over them, only a launch and a
|
|
10
|
+
messaging channel. Each peer has its own context, its own token budget, its own mistakes.
|
|
11
|
+
Work directly by default; spawn a peer only when the user asked for parallel/independent work,
|
|
12
|
+
or a task clearly warrants a separate context. Never spawn for a single lookup or a file read.
|
|
13
|
+
|
|
14
|
+
## The three tools
|
|
15
|
+
|
|
16
|
+
### `peer_spawn(topic, task, prompt?, context?, model?)`
|
|
17
|
+
Creates a peer in its own tmux pane and returns its sessionId immediately — no handshake. The peer
|
|
18
|
+
is named `[peer]<topic>`, receives `task` as its first message, and starts working as soon as it
|
|
19
|
+
boots. Use `peer_send_mail` for everything after that.
|
|
20
|
+
|
|
21
|
+
### `peer_send_mail(to, summary, message)`
|
|
22
|
+
Delivers a letter to any session's mailbox as a .md file with a frontmatter header (from + summary).
|
|
23
|
+
The recipient's poll injects only the summary plus the file path — never the full text; the recipient
|
|
24
|
+
reads the full letter itself and deletes the file afterwards. Notices reach a running session within
|
|
25
|
+
~1.5s — via steer while it is running, via followUp when it is idle — and pending notices merge into one
|
|
26
|
+
message. A dead recipient is revived automatically — the call may block up to ~20s and tells you
|
|
27
|
+
whether the revive succeeded; on failure the letter stays queued and is read whenever that session
|
|
28
|
+
next runs.
|
|
29
|
+
The summary is all the recipient sees up front: state who you are (name/sessionId) and what the
|
|
30
|
+
letter is about. The letter body must be self-contained: state whether a reply is needed and where
|
|
31
|
+
to send it. Batch related matters into one letter — every letter costs the recipient a turn.
|
|
32
|
+
|
|
33
|
+
### `peer_list(parent?, alive?, page?)`
|
|
34
|
+
Lists sessions with their creator, liveness, cwd, and sessionId — newest first, 40 per page; the
|
|
35
|
+
first line summarises the total count and the current page (pass `page` for older ones). Run it
|
|
36
|
+
before spawning: reusing or forking an existing peer beats recreating one. Use `parent: "self"` to
|
|
37
|
+
see only your own peers.
|
|
38
|
+
|
|
39
|
+
## Writing a spawn
|
|
40
|
+
|
|
41
|
+
### `topic`
|
|
42
|
+
The peer's name is `[peer]<topic>` (capped at 60 chars). Name the peer after its task or
|
|
43
|
+
responsibility in detail — a reader should understand what the peer does from the topic alone,
|
|
44
|
+
without opening its task. Use a semantic, mission-descriptive name, not a vague handle: e.g.
|
|
45
|
+
`investigate-auth-latency-and-analyze-causes`, `分析8月英伟达股价波动情况以判断未来走势` — not `task1`,
|
|
46
|
+
`helper`, or a bare `refactor-auth`.
|
|
47
|
+
|
|
48
|
+
### `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.
|
|
52
|
+
|
|
53
|
+
### `context`
|
|
54
|
+
- omit — empty context; cheapest; the default for a self-contained task.
|
|
55
|
+
- `fork:<sessionId>` — inherit that session's full history, and the forked peer also inherits its
|
|
56
|
+
role (unless you pass a different `prompt`, or the source has no role — then nothing is inherited);
|
|
57
|
+
only when the peer truly needs everything you know (expensive).
|
|
58
|
+
- `handoff:<text>` — you write exactly what the peer needs; best token/value ratio for a large
|
|
59
|
+
context. To pass on your own context, write the handoff yourself — do NOT reuse your /compact
|
|
60
|
+
summary: it is a self-continuation artifact and is stale by everything that happened after it.
|
|
61
|
+
|
|
62
|
+
### `prompt` (role)
|
|
63
|
+
Pass the path to a role file — no need to read it first; `peer_spawn` only checks it exists. The
|
|
64
|
+
role stays with that session across every start (fresh spawn, revive after a letter, or a manual
|
|
65
|
+
`/resume`), and editing the role file takes effect without respawning. Identity follows the
|
|
66
|
+
session, never the process: `/new` inside a peer's pane starts a plain session, while a fork
|
|
67
|
+
inherits the role of the session it branched from (unless you pass `prompt`) — spawn a proper
|
|
68
|
+
peer when a new one is needed.
|
|
69
|
+
|
|
70
|
+
## The standard roles
|
|
71
|
+
All three are read-only by intent. Peers launch with the full toolset; discipline comes from the
|
|
72
|
+
role file and your task text, not from restrictions. A peer can always mail you back.
|
|
73
|
+
|
|
74
|
+
- `~/.pi/agent/AGENTS.scout.md` — fast reconnaissance. Use it to map unknown territory: key
|
|
75
|
+
directories, entry points, one-line file descriptions. It locates, it does not analyze deeply,
|
|
76
|
+
and it states its interpretation when your task is ambiguous.
|
|
77
|
+
- `~/.pi/agent/AGENTS.researcher.md` — directed research. Use it for a sourced findings brief on a
|
|
78
|
+
question you could not answer from the code alone; every conclusion cites a file path or URL.
|
|
79
|
+
- `~/.pi/agent/AGENTS.critic.md` — independent review. Use it for an unpolluted second opinion on a
|
|
80
|
+
plan, diff, or conclusion of yours; it answers VERDICT (agree / agree-with-changes / object)
|
|
81
|
+
plus numbered findings by severity, each with quoted evidence and a concrete suggestion. It
|
|
82
|
+
never rubber-stamps — give it the artifact, not your defense of it.
|
|
83
|
+
|
|
84
|
+
## Conventions
|
|
85
|
+
- One direction per peer; a peer with two missions does neither well.
|
|
86
|
+
- Tell the peer where to reply (your sessionId) in the task itself.
|
|
87
|
+
- 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.
|
|
88
|
+
- No acknowledgements or small talk by letter; write only when there is substance.
|
|
89
|
+
- A peer that goes quiet is not stuck — letters merge while it is busy; wait for its report before
|
|
90
|
+
nudging, and nudge with new information, not with "status?".
|