@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 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 liveness, filterable by creator ("self" = your own peers).
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
@@ -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 = 40;
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
- creatorSessionId: string;
26
- /** Absolute role-file path; absent for plain peers. */
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 every peer tool: this pi must run inside tmux. A throw reads as "server down". */
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
- /** The pi invocation for a pane command: works under node, bun and the compiled binary.
72
- * PI_PEER_CMD, when set, overrides the launcher used for panes
73
- * (a deployment pin — takes precedence over execPath). */
74
- function piCommandString(args: string[]): string {
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 `${piCommandString(opts.args)}${positional}`;
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
- // Every TUI pi labels its own pane with its sessionId; the pane dies with its pi, so the label
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 sleep(500);
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(bundledRolesDir, name);
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. The spawner writes before launching, newest-wins.
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(norm));
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
- // Records are write-once, so this cache (negatives included) never goes stale.
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.creatorSessionId === "string") rec = raw as PeerRecord;
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
- function deletePeerRecord(sid: string): void {
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.unlinkSync(path.join(registryDir(), `${sid}.json`));
238
- } catch {}
239
- recordCache.delete(sid);
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 listPeerRecords(): Map<string, PeerRecord> {
243
- const out = new Map<string, PeerRecord>();
244
- let names: string[];
231
+ function readContacts(sid: string): string[] {
232
+ let raw: string;
245
233
  try {
246
- names = fs.readdirSync(registryDir());
234
+ raw = fs.readFileSync(contactsFile(sid), "utf-8");
247
235
  } catch {
248
- return out;
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 out;
238
+ return [...new Set(raw.split("\n").filter((l) => UUID_RE.test(l)))];
257
239
  }
258
240
 
259
- /** 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. */
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
- alive: alive.has(info.id),
260
+ contactIds: [],
271
261
  };
272
262
  });
273
- const peers = listPeerRecords();
263
+ const known = new Set(records.map((r) => r.sessionId));
274
264
  for (const r of records) {
275
- const rec = peers.get(r.sessionId);
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
- const { from, summary } = parseLetterHeader(text);
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(BATCH_SEP),
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 copied over —
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?.appendPrompt) return;
480
- writePeerRecord(ownSid, { ...src });
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; // the fresh session is not compacting
473
+ compactionActive = false;
491
474
  // Only a TUI owns a pane: a nested print/json pi inherits $TMUX_PANE and would steal it.
492
- if (ctx.mode === "tui") await labelPane(pi, sid);
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 inherited: PeerRecord | undefined;
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
- inherited = source.peer; // the forked session's registry fields, overridden below
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 appendPrompt: string | undefined;
581
+ let role: string | undefined;
594
582
  if (params.prompt) {
595
- appendPrompt = expandPath(params.prompt);
596
- if (!fs.existsSync(appendPrompt)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
583
+ role = expandPath(params.prompt);
584
+ if (!fs.existsSync(role)) return fail("prompt_file_missing", `prompt file not found: ${params.prompt}`);
597
585
  }
598
- // Newest-wins: a fork copies the source record, this spawn's fields override it.
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, 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'`.",
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
- parent: Type.Optional(
655
- Type.String({ description: 'Creator sessionId (exact lowercase 36-character UUID), or "self" for this session; peers only' }),
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.parent) {
668
- const creator = params.parent === "self" ? self : params.parent.trim();
669
- if (!UUID_RE.test(creator)) {
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
- "bad_parent",
672
- `"${params.parent}" is not a sessionId. Pass "self" or the exact lowercase 36-character UUID as peer_list prints it.`,
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
- recs = recs.filter((r) => r.peer?.creatorSessionId === creator);
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 by = r.peer ? `created by sessionId: ${r.peer.creatorSessionId}` : "created by user manually";
693
- return `${r.name ?? "—"} | ${by} | ${r.alive ? "alive" : "not alive"} | ${r.cwd} | ${fmtTime(r.startedAt)} | ${r.sessionId}`;
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 (target.alive) {
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",
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 peers, alive or exited), peer_send_mail (deliver a letter, reviving dead recipients). Ships investigator / strategist / auditor role prompts; developed against the pi version given by the piVersion field",
5
+ "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 ~20s and tells you
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(parent?, alive?, page?)`
34
- Lists sessions with their creator, liveness, cwd, and sessionId — newest first, 40 per page; the
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. Use `parent: "self"` to
37
- see only your own peers.
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