@semanaut/zpi-peer 2.0.3 → 2.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 +1 -1
- package/extensions/zpi-peer.ts +94 -107
- package/package.json +2 -2
- package/skills/peer-orchestrator/SKILL.md +7 -5
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Peers are equal instances, not subordinates: each runs in its own tmux pane, wit
|
|
|
14
14
|
## The three tools
|
|
15
15
|
|
|
16
16
|
- **`peer_spawn`** — launch an equal peer in a tmux pane; optional role-prompt file and fork/handoff context.
|
|
17
|
-
- **`peer_list`** — list sessions and their
|
|
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
18
|
- **`peer_send_mail`** — deliver a letter to any pi session's mailbox, peers and human-started alike.
|
|
19
19
|
|
|
20
20
|
## Compatibility
|
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,18 @@ 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 .json at all. Legacy records with creatorSessionId are ignored on read.
|
|
211
193
|
function writePeerRecord(sid: string, rec: PeerRecord): void {
|
|
212
194
|
fs.mkdirSync(registryDir(), { recursive: true });
|
|
213
|
-
const norm: PeerRecord = rec.appendPrompt ? { ...rec, appendPrompt: expandPath(rec.appendPrompt) } : rec;
|
|
214
195
|
const file = path.join(registryDir(), `${sid}.json`);
|
|
215
196
|
const tmp = `${file}.tmp`;
|
|
216
|
-
fs.writeFileSync(tmp, JSON.stringify(
|
|
197
|
+
fs.writeFileSync(tmp, JSON.stringify({ appendPrompt: expandPath(rec.appendPrompt) }));
|
|
217
198
|
fs.renameSync(tmp, file); // tmp+rename: readers never see a half-written record
|
|
218
199
|
}
|
|
219
200
|
|
|
220
|
-
//
|
|
201
|
+
// Role records are write-once (the role path never changes), so this cache (negatives included) never goes stale.
|
|
221
202
|
const recordCache = new Map<string, PeerRecord | null>();
|
|
222
203
|
|
|
223
204
|
function readPeerRecord(sid: string): PeerRecord | null {
|
|
@@ -226,40 +207,49 @@ function readPeerRecord(sid: string): PeerRecord | null {
|
|
|
226
207
|
let rec: PeerRecord | null = null;
|
|
227
208
|
try {
|
|
228
209
|
const raw = JSON.parse(fs.readFileSync(path.join(registryDir(), `${sid}.json`), "utf-8"));
|
|
229
|
-
if (raw && typeof raw.
|
|
210
|
+
if (raw && typeof raw.appendPrompt === "string") rec = { appendPrompt: raw.appendPrompt };
|
|
230
211
|
} catch {}
|
|
231
212
|
recordCache.set(sid, rec);
|
|
232
213
|
return rec;
|
|
233
214
|
}
|
|
234
215
|
|
|
235
|
-
|
|
216
|
+
const contactsFile = (sid: string) => path.join(registryDir(), `${sid}.contacts`);
|
|
217
|
+
|
|
218
|
+
/** Append-only contact log: one sessionId per line, both directions written by whoever acts
|
|
219
|
+
* (spawn or letter). A single short O_APPEND write is atomic under the kernel's inode lock, so
|
|
220
|
+
* concurrent writers never overwrite each other — there is no read-modify-write to race. A
|
|
221
|
+
* crash mid-write leaves at most a partial line, which readContacts drops via the UUID check;
|
|
222
|
+
* duplicates are harmless because reads dedupe. */
|
|
223
|
+
function addContact(a: string, b: string): void {
|
|
236
224
|
try {
|
|
237
|
-
fs.
|
|
238
|
-
|
|
239
|
-
|
|
225
|
+
fs.mkdirSync(registryDir(), { recursive: true });
|
|
226
|
+
fs.appendFileSync(contactsFile(a), `${b}\n`);
|
|
227
|
+
fs.appendFileSync(contactsFile(b), `${a}\n`);
|
|
228
|
+
} catch {} // best effort: a lost line only narrows a future peer_list filter
|
|
240
229
|
}
|
|
241
230
|
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
let names: string[];
|
|
231
|
+
function readContacts(sid: string): string[] {
|
|
232
|
+
let raw: string;
|
|
245
233
|
try {
|
|
246
|
-
|
|
234
|
+
raw = fs.readFileSync(contactsFile(sid), "utf-8");
|
|
247
235
|
} 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);
|
|
236
|
+
return [];
|
|
255
237
|
}
|
|
256
|
-
return
|
|
238
|
+
return [...new Set(raw.split("\n").filter((l) => UUID_RE.test(l)))];
|
|
257
239
|
}
|
|
258
240
|
|
|
259
|
-
|
|
241
|
+
function deletePeerRecord(sid: string): void {
|
|
242
|
+
try {
|
|
243
|
+
fs.unlinkSync(path.join(registryDir(), `${sid}.json`));
|
|
244
|
+
} catch {}
|
|
245
|
+
recordCache.delete(sid);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Sessions on disk joined with the registry — a pure disk read, no tmux. pi's listAll() parser
|
|
249
|
+
* lets the LAST session_info win, naming a fork after itself, not its parent. Liveness is
|
|
250
|
+
* deliberately absent: a pane-level fact, queried per sid only where it matters (send_mail). */
|
|
260
251
|
async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
|
|
261
252
|
const infos = await SessionManager.listAll();
|
|
262
|
-
const alive = new Set((await paneLabels(pi)).values());
|
|
263
253
|
const records: SessionRecord[] = infos.map((info) => {
|
|
264
254
|
const started = info.created.getTime();
|
|
265
255
|
return {
|
|
@@ -267,13 +257,14 @@ async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
|
|
|
267
257
|
name: info.name ?? null,
|
|
268
258
|
cwd: info.cwd,
|
|
269
259
|
startedAt: Number.isNaN(started) ? info.modified.getTime() : started,
|
|
270
|
-
|
|
260
|
+
contactIds: [],
|
|
271
261
|
};
|
|
272
262
|
});
|
|
273
|
-
const
|
|
263
|
+
const known = new Set(records.map((r) => r.sessionId));
|
|
274
264
|
for (const r of records) {
|
|
275
|
-
const rec =
|
|
265
|
+
const rec = readPeerRecord(r.sessionId);
|
|
276
266
|
if (rec) r.peer = rec;
|
|
267
|
+
r.contactIds = readContacts(r.sessionId).filter((id) => known.has(id));
|
|
277
268
|
}
|
|
278
269
|
records.sort((a, b) => b.startedAt - a.startedAt);
|
|
279
270
|
return records;
|
|
@@ -283,7 +274,6 @@ async function scanAllSessions(pi: ExtensionAPI): Promise<SessionRecord[]> {
|
|
|
283
274
|
function findSessionById(records: SessionRecord[], sessionId: string): SessionRecord | undefined {
|
|
284
275
|
return records.find((r) => r.sessionId === sessionId);
|
|
285
276
|
}
|
|
286
|
-
const fmtTime = (ms: number) => new Date(ms).toISOString().replace("T", " ").slice(0, 16);
|
|
287
277
|
|
|
288
278
|
/** Uniform failed-tool result: "<code>: <detail>" text plus structured { code, message, …extra }. */
|
|
289
279
|
function errResult(code: string, detail: string, extra?: Record<string, unknown>) {
|
|
@@ -308,20 +298,14 @@ function errResult(code: string, detail: string, extra?: Record<string, unknown>
|
|
|
308
298
|
const boxDir = (sid: string) => path.join(getAgentDir(), "peer", "letters", sid);
|
|
309
299
|
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
310
300
|
|
|
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
301
|
function formatNotice(file: string, text: string): string {
|
|
320
|
-
|
|
302
|
+
// frontmatter (from + summary) as written by post()
|
|
303
|
+
const header = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
|
|
304
|
+
const pick = (key: string) => new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(header)?.[1]?.trim() || null;
|
|
321
305
|
return [
|
|
322
306
|
`New letter received.`,
|
|
323
|
-
`From: ${from ?? "unknown sender"}`,
|
|
324
|
-
`Summary: ${summary ?? "(no summary header)"}`,
|
|
307
|
+
`From: ${pick("from") ?? "unknown sender"}`,
|
|
308
|
+
`Summary: ${pick("summary") ?? "(no summary header)"}`,
|
|
325
309
|
`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
310
|
].join("\n");
|
|
327
311
|
}
|
|
@@ -394,7 +378,7 @@ function startInboxLoop(pi: ExtensionAPI, sid: string, isIdle: () => boolean): (
|
|
|
394
378
|
}
|
|
395
379
|
if (batch.length === 0) return;
|
|
396
380
|
pi.sendUserMessage(
|
|
397
|
-
batch.map((b) => formatNotice(b.finalPath, b.text)).join(
|
|
381
|
+
batch.map((b) => formatNotice(b.finalPath, b.text)).join("\n\n──────── next letter ────────\n\n"),
|
|
398
382
|
{ deliverAs: isIdle() ? "followUp" : "steer" },
|
|
399
383
|
);
|
|
400
384
|
// Strip the pending- prefix — the one-way "notice issued" transition. Reached
|
|
@@ -468,16 +452,15 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
468
452
|
appendPromptPath = readPeerRecord(sid)?.appendPrompt ?? null;
|
|
469
453
|
};
|
|
470
454
|
|
|
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.
|
|
455
|
+
// A fork keeps the branched session's role; an existing record (the spawner's write) is never overwritten.
|
|
473
456
|
const inheritRoleFromFork = (event: { reason?: string; previousSessionFile?: string }, ownSid: string) => {
|
|
474
457
|
if (event.reason !== "fork" || !event.previousSessionFile) return;
|
|
475
458
|
const file = path.basename(event.previousSessionFile);
|
|
476
459
|
const srcSid = file.endsWith(".jsonl") ? file.slice(0, -".jsonl".length).split("_").pop() : undefined;
|
|
477
460
|
if (!srcSid || fs.existsSync(path.join(registryDir(), `${ownSid}.json`))) return;
|
|
478
461
|
const src = readPeerRecord(srcSid);
|
|
479
|
-
if (!src
|
|
480
|
-
writePeerRecord(ownSid, {
|
|
462
|
+
if (!src) return;
|
|
463
|
+
writePeerRecord(ownSid, { appendPrompt: src.appendPrompt }); // role only — contacts are never inherited
|
|
481
464
|
recordCache.delete(ownSid); // a lookup may already have cached "no record" for this sid
|
|
482
465
|
};
|
|
483
466
|
|
|
@@ -487,9 +470,14 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
487
470
|
const sid = ctx.sessionManager.getSessionId();
|
|
488
471
|
inheritRoleFromFork(event, sid);
|
|
489
472
|
loadOwnRole(sid);
|
|
490
|
-
compactionActive = false;
|
|
473
|
+
compactionActive = false;
|
|
491
474
|
// Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
|
|
492
|
-
|
|
475
|
+
// Best effort — without the label this session reads as dead.
|
|
476
|
+
if (ctx.mode === "tui" && process.env.TMUX_PANE) {
|
|
477
|
+
try {
|
|
478
|
+
await pi.exec("tmux", ["set-option", "-p", "-t", process.env.TMUX_PANE, "@pi_sid", sid], {});
|
|
479
|
+
} catch {}
|
|
480
|
+
}
|
|
493
481
|
stopLoop?.();
|
|
494
482
|
stopLoop = startInboxLoop(pi, sid, () => ctx.isIdle());
|
|
495
483
|
});
|
|
@@ -567,7 +555,7 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
567
555
|
|
|
568
556
|
let mode = "empty";
|
|
569
557
|
let forkSid: string | undefined;
|
|
570
|
-
let
|
|
558
|
+
let inheritedRole: string | undefined;
|
|
571
559
|
let payload: string | undefined;
|
|
572
560
|
const ctxArg = (params.context ?? "").trim();
|
|
573
561
|
if (ctxArg) {
|
|
@@ -581,7 +569,7 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
581
569
|
if (!source) return fail("unknown_session", `fork: no session with id ${src} (try peer_list)`);
|
|
582
570
|
mode = "fork";
|
|
583
571
|
forkSid = src;
|
|
584
|
-
|
|
572
|
+
inheritedRole = source.peer?.appendPrompt;
|
|
585
573
|
} else if (kind === "handoff") {
|
|
586
574
|
mode = "handoff";
|
|
587
575
|
payload = rest;
|
|
@@ -590,17 +578,13 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
590
578
|
}
|
|
591
579
|
}
|
|
592
580
|
|
|
593
|
-
let
|
|
581
|
+
let role: string | undefined;
|
|
594
582
|
if (params.prompt) {
|
|
595
|
-
|
|
596
|
-
if (!fs.existsSync(
|
|
583
|
+
role = expandPath(params.prompt);
|
|
584
|
+
if (!fs.existsSync(role)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
|
|
597
585
|
}
|
|
598
|
-
|
|
599
|
-
writePeerRecord(sid, {
|
|
600
|
-
...(inherited ?? {}),
|
|
601
|
-
creatorSessionId: ctx.sessionManager.getSessionId(),
|
|
602
|
-
...(appendPrompt ? { appendPrompt } : {}),
|
|
603
|
-
});
|
|
586
|
+
if (!role) role = inheritedRole;
|
|
587
|
+
if (role) writePeerRecord(sid, { appendPrompt: role });
|
|
604
588
|
|
|
605
589
|
// No --append-system-prompt: the role hook re-applies the role file every turn.
|
|
606
590
|
const piArgs: string[] = ["--session-id", sid, "--name", name];
|
|
@@ -628,7 +612,6 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
628
612
|
return fail("boot_failed", launched.message);
|
|
629
613
|
}
|
|
630
614
|
|
|
631
|
-
// No sid is reported until the peer labels its pane.
|
|
632
615
|
if (!(await waitForBoot(pi, sid, launched.pane, signal))) {
|
|
633
616
|
const why = signal?.aborted
|
|
634
617
|
? "spawn cancelled while waiting for the peer to start"
|
|
@@ -637,6 +620,7 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
637
620
|
await killPane(pi, launched.pane);
|
|
638
621
|
return fail("boot_failed", `${why} — pane=${launched.pane} (closed)`);
|
|
639
622
|
}
|
|
623
|
+
addContact(ctx.sessionManager.getSessionId(), sid); // a spawn counts as contact
|
|
640
624
|
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
625
|
return {
|
|
642
626
|
content: [{ type: "text" as const, text: message }],
|
|
@@ -649,33 +633,33 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
649
633
|
name: "peer_list",
|
|
650
634
|
label: "List sessions",
|
|
651
635
|
description:
|
|
652
|
-
"List pi sessions
|
|
636
|
+
"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
637
|
parameters: Type.Object({
|
|
654
|
-
|
|
655
|
-
Type.String({
|
|
638
|
+
contact: Type.Optional(
|
|
639
|
+
Type.String({
|
|
640
|
+
description:
|
|
641
|
+
'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.',
|
|
642
|
+
}),
|
|
656
643
|
),
|
|
657
|
-
alive: Type.Optional(Type.Boolean({ description: "true = only alive, false = only not alive" })),
|
|
658
644
|
page: Type.Optional(
|
|
659
645
|
Type.Number({ description: `1-based page number, ${LIST_PAGE_SIZE} sessions per page (default: 1)` }),
|
|
660
646
|
),
|
|
661
647
|
}),
|
|
662
648
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
663
|
-
const tmuxProblem = await tmuxGuard(pi);
|
|
664
|
-
if (tmuxProblem) return errResult("no_tmux", tmuxProblem, { total: 0 });
|
|
665
649
|
const self = ctx.sessionManager.getSessionId();
|
|
666
650
|
let recs = await scanAllSessions(pi);
|
|
667
|
-
if (params.
|
|
668
|
-
const
|
|
669
|
-
if (!UUID_RE.test(
|
|
651
|
+
if (params.contact) {
|
|
652
|
+
const target = params.contact === "self" ? self : params.contact.trim();
|
|
653
|
+
if (!UUID_RE.test(target)) {
|
|
670
654
|
return errResult(
|
|
671
|
-
"
|
|
672
|
-
`"${params.
|
|
655
|
+
"bad_contact",
|
|
656
|
+
`"${params.contact}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
|
|
673
657
|
{ total: 0 },
|
|
674
658
|
);
|
|
675
659
|
}
|
|
676
|
-
|
|
660
|
+
const ids = new Set(readContacts(target));
|
|
661
|
+
recs = recs.filter((r) => ids.has(r.sessionId));
|
|
677
662
|
}
|
|
678
|
-
if (params.alive !== undefined) recs = recs.filter((r) => r.alive === params.alive);
|
|
679
663
|
|
|
680
664
|
const total = recs.length;
|
|
681
665
|
const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE));
|
|
@@ -689,8 +673,8 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
689
673
|
}
|
|
690
674
|
const start = (page - 1) * LIST_PAGE_SIZE;
|
|
691
675
|
const lines = recs.slice(start, start + LIST_PAGE_SIZE).map((r) => {
|
|
692
|
-
const
|
|
693
|
-
return `${r.name ?? "—"} |
|
|
676
|
+
const time = new Date(r.startedAt).toISOString().replace("T", " ").slice(0, 16);
|
|
677
|
+
return `${r.name ?? "—"} | contacts: ${r.contactIds.length || "none"} | ${r.cwd} | ${time} | ${r.sessionId}`;
|
|
694
678
|
});
|
|
695
679
|
const shown = lines.length;
|
|
696
680
|
const header = `total: ${total} matching session${total === 1 ? "" : "s"} | page ${page}/${totalPages}${shown > 0 ? ` | showing ${start + 1}-${start + shown}` : ""}`;
|
|
@@ -750,13 +734,16 @@ export default function piPeer(pi: ExtensionAPI) {
|
|
|
750
734
|
const selfName = findSessionById(records, self)?.name;
|
|
751
735
|
const from = selfName ? `${selfName} (sessionId: ${self})` : `sessionId: ${self}`;
|
|
752
736
|
const letterFile = await post(sid, params.message, { from, summary: params.summary });
|
|
737
|
+
addContact(self, sid); // an exchanged letter counts as contact
|
|
753
738
|
const label = target.name ?? sid;
|
|
754
739
|
const done = (state: string, text: string) => ({
|
|
755
740
|
content: [{ type: "text" as const, text: `${text} | letter file: ${letterFile}` }],
|
|
756
741
|
details: { recipient: label, sessionId: sid, state, letterFile },
|
|
757
742
|
});
|
|
758
743
|
|
|
759
|
-
if (
|
|
744
|
+
// paneLabels throws if tmux died mid-call (we are behind tmuxGuard) — an honest error
|
|
745
|
+
// beats a false "not alive" that would push a live peer down the revive path.
|
|
746
|
+
if ([...(await paneLabels(pi)).values()].includes(sid)) {
|
|
760
747
|
return done("alive", `sent → ${label} — alive; it picks the letter up within ~${POLL_MS / 1000}s`);
|
|
761
748
|
}
|
|
762
749
|
// Empty recorded cwd (old sessions) falls back to the sender's.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semanaut/zpi-peer",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.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). 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"
|
|
@@ -23,18 +23,20 @@ Delivers a letter to any session's mailbox as a .md file with a frontmatter head
|
|
|
23
23
|
The recipient's poll injects only the summary plus the file path — never the full text; the recipient
|
|
24
24
|
reads the full letter itself and deletes the file afterwards. Notices reach a running session within
|
|
25
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 ~
|
|
26
|
+
message. A dead recipient is revived automatically — the call may block up to ~25s and tells you
|
|
27
27
|
whether the revive succeeded; on failure the letter stays queued and is read whenever that session
|
|
28
28
|
next runs.
|
|
29
29
|
The summary is all the recipient sees up front: state who you are (name/sessionId) and what the
|
|
30
30
|
letter is about. The letter body must be self-contained: state whether a reply is needed and where
|
|
31
31
|
to send it. Batch related matters into one letter — every letter costs the recipient a turn.
|
|
32
32
|
|
|
33
|
-
### `peer_list(
|
|
34
|
-
Lists sessions with their
|
|
33
|
+
### `peer_list(contact?, page?)`
|
|
34
|
+
Lists sessions with their contact count, cwd, and sessionId — newest first, 15 per page; the
|
|
35
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
|
|
37
|
-
|
|
36
|
+
before spawning: reusing or forking an existing peer beats recreating one — a session's whole
|
|
37
|
+
history is on disk whether it is running or not, so mail it instead of spawning a duplicate. Use
|
|
38
|
+
`contact: "self"` to see the sessions you have spawned or exchanged letters with (either
|
|
39
|
+
direction counts); pass any other sessionId to inspect who that session has communicated with.
|
|
38
40
|
|
|
39
41
|
## Writing a spawn
|
|
40
42
|
|