@trim21/personal-pi-extensions 0.0.202 → 0.0.204

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
@@ -230,23 +230,23 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
230
230
 
231
231
  ### 工具(LLM 可见)
232
232
 
233
- | 工具 | 作用 |
234
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
235
- | `talk-list-sessions` | 列出其他 session,返回 JSON 数组(`status` / `work_dir` / `id` / `name`);默认只列有心跳的,`includeOffline: true` 列全部 |
236
- | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
237
- | `talk-send` | 发送纯文本消息(`to: "*"` 广播所有,`to: "cwd"` 广播同 cwd) |
238
- | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
233
+ | 工具 | 作用 |
234
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
235
+ | `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);始终列出所有可见会话,`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
236
+ | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
237
+ | `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
238
+ | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
239
239
 
240
240
  对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
241
241
 
242
242
  **定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
243
243
 
244
- **标记废弃 session**:`/talk-dead` session `lastSeenAt` 置 0(从默认列表消失,下次 sweep 无 mail 即回收):无参标记当前 session(同时停止其心跳),`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
244
+ **标记废弃 session**:`/talk-dead` session `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
245
245
 
246
246
  ### 关键设计
247
247
 
248
- - **心跳即活跃**:每个 session 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 45s 心跳阈值区分 live / not responding / offline,并显示 `working` / `waiting-talk-message`(`talk-ask` 阻塞等待回复中)/ `idle`。
249
- - **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
248
+ - **presence 不靠心跳**:presence `offline` 标志 + 进程 pid 存活判定;pid 存活时还校验进程启动时间(`/proc/<pid>/stat`),排除 pid 回卷复用造成的误判。未标记 offline 且进程存活即 live,否则 offline;没有心跳,进程挂死(wedged)与健康空闲不可区分。`status` live 时显示 `working` / `waiting-talk-message`(`talk-ask` 阻塞等待回复中)/ `idle`。
249
+ - **定期清理**:进程仍存活的记录永不回收;进程已死且最后活跃超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
250
250
  - **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
251
251
  - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
252
252
  - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.202",
3
+ "version": "0.0.204",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -67,6 +67,9 @@
67
67
  "src/opencode-todo.ts",
68
68
  "src/question.ts",
69
69
  "src/talk/index.ts"
70
+ ],
71
+ "skills": [
72
+ "src/talk/skills"
70
73
  ]
71
74
  },
72
75
  "lint-staged": {
package/src/talk/core.ts CHANGED
@@ -34,11 +34,11 @@ import {
34
34
  } from "./mailbox.js";
35
35
  import { inboundAccepts, OutboundPolicy } from "./policy.js";
36
36
  import {
37
- LIST_ACTIVE_MS,
38
37
  listRecords,
39
38
  type Presence,
40
39
  presenceOf,
41
40
  readRecord,
41
+ readStartTime,
42
42
  type SessionRecord,
43
43
  sweep,
44
44
  writeRecord,
@@ -64,7 +64,6 @@ export interface TalkCoreOptions {
64
64
  }
65
65
 
66
66
  const INBOX_POLL_MS = 3000;
67
- const HEARTBEAT_MS = 15_000;
68
67
  const WATCH_POLL_MS = 5000;
69
68
  const DELIVERY_BACKOFF_MS = 5000;
70
69
  const INITIAL_DRAIN_DELAY_MS = 1200;
@@ -124,11 +123,10 @@ export class TalkCore {
124
123
  private readonly deliveredIds = new Set<string>();
125
124
  /** Visibility gate over peer working directories; defaults to everything visible. */
126
125
  private isPeerVisible: (peerCwd: string) => boolean = () => true;
127
- /** Manually marked dead: heartbeat stopped, lastSeenAt pinned to 0. */
126
+ /** Manually marked dead: offline flag set, lastSeenAt pinned to 0. */
128
127
  private dead = false;
129
128
 
130
129
  private inboxPoll: ReturnType<typeof setInterval> | undefined;
131
- private heartbeat: ReturnType<typeof setInterval> | undefined;
132
130
  private watchPoller: ReturnType<typeof setInterval> | undefined;
133
131
  private sweeper: ReturnType<typeof setInterval> | undefined;
134
132
  private lastDeliveryFailureAt = 0;
@@ -158,18 +156,16 @@ export class TalkCore {
158
156
 
159
157
  async start(self: SessionRecord): Promise<void> {
160
158
  await this.storage.init();
161
- this.self = self;
162
- await writeRecord(this.storage, self);
159
+ // Record the process start time so presence can rule out pid reuse later.
160
+ const pidStart = readStartTime(self.pid);
161
+ this.self = pidStart === undefined ? self : { ...self, pidStart };
162
+ await writeRecord(this.storage, this.self);
163
163
  try {
164
164
  await sweep(this.storage, this.now());
165
165
  } catch {
166
166
  // sweep failure never breaks the session
167
167
  }
168
168
  this.startInboxPoll();
169
- this.heartbeat = setInterval(() => {
170
- void this.writeSelf({});
171
- }, HEARTBEAT_MS);
172
- this.heartbeat.unref();
173
169
  // Reclaim dead records periodically, not just at startup.
174
170
  this.sweeper = setInterval(() => {
175
171
  void sweep(this.storage, this.now());
@@ -184,7 +180,6 @@ export class TalkCore {
184
180
  }
185
181
 
186
182
  async stop(): Promise<void> {
187
- if (this.heartbeat) clearInterval(this.heartbeat);
188
183
  if (this.watchPoller) clearInterval(this.watchPoller);
189
184
  if (this.inboxPoll) clearInterval(this.inboxPoll);
190
185
  if (this.sweeper) clearInterval(this.sweeper);
@@ -221,7 +216,7 @@ export class TalkCore {
221
216
  try {
222
217
  await writeRecord(this.storage, this.self);
223
218
  } catch {
224
- // heartbeat/registration failures never break the session
219
+ // registration failures never break the session
225
220
  }
226
221
  }
227
222
 
@@ -375,7 +370,7 @@ export class TalkCore {
375
370
  return {
376
371
  ok: true,
377
372
  letter,
378
- verdict: `queued (target ${presence === "stalled" ? "is not responding" : "is offline"} — waits on disk)`,
373
+ verdict: `queued (target is offline — waits on disk)`,
379
374
  };
380
375
  }
381
376
 
@@ -441,7 +436,7 @@ export class TalkCore {
441
436
  this.askWaiters.delete(myAsk.askId);
442
437
  waiter({
443
438
  replied: false,
444
- reason: `peer asked first (their ask id ${letter.id.slice(0, 8)}) — answer it with talk-reply before re-asking`,
439
+ reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — answer it with talk-reply before re-asking`,
445
440
  });
446
441
  await clearAsk(this.storage, self.addr, myAsk.askId);
447
442
  }
@@ -449,32 +444,30 @@ export class TalkCore {
449
444
  // ── Tool actions ───────────────────────────────────────────────────────
450
445
 
451
446
  /**
452
- * JSON listing of visible peer sessions. Defaults to sessions whose
453
- * heartbeat is fresh (within LIST_ACTIVE_MS); pass includeOffline to show
454
- * every visible peer regardless of last contact.
447
+ * JSON listing of visible sessions, including self (marked `self: true`).
448
+ * Every visible record is listed, live or offline; presence decides the
449
+ * per-session status.
455
450
  */
456
- async list(includeOffline = false): Promise<string> {
451
+ async list(): Promise<string> {
457
452
  const self = this.requireSelf();
458
- const now = this.now();
459
453
  const all = await listRecords(this.storage);
460
- const records = all.filter(
461
- (r) => this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
462
- );
463
- return formatListing(records, self.addr, (r) => presenceOf(r, now));
454
+ const records = all.filter((r) => {
455
+ if (r.addr === self.addr) return !this.dead;
456
+ return this.isPeerVisible(r.cwd);
457
+ });
458
+ return formatListing(records, self.addr, presenceOf);
464
459
  }
465
460
 
466
461
  /** Same as list(), filtered to one working directory. */
467
- async listCwd(cwd: string, includeOffline = false): Promise<string> {
462
+ async listCwd(cwd: string): Promise<string> {
468
463
  const self = this.requireSelf();
469
- const now = this.now();
470
464
  const records = await listRecords(this.storage);
471
- const filtered = records.filter(
472
- (r) =>
473
- r.cwd === cwd &&
474
- this.isPeerVisible(r.cwd) &&
475
- (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
476
- );
477
- return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
465
+ const filtered = records.filter((r) => {
466
+ if (r.cwd !== cwd) return false;
467
+ if (r.addr === self.addr) return !this.dead;
468
+ return this.isPeerVisible(r.cwd);
469
+ });
470
+ return formatListing(filtered, self.addr, presenceOf);
478
471
  }
479
472
 
480
473
  /** Visible peer records (excluding self), e.g. for command completions. */
@@ -485,24 +478,20 @@ export class TalkCore {
485
478
  }
486
479
 
487
480
  /**
488
- * Mark a session as dead by pinning lastSeenAt to 0: it vanishes from the
489
- * default listing and the next sweep reaps it (empty mailbox). Without a
490
- * target, marks this session its heartbeat is stopped and later
491
- * writeSelf calls no longer refresh lastSeenAt.
481
+ * Mark a session as dead: set its offline flag and pin lastSeenAt to 0 so
482
+ * the next sweep reaps it (empty mailbox). Without a target, marks this
483
+ * session later writeSelf calls no longer refresh lastSeenAt, and the
484
+ * record stays offline for peers.
492
485
  */
493
486
  async markDead(target?: string): Promise<string> {
494
487
  if (!target) {
495
488
  this.dead = true;
496
- if (this.heartbeat) {
497
- clearInterval(this.heartbeat);
498
- this.heartbeat = undefined;
499
- }
500
- await this.writeSelf({});
489
+ await this.writeSelf({ offline: true });
501
490
  return "Marked this session as dead.";
502
491
  }
503
492
  const resolved = await this.resolveTarget(target);
504
493
  if (!resolved.ok) return resolved.error;
505
- await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
494
+ await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0, offline: true });
506
495
  return `Marked "${resolved.record.name}" as dead.`;
507
496
  }
508
497
 
@@ -510,7 +499,7 @@ export class TalkCore {
510
499
  async markAllDead(): Promise<string> {
511
500
  const peers = await this.listPeers();
512
501
  for (const peer of peers) {
513
- await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
502
+ await writeRecord(this.storage, { ...peer, lastSeenAt: 0, offline: true });
514
503
  }
515
504
  return `Marked ${peers.length} session(s) as dead.`;
516
505
  }
@@ -518,40 +507,21 @@ export class TalkCore {
518
507
  async send(to: string, body: string): Promise<string> {
519
508
  if (!to) return 'send requires "to".';
520
509
  if (!body) return 'send requires "message".';
521
- const self = this.requireSelf();
522
- // Broadcast: N atomic deposits through the existing deposit path so
523
- // rate/dedupe caps still bind (per-peer dedupe; rate caps total fan-out).
524
510
  if (to === "*" || to === "cwd") {
525
- const records = await listRecords(this.storage);
526
- const peers = records.filter((r) => {
527
- if (r.addr === self.addr) return false;
528
- if (!this.isPeerVisible(r.cwd)) return false;
529
- return to === "*" ? true : r.cwd === self.cwd;
530
- });
531
- if (peers.length === 0) return "No other sessions to broadcast to.";
532
- const ok: string[] = [];
533
- const failed: string[] = [];
534
- for (const peer of peers) {
535
- const sent = await this.sendLetter(peer, "message", body);
536
- if (sent.ok) ok.push(`"${peer.name}"`);
537
- else failed.push(`"${peer.name}": ${sent.error}`);
538
- }
539
- const head = `Broadcast to ${ok.length}/${peers.length} session${peers.length === 1 ? "" : "s"}.`;
540
- const detail = failed.length > 0 ? ` Refused: ${failed.join("; ")}.` : "";
541
- return head + detail;
511
+ return "send requires a single session id; broadcasting is disabled.";
542
512
  }
543
513
  const resolved = await this.resolveTarget(to);
544
514
  if (!resolved.ok) return resolved.error;
545
515
  const sent = await this.sendLetter(resolved.record, "message", body);
546
516
  if (!sent.ok) return sent.error;
547
- return `Sent to "${resolved.record.name}" (${shortAddr(resolved.record.addr)}) [id ${sent.letter.id.slice(0, 8)}]: ${sent.verdict}.`;
517
+ return `Sent to "${resolved.record.name}" (${shortAddr(resolved.record.addr)}) [id ${sent.letter.id.slice(-8)}]: ${sent.verdict}.`;
548
518
  }
549
519
 
550
520
  async ask(to: string, body: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
551
521
  if (!to) return 'ask requires "to".';
552
522
  if (!body) return 'ask requires "message".';
553
523
  if (to === "*" || to === "cwd") {
554
- return 'ask is 1:1 and cannot broadcast; use send with to: "*" or "cwd".';
524
+ return "ask is 1:1 and cannot broadcast.";
555
525
  }
556
526
  const self = this.requireSelf();
557
527
  const resolved = await this.resolveTarget(to);
@@ -578,7 +548,7 @@ export class TalkCore {
578
548
  const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
579
549
  await clearAsk(this.storage, self.addr, sent.letter.id);
580
550
  if (!outcome.replied)
581
- return `Ask ${sent.letter.id.slice(0, 8)} to "${record.name}": ${outcome.reason}.`;
551
+ return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
582
552
  return `"${record.name}" replied:\n\n${outcome.body}`;
583
553
  } finally {
584
554
  // The ask tool call is still part of a running agent turn.
@@ -600,7 +570,7 @@ export class TalkCore {
600
570
  const sent = await this.sendLetter(target, "reply", body, ask.id);
601
571
  if (!sent.ok) return sent.error;
602
572
  await clearAsk(this.storage, self.addr, ask.id);
603
- return `Replied to "${target.name}" (ask ${ask.id.slice(0, 8)}): ${sent.verdict}.`;
573
+ return `Replied to "${target.name}" (ask ${ask.id.slice(-8)}): ${sent.verdict}.`;
604
574
  }
605
575
 
606
576
  /** Build a minimal record from a letter's sender when the peer record is gone. */
@@ -644,8 +614,7 @@ export class TalkCore {
644
614
  if (now === prev) continue;
645
615
  this.watched.set(addr, now);
646
616
  const label = rec ? `"${rec.name}"` : shortAddr(addr);
647
- const state =
648
- now === "live" ? (rec?.status ?? "idle") : now === "stalled" ? "not responding" : "offline";
617
+ const state = now === "live" ? (rec?.status ?? "idle") : "offline";
649
618
  this.events.notify(`talk watch: ${label} is now ${state}.`);
650
619
  }
651
620
  }
@@ -31,27 +31,30 @@ export function formatDelivery(letter: Letter, now: number = Date.now()): string
31
31
  return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
32
32
  }
33
33
 
34
- /** One peer session as the model sees it in a listing. `id` is the stable
35
- * pi session uuid; `name` is the display name when one was set. */
34
+ /** One session as the model sees it in a listing. `id` is the stable pi
35
+ * session uuid; `name` is the display name when one was set; `self` marks
36
+ * the calling session itself. */
36
37
  export interface SessionListItem {
37
38
  status: string;
38
39
  work_dir: string;
39
40
  id: string;
40
41
  name?: string;
42
+ self?: boolean;
41
43
  }
42
44
 
43
- /** Machine-readable JSON listing of peer sessions (what the model sees). */
45
+ /** Machine-readable JSON listing of sessions (what the model sees). */
44
46
  export function formatListing(
45
47
  records: SessionRecord[],
46
48
  selfAddr: string,
47
49
  presence: (r: SessionRecord) => Presence,
48
50
  ): string {
49
- const others = records.filter((r) => r.addr !== selfAddr);
50
- if (others.length === 0) return "[]";
51
- const items: SessionListItem[] = others.map((r) => {
51
+ if (records.length === 0) return "[]";
52
+ const items: SessionListItem[] = records.map((r) => {
52
53
  const p = presence(r);
53
- const status = p === "live" ? r.status : p === "stalled" ? "not responding" : "offline";
54
- return { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
54
+ const status = p === "live" ? r.status : "offline";
55
+ const item: SessionListItem = { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
56
+ if (r.addr === selfAddr) item.self = true;
57
+ return item;
55
58
  });
56
59
  return JSON.stringify(items, null, 2);
57
60
  }
package/src/talk/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
 
15
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
17
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -31,6 +32,9 @@ const NOTIFY_TYPE = "talk:notify";
31
32
 
32
33
  const ASK_TIMEOUT_MS = 30 * 60 * 1000;
33
34
 
35
+ /** Guide the model to the multi-agent workflow skill shipped with this package. */
36
+ const SKILL_PATH = fileURLToPath(new URL("skills/multi-agent-dev/SKILL.md", import.meta.url));
37
+
34
38
  function toolResult(text: string) {
35
39
  return { content: [{ type: "text" as const, text }], details: {} };
36
40
  }
@@ -200,6 +204,14 @@ export default function talk(pi: ExtensionAPI) {
200
204
  pi.on("agent_start", () => core.setWorking());
201
205
  pi.on("agent_end", () => core.setIdle());
202
206
  pi.on("agent_settled", () => core.setIdle());
207
+ pi.on("before_agent_start", (event) => {
208
+ // One-line nudge: before coordinating with other pi sessions, read the
209
+ // shipped workflow skill. Skipped when the skill file is absent.
210
+ if (!fs.existsSync(SKILL_PATH)) return;
211
+ return {
212
+ systemPrompt: `${event.systemPrompt}\n\nBefore multi-session collaboration, read ${SKILL_PATH} to understand the talk workflow.`,
213
+ };
214
+ });
203
215
  pi.on("session_info_changed", () => {
204
216
  if (self) core.setSessionName(pi.getSessionName() ?? self.name);
205
217
  });
@@ -212,24 +224,15 @@ export default function talk(pi: ExtensionAPI) {
212
224
  pi.registerTool({
213
225
  name: "talk-list-sessions",
214
226
  label: "List Talk Sessions",
215
- description:
216
- "List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
227
+ description: "List visible pi sessions (id, status, work_dir, name).",
217
228
  promptSnippet: "List other pi sessions on this machine",
218
229
  parameters: Type.Object({
219
230
  cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
220
- includeOffline: Type.Optional(
221
- Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
222
- ),
223
231
  }),
224
232
  async execute(_toolCallId, params) {
225
233
  const initError = requireInit();
226
234
  if (initError) return toolResult(initError);
227
- const includeOffline = params.includeOffline === true;
228
- return toolResult(
229
- params.cwd
230
- ? await core.listCwd(params.cwd, includeOffline)
231
- : await core.list(includeOffline),
232
- );
235
+ return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
233
236
  },
234
237
  });
235
238
 
@@ -264,12 +267,10 @@ export default function talk(pi: ExtensionAPI) {
264
267
  name: "talk-send",
265
268
  label: "Send Talk Message",
266
269
  description:
267
- 'Send a plain-text message to another pi session. Plain text only, ≤32KB — send a summary and a path, never file contents. `to: "*"` broadcasts to every session; `to: "cwd"` broadcasts to sessions in this cwd.',
270
+ "Send a plain-text message to a single pi session. Plain text only, ≤32KB — send a summary and a path, never file contents.",
268
271
  promptSnippet: "Send a message to another pi session",
269
272
  parameters: Type.Object({
270
- to: Type.String({
271
- description: 'Target session (name/address/@alias; "*" or "cwd" to broadcast)',
272
- }),
273
+ to: Type.String({ description: "Target session id (from talk-list-sessions)" }),
273
274
  message: Type.String({ description: "Message body" }),
274
275
  }),
275
276
  async execute(_toolCallId, params) {
@@ -308,7 +309,7 @@ export default function talk(pi: ExtensionAPI) {
308
309
 
309
310
  pi.registerCommand("talk-dead", {
310
311
  description:
311
- "Mark a talk session as dead (removed from listings, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
312
+ "Mark a talk session as dead (shown offline, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
312
313
  async handler(args) {
313
314
  const initError = requireInit();
314
315
  const trimmed = args.trim();
@@ -336,13 +337,13 @@ export default function talk(pi: ExtensionAPI) {
336
337
  ) {
337
338
  return; // pre-renderer entries: keep pi's default custom-message box
338
339
  }
339
- const id8 = d.id.slice(0, 8);
340
+ const idTail = d.id.slice(-8);
340
341
  const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
341
342
  const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
342
- const footer = theme.fg("dim", `id ${id8} · ${d.kind} · ${relativeTime(d.ts)}`);
343
+ const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${relativeTime(d.ts)}`);
343
344
  const out = [header, sanitizeTerminal(d.body), "", footer];
344
345
  if (d.kind === "ask") {
345
- out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${id8}"`));
346
+ out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${idTail}"`));
346
347
  }
347
348
  const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
348
349
  box.addChild(new Text(out.join("\n"), 0, 0));
@@ -247,7 +247,7 @@ export async function pendingAsks(storage: TalkStorage, addr: string): Promise<L
247
247
  return out;
248
248
  }
249
249
 
250
- /** Outgoing ask ids, used by the adapter for prefix resolution. */
250
+ /** Outgoing ask ids, used by the core for interlock arbitration. */
251
251
  export async function outgoingAskIds(storage: TalkStorage, addr: string): Promise<string[]> {
252
252
  const out: string[] = [];
253
253
  for (const key of await storage.listKeys(asksNs(addr))) {
@@ -257,15 +257,19 @@ export async function outgoingAskIds(storage: TalkStorage, addr: string): Promis
257
257
  return out;
258
258
  }
259
259
 
260
- /** Resolve a pending ask by explicit replyTo id or unique prefix. No inference. */
260
+ /**
261
+ * Resolve a pending ask by explicit replyTo id or unique suffix. No inference.
262
+ * Matching is on the suffix: the prefix of a time-ordered (v7-style) id is the
263
+ * timestamp part and collides easily, the suffix is the random part.
264
+ */
261
265
  export async function resolveAskByRef(
262
266
  storage: TalkStorage,
263
267
  addr: string,
264
268
  replyTo: string,
265
269
  ): Promise<Letter | null> {
266
- if (!replyTo) return null; // empty prefix matches every id — an explicit ref is required
270
+ if (!replyTo) return null; // empty suffix matches every id — an explicit ref is required
267
271
  const asks = await pendingAsks(storage, addr);
268
- return asks.find((a) => a.id === replyTo || a.id.startsWith(replyTo)) ?? null;
272
+ return asks.find((a) => a.id === replyTo || a.id.endsWith(replyTo)) ?? null;
269
273
  }
270
274
 
271
275
  // ── Audit log ────────────────────────────────────────────────────────────
@@ -28,8 +28,7 @@ export class OutboundPolicy {
28
28
  * Gate one outbound letter. `unreadBacklog` is the target's current
29
29
  * unread count (report 0 when the target is idle — an idle agent has by
30
30
  * definition worked through what it was handed). `target` scopes the
31
- * identical-body dedupe to a single peer (loop-breaking) so a broadcast
32
- * of one body to N peers is not deduped after the first.
31
+ * identical-body dedupe to a single peer (loop-breaking).
33
32
  */
34
33
  check(body: string, unreadBacklog: number, target?: string): OutboundVerdict {
35
34
  if (body.length > MAX_BODY_CHARS) {
@@ -8,14 +8,18 @@
8
8
  * two sessions on one directory never share an inbox.
9
9
  * - A record outlives the process that wrote it — that's what makes a session
10
10
  * addressable while it's down (mail waits on disk).
11
- * - Presence is a pid PLUS a heartbeat: pid alone can't tell wedged from
12
- * healthy (and pids get reused); heartbeat alone can't tell crash from pause.
11
+ * - Presence is the offline flag plus the pid and its start time: a record
12
+ * whose process is alive (pid + matching start time, ruling out pid reuse)
13
+ * and not flagged offline is live; everything else is offline. There is no
14
+ * heartbeat, so a wedged process is indistinguishable from a healthy idle
15
+ * one.
13
16
  * - Listing has NO side effects.
14
17
  *
15
18
  * All values read from storage are validated with TypeBox before use.
16
19
  */
17
20
 
18
21
  import { createHash } from "node:crypto";
22
+ import { readFileSync } from "node:fs";
19
23
 
20
24
  import { type Static, Type } from "typebox";
21
25
  import { Value } from "typebox/value";
@@ -28,6 +32,7 @@ export const SessionRecordSchema = Type.Object({
28
32
  name: Type.String(),
29
33
  cwd: Type.String(),
30
34
  pid: Type.Number(),
35
+ pidStart: Type.Optional(Type.Number()),
31
36
  startedAt: Type.Number(),
32
37
  lastSeenAt: Type.Number(),
33
38
  status: Type.Union([
@@ -39,12 +44,9 @@ export const SessionRecordSchema = Type.Object({
39
44
  });
40
45
  export type SessionRecord = Static<typeof SessionRecordSchema>;
41
46
 
42
- export type Presence = "live" | "stalled" | "offline";
47
+ export type Presence = "live" | "offline";
43
48
 
44
- export const HEARTBEAT_STALE_MS = 45_000;
45
- /** A session is shown in the default listing while its heartbeat is this fresh. */
46
- export const LIST_ACTIVE_MS = 15 * 60 * 1000;
47
- /** Sweep leaves a record alone until its heartbeat has been quiet this long. */
49
+ /** Sweep leaves a record alone until its last activity was this long ago. */
48
50
  export const SWEEP_OFFLINE_GRACE_MS = 24 * 60 * 60 * 1000;
49
51
  /** A mailbox holding undelivered mail is kept this long after last contact. */
50
52
  export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
@@ -105,29 +107,58 @@ export async function listRecords(storage: TalkStorage): Promise<SessionRecord[]
105
107
  return out.toSorted((a, b) => a.startedAt - b.startedAt);
106
108
  }
107
109
 
108
- function pidAlive(pid: number): boolean {
110
+ /**
111
+ * Process start time (field 22 of /proc/<pid>/stat), used to rule out pid
112
+ * reuse: pids wrap around, so an alive pid is only the same session when its
113
+ * start time matches the recorded one. Returns undefined on non-Linux or when
114
+ * the stat file is unreadable.
115
+ */
116
+ export function readStartTime(pid: number): number | undefined {
117
+ try {
118
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
119
+ // comm sits in parens and may contain spaces: split after the last ')'.
120
+ // starttime is field 22 (1-indexed); pid and (comm) consumed two tokens,
121
+ // so it sits at index 19 of the remainder.
122
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
123
+ const starttime = Number(fields[19]);
124
+ return Number.isFinite(starttime) ? starttime : undefined;
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Whether the recorded process is really alive. `pidStart` guards against
132
+ * pid wrap-around: when present, an alive pid only counts as live if its
133
+ * current start time matches. Without it (legacy records) — or when /proc is
134
+ * unreadable — we fall back to the bare pid check.
135
+ */
136
+ function pidAlive(pid: number, pidStart?: number): boolean {
109
137
  if (!Number.isSafeInteger(pid) || pid <= 0) return false;
110
138
  try {
111
139
  process.kill(pid, 0);
112
- return true;
113
140
  } catch (error) {
114
141
  // EPERM means the process exists but isn't ours — still alive
115
142
  return (error as NodeJS.ErrnoException)?.code === "EPERM";
116
143
  }
144
+ if (pidStart === undefined) return true;
145
+ const start = readStartTime(pid);
146
+ return start === undefined ? true : start === pidStart;
117
147
  }
118
148
 
119
- export function presenceOf(record: SessionRecord, now: number = Date.now()): Presence {
149
+ export function presenceOf(record: SessionRecord): Presence {
120
150
  if (record.offline) return "offline";
121
- if (!pidAlive(record.pid)) return "offline";
122
- return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? "live" : "stalled";
151
+ if (!pidAlive(record.pid, record.pidStart)) return "offline";
152
+ return "live";
123
153
  }
124
154
 
125
155
  // ── Sweep ────────────────────────────────────────────────────────────────
126
156
 
127
157
  /**
128
158
  * Reclaim dead sessions' data. Rules (mail outranks tidiness):
129
- * - a record whose heartbeat went quiet less than SWEEP_OFFLINE_GRACE_MS ago
130
- * is never touched it may be merely down or suspended, and a resume will
159
+ * - a record whose process is still alive is never touched;
160
+ * - a record whose last activity was less than SWEEP_OFFLINE_GRACE_MS ago is
161
+ * never touched — it may be merely down or suspended, and a resume will
131
162
  * re-register it under the same id anyway;
132
163
  * - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
133
164
  * - once the grace period has passed, an empty mailbox is discarded promptly
@@ -136,6 +167,9 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
136
167
  */
137
168
  export async function sweep(storage: TalkStorage, now: number = Date.now()): Promise<void> {
138
169
  for (const record of await listRecords(storage)) {
170
+ // Without a heartbeat, lastSeenAt only tracks the last event, so an idle
171
+ // live session would look long-quiet — never reap a live process.
172
+ if (pidAlive(record.pid, record.pidStart)) continue;
139
173
  const quietFor = now - record.lastSeenAt;
140
174
  if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;
141
175
  const hasMail =
@@ -0,0 +1,94 @@
1
+ ---
2
+ name: multi-agent-dev
3
+ description: Coordinate multi-agent development across pi sessions using the talk extension. Explains how sessions discover and address each other, when to use talk-send vs talk-ask, and how to split work, exchange information, and review between agents. Use whenever you need to collaborate with other pi sessions or agents on the same machine.
4
+ ---
5
+
6
+ # Multi-Agent Development with Talk
7
+
8
+ ## Concept
9
+
10
+ Multi-agent development runs several independent pi sessions in parallel and coordinates them over **talk**. Every session is a complete agent workspace — its own cwd, conversation history, and context. Talk lets sessions discover each other, exchange messages, ask questions, and sync progress.
11
+
12
+ The core rule: **a peer only knows what you tell it.** Messages must be self-contained — background, goal, and constraints — because the receiving session has none of your context.
13
+
14
+ ## Session model
15
+
16
+ ### Discovery and addressing
17
+
18
+ - `talk-list-sessions` returns sessions as JSON — **your own session is included and marked `self: true`** (also where you learn your own id):
19
+
20
+ ```json
21
+ [
22
+ {
23
+ "status": "idle",
24
+ "work_dir": "/path/to/cwd",
25
+ "id": "0193a2f5-...",
26
+ "name": "...",
27
+ "self": true
28
+ }
29
+ ]
30
+ ```
31
+
32
+ - Addressing is **by session id only**: `talk-send` / `talk-ask` take the full `id` (pi session uuid). Names, paths, and prefixes are not accepted.
33
+ - An unknown or invisible target is refused with `Unknown session id` — always list before sending.
34
+
35
+ ### Status
36
+
37
+ - `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a reply)
38
+ - `offline` (process exited or marked dead)
39
+ - `talk-list-sessions` lists every visible session — live or offline — with its current status.
40
+
41
+ ### Visibility
42
+
43
+ - Each workspace controls what it can see via `allowed` in `<cwd>/.pi/talk.json` (path prefixes). Sessions outside the prefixes are neither listed nor addressable.
44
+ - Visibility is one-way: you seeing a session does not mean it sees you.
45
+
46
+ ## Tools
47
+
48
+ | Tool | Purpose |
49
+ | -------------------- | -------------------------------------------------------------------------------------- |
50
+ | `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`) |
51
+ | `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive) |
52
+ | `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
53
+ | `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
54
+
55
+ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown offline, swept soon).
56
+
57
+ ## Collaboration workflows
58
+
59
+ ### Split work between sessions
60
+
61
+ 1. `talk-list-sessions` first: see which sessions exist, their `work_dir`, and status.
62
+ 2. Assign work by module/files with `talk-send` — state the scope, boundaries, and expected output.
63
+ 3. Each session completes its slice, then sends the result or a review request.
64
+ 4. Sync progress periodically to avoid overlapping edits.
65
+
66
+ ### Synchronous question/answer (need the answer to continue)
67
+
68
+ - Use `talk-ask` when the next step depends on the peer's information and the peer is reachable.
69
+ - On receiving an ask, reply with `talk-reply` using the `replyTo` id from the delivered message.
70
+ - If two sessions ask each other simultaneously: the later asker yields — answer the peer's ask first, then re-ask.
71
+
72
+ ### Async notifications
73
+
74
+ - Use `talk-send` for heads-ups that do not block: send and keep working.
75
+ - Messages deliver on the next natural turn by default (`queue`); `steer` interrupts the peer immediately — behavior depends on the `talk.deliver` setting.
76
+
77
+ ### Cross-session review
78
+
79
+ - Ask another session to review your changes: `talk-send` the file paths plus a diff summary, request a review, and let it reply.
80
+ - Send paths and summaries, not whole file contents — the peer can `read` them itself.
81
+
82
+ ## Message style
83
+
84
+ - Plain text, ≤32KB. Send a summary and paths, never the full file or large code blocks.
85
+ - Self-contained: background, goal, constraints — the peer has none of your context.
86
+ - Make the ask explicit: "please review", "please implement", or "FYI only".
87
+ - One topic per message so the reply stays focused.
88
+
89
+ ## Pitfalls
90
+
91
+ - **Avoid message loops**: if the peer sent you something or is asking you, answer it before sending new ones. Two agents pinging each other deadlock.
92
+ - **Address from known ids**: only run `talk-list-sessions` to discover sessions or verify an id. If you already hold a valid id (e.g. from an incoming message or a previous listing), send directly — an unknown or invisible id is refused with `Unknown session id`.
93
+ - **Respect status**: asking an offline session blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
94
+ - **Visibility boundary**: you can only collaborate with sessions you can see; invisible sessions are unreachable by design.