@trim21/personal-pi-extensions 0.0.203 → 0.0.205

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` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);默认只列有心跳的,`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.203",
3
+ "version": "0.0.205",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
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
  }
@@ -450,31 +445,29 @@ export class TalkCore {
450
445
 
451
446
  /**
452
447
  * JSON listing of visible sessions, including self (marked `self: true`).
453
- * Defaults to sessions whose heartbeat is fresh (within LIST_ACTIVE_MS);
454
- * pass includeOffline to show every visible peer regardless of last contact.
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
454
  const records = all.filter((r) => {
461
455
  if (r.addr === self.addr) return !this.dead;
462
- return this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS);
456
+ return this.isPeerVisible(r.cwd);
463
457
  });
464
- return formatListing(records, self.addr, (r) => presenceOf(r, now));
458
+ return formatListing(records, self.addr, presenceOf);
465
459
  }
466
460
 
467
461
  /** Same as list(), filtered to one working directory. */
468
- async listCwd(cwd: string, includeOffline = false): Promise<string> {
462
+ async listCwd(cwd: string): Promise<string> {
469
463
  const self = this.requireSelf();
470
- const now = this.now();
471
464
  const records = await listRecords(this.storage);
472
465
  const filtered = records.filter((r) => {
473
466
  if (r.cwd !== cwd) return false;
474
467
  if (r.addr === self.addr) return !this.dead;
475
- return this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS);
468
+ return this.isPeerVisible(r.cwd);
476
469
  });
477
- return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
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
  }
@@ -51,7 +51,7 @@ export function formatListing(
51
51
  if (records.length === 0) return "[]";
52
52
  const items: SessionListItem[] = records.map((r) => {
53
53
  const p = presence(r);
54
- const status = p === "live" ? r.status : p === "stalled" ? "not responding" : "offline";
54
+ const status = p === "live" ? r.status : "offline";
55
55
  const item: SessionListItem = { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
56
56
  if (r.addr === selfAddr) item.self = true;
57
57
  return item;
package/src/talk/index.ts CHANGED
@@ -224,24 +224,15 @@ export default function talk(pi: ExtensionAPI) {
224
224
  pi.registerTool({
225
225
  name: "talk-list-sessions",
226
226
  label: "List Talk Sessions",
227
- description:
228
- "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).",
229
228
  promptSnippet: "List other pi sessions on this machine",
230
229
  parameters: Type.Object({
231
230
  cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
232
- includeOffline: Type.Optional(
233
- Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
234
- ),
235
231
  }),
236
232
  async execute(_toolCallId, params) {
237
233
  const initError = requireInit();
238
234
  if (initError) return toolResult(initError);
239
- const includeOffline = params.includeOffline === true;
240
- return toolResult(
241
- params.cwd
242
- ? await core.listCwd(params.cwd, includeOffline)
243
- : await core.list(includeOffline),
244
- );
235
+ return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
245
236
  },
246
237
  });
247
238
 
@@ -276,12 +267,10 @@ export default function talk(pi: ExtensionAPI) {
276
267
  name: "talk-send",
277
268
  label: "Send Talk Message",
278
269
  description:
279
- '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.",
280
271
  promptSnippet: "Send a message to another pi session",
281
272
  parameters: Type.Object({
282
- to: Type.String({
283
- description: 'Target session (name/address/@alias; "*" or "cwd" to broadcast)',
284
- }),
273
+ to: Type.String({ description: "Target session id (from talk-list-sessions)" }),
285
274
  message: Type.String({ description: "Message body" }),
286
275
  }),
287
276
  async execute(_toolCallId, params) {
@@ -320,7 +309,7 @@ export default function talk(pi: ExtensionAPI) {
320
309
 
321
310
  pi.registerCommand("talk-dead", {
322
311
  description:
323
- "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",
324
313
  async handler(args) {
325
314
  const initError = requireInit();
326
315
  const trimmed = args.trim();
@@ -348,13 +337,13 @@ export default function talk(pi: ExtensionAPI) {
348
337
  ) {
349
338
  return; // pre-renderer entries: keep pi's default custom-message box
350
339
  }
351
- const id8 = d.id.slice(0, 8);
340
+ const idTail = d.id.slice(-8);
352
341
  const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
353
342
  const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
354
- 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)}`);
355
344
  const out = [header, sanitizeTerminal(d.body), "", footer];
356
345
  if (d.kind === "ask") {
357
- out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${id8}"`));
346
+ out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${idTail}"`));
358
347
  }
359
348
  const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
360
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 =
@@ -35,8 +35,8 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
35
35
  ### Status
36
36
 
37
37
  - `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a reply)
38
- - `not responding` (heartbeat stale, process alive but unresponsive) / `offline` (process exited or marked dead)
39
- - The default listing shows only sessions with a heartbeat in the last 15 minutes; pass `includeOffline: true` for everything.
38
+ - `offline` (process exited or marked dead)
39
+ - `talk-list-sessions` lists every visible session live or offline with its current status.
40
40
 
41
41
  ### Visibility
42
42
 
@@ -45,14 +45,14 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
45
45
 
46
46
  ## Tools
47
47
 
48
- | Tool | Purpose |
49
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
50
- | `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`) |
51
- | `talk-send` | Send a plain message (async — the main collaboration primitive). `to: "*"` broadcasts, `to: "cwd"` broadcasts to the same directory |
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 |
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
54
 
55
- In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (removes it from listings and sweeps it).
55
+ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown offline, swept soon).
56
56
 
57
57
  ## Collaboration workflows
58
58
 
@@ -85,10 +85,11 @@ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (remove
85
85
  - Self-contained: background, goal, constraints — the peer has none of your context.
86
86
  - Make the ask explicit: "please review", "please implement", or "FYI only".
87
87
  - One topic per message so the reply stays focused.
88
+ - No empty pleasantries: skip "received, thanks for the sync" / "nice collaborating with you" / "thanks, noted". An acknowledgment with no new information wastes a peer's turn — send facts, questions, or decisions, not ceremony.
88
89
 
89
90
  ## Pitfalls
90
91
 
91
92
  - **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
93
  - **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/not-responding session blocks up to 30 min. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
94
+ - **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
95
  - **Visibility boundary**: you can only collaborate with sessions you can see; invisible sessions are unreachable by design.