@trim21/personal-pi-extensions 0.0.195 → 0.0.197

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,27 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
230
230
 
231
231
  ### 工具(LLM 可见)
232
232
 
233
- | 工具 | 作用 |
234
- | -------------------- | ----------------------------------------------------------------- |
235
- | `talk-list-sessions` | 列出其他 session(presence:idle/working/not responding/offline) |
236
- | `talk-read-messages` | 主动读收件箱(读即消费) |
237
- | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 120s 超时) |
238
- | `talk-wait` | 阻塞等待新消息到达 |
239
- | `talk-send` | 发送纯文本消息(`to: "*"` 广播所有,`to: "cwd"` 广播同 cwd) |
240
- | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
233
+ | 工具 | 作用 |
234
+ | -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
235
+ | `talk-list-sessions` | 列出其他 session,返回 JSON 数组(`status` / `work_dir` / `id` / `name`);默认只列有心跳的,`includeOffline: true` 列全部 |
236
+ | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
237
+ | `talk-wait` | 阻塞等待新消息到达 |
238
+ | `talk-send` | 发送纯文本消息(`to: "*"` 广播所有,`to: "cwd"` 广播同 cwd) |
239
+ | `talk-reply` | 回复一个 ask(`replyTo` ask id,显式关联、不推断) |
241
240
 
242
- 对端消息在模型工作过程中以 steer 方式注入上下文;模型也可主动 `talk-read-messages` 拉取。
241
+ 对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
242
+
243
+ **定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
243
244
 
244
245
  ### 关键设计
245
246
 
247
+ - **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline。
248
+ - **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
246
249
  - **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
247
250
  - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
248
251
  - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
249
252
  - **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递带「来自其他 session、无权威」声明。
253
+ - **workspace 可见性**:每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 控制自己能看到哪些 session,见下方配置。
250
254
 
251
255
  ### 配置
252
256
 
@@ -261,14 +265,39 @@ sqlite 文件路径按优先级取第一个可用值:
261
265
  ```jsonc
262
266
  // ~/.pi/agent/settings.json
263
267
  {
264
- "talk": { "db_path": "~/data/talk.db" },
268
+ "talk": { "db_path": "~/data/talk.db", "deliver": "queue" },
269
+ }
270
+ ```
271
+
272
+ `talk.deliver` 控制对端消息的投递方式:
273
+
274
+ - `"steer"`:消息到达时打断当前工作(工具调用间隙注入),空闲 session 被唤醒;
275
+ - `"queue"`:消息排队,在 session 下一轮自然 turn(如用户发消息)时注入,不主动唤醒。
276
+
277
+ 默认 `"queue"`。
278
+
279
+ ### workspace 可见性
280
+
281
+ 每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 决定自己能看到哪些 session:
282
+
283
+ ```jsonc
284
+ // ~/projects/company1/.pi/talk.json
285
+ {
286
+ "allowed": ["~/projects/company1/"],
265
287
  }
266
288
  ```
267
289
 
268
- | 变量 | 默认 | 含义 |
269
- | ----------------- | --------------------------------- | ----------------------------- |
270
- | `PI_TALK_DB` | settings `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
271
- | `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
290
+ - `allowed` 是路径前缀列表:能看到前缀本身及其子目录下的 session(`~/projects/company1/*`),看不到 `~/projects/company2/` 下的 session;`company1` 不会误匹配 `company12`
291
+ - 路径支持 `~` 展开,相对路径相对该 workspace cwd 解析
292
+ - `allowed` 字段 全部可见;`"allowed": []` 谁都看不到
293
+ - 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
294
+ - 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
295
+
296
+ | 变量 | 默认 | 含义 |
297
+ | ----------------- | --------------------------------- | ------------------------------- |
298
+ | `PI_TALK_DB` | settings 或 `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
299
+ | `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
300
+ | `talk.deliver` | `queue` | 消息投递方式:`steer` / `queue` |
272
301
 
273
302
  ### 使用
274
303
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.195",
3
+ "version": "0.0.197",
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
@@ -9,13 +9,10 @@
9
9
  * poll — so a swallowed sendMessage error no longer destroys the letter.
10
10
  */
11
11
 
12
- import {
13
- formatDelivery,
14
- formatListing,
15
- refusalAmbiguous,
16
- refusalUnknown,
17
- shortAddr,
18
- } from "./format.js";
12
+ import { isAbsolute, resolve } from "node:path";
13
+
14
+ import { expandHome } from "../lib/path.js";
15
+ import { formatDelivery, formatListing, refusalUnknown, shortAddr } from "./format.js";
19
16
  import {
20
17
  appendAudit,
21
18
  awaitReceipt,
@@ -38,6 +35,7 @@ import {
38
35
  } from "./mailbox.js";
39
36
  import { inboundAccepts, OutboundPolicy } from "./policy.js";
40
37
  import {
38
+ LIST_ACTIVE_MS,
41
39
  listRecords,
42
40
  type Presence,
43
41
  presenceOf,
@@ -63,8 +61,6 @@ export interface TalkCoreEvents {
63
61
  export interface TalkCoreOptions {
64
62
  storage: TalkStorage;
65
63
  events: TalkCoreEvents;
66
- /** Adapter-provided: which session ids pi can still resume (for sweep). */
67
- collectResumableSessionIds?: () => Set<string>;
68
64
  now?: () => number;
69
65
  }
70
66
 
@@ -74,11 +70,34 @@ const WATCH_POLL_MS = 5000;
74
70
  const DELIVERY_BACKOFF_MS = 5000;
75
71
  const INITIAL_DRAIN_DELAY_MS = 1200;
76
72
  const WAIT_POLL_MS = 500;
73
+ const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
77
74
 
78
75
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
79
76
 
80
- function recordLabel(record: SessionRecord): string {
81
- return `"${record.name}" (${shortAddr(record.addr)})`;
77
+ /** Normalize an allowed path: expand ~, resolve relative against baseCwd, strip trailing slashes. */
78
+ function normalizeAllowedPath(p: string, baseCwd: string): string {
79
+ const expanded = expandHome(p);
80
+ const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(baseCwd, expanded);
81
+ return abs.replace(/[\\/]+$/, "") || "/";
82
+ }
83
+
84
+ /**
85
+ * Build the workspace-visibility gate for one session. `allowed` is the
86
+ * `allowed` array from `<cwd>/.pi/talk.json` (undefined when the file or key
87
+ * is absent). A peer session is visible when its cwd equals an allowed prefix
88
+ * or sits below it (`prefix` or `prefix/*`); `company1` never matches
89
+ * `company12`. An undefined list shows everything; an explicit empty list
90
+ * shows nothing.
91
+ */
92
+ export function buildVisibilityFilter(
93
+ allowed: string[] | undefined,
94
+ baseCwd: string,
95
+ ): (peerCwd: string) => boolean {
96
+ if (allowed === undefined) return () => true;
97
+ if (allowed.length === 0) return () => false;
98
+ const prefixes = allowed.map((p) => normalizeAllowedPath(p, baseCwd));
99
+ return (peerCwd) =>
100
+ prefixes.some((prefix) => peerCwd === prefix || peerCwd.startsWith(`${prefix}/`));
82
101
  }
83
102
 
84
103
  /**
@@ -99,7 +118,6 @@ export function peerAskedFirst(
99
118
  export class TalkCore {
100
119
  private readonly storage: TalkStorage;
101
120
  private readonly events: TalkCoreEvents;
102
- private readonly collectResumableSessionIds?: () => Set<string>;
103
121
  private readonly now: () => number;
104
122
 
105
123
  private self: SessionRecord | undefined;
@@ -108,16 +126,18 @@ export class TalkCore {
108
126
  private readonly watched = new Map<string, Presence>();
109
127
  /** Message ids already handed to the adapter but not yet removed from the inbox. */
110
128
  private readonly deliveredIds = new Set<string>();
129
+ /** Visibility gate over peer working directories; defaults to everything visible. */
130
+ private isPeerVisible: (peerCwd: string) => boolean = () => true;
111
131
 
112
132
  private inboxPoll: ReturnType<typeof setInterval> | undefined;
113
133
  private heartbeat: ReturnType<typeof setInterval> | undefined;
114
134
  private watchPoller: ReturnType<typeof setInterval> | undefined;
135
+ private sweeper: ReturnType<typeof setInterval> | undefined;
115
136
  private lastDeliveryFailureAt = 0;
116
137
 
117
138
  constructor(options: TalkCoreOptions) {
118
139
  this.storage = options.storage;
119
140
  this.events = options.events;
120
- this.collectResumableSessionIds = options.collectResumableSessionIds;
121
141
  this.now = options.now ?? Date.now;
122
142
  }
123
143
 
@@ -125,6 +145,11 @@ export class TalkCore {
125
145
  return this.self?.addr;
126
146
  }
127
147
 
148
+ /** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
149
+ setPeerVisibility(filter: (peerCwd: string) => boolean): void {
150
+ this.isPeerVisible = filter;
151
+ }
152
+
128
153
  private requireSelf(): SessionRecord {
129
154
  const self = this.self;
130
155
  if (!self) throw new Error("Talk core is not started");
@@ -138,11 +163,7 @@ export class TalkCore {
138
163
  this.self = self;
139
164
  await writeRecord(this.storage, self);
140
165
  try {
141
- const collectResumable = this.collectResumableSessionIds;
142
- const sessionExists = collectResumable
143
- ? (id: string) => collectResumable().has(id)
144
- : undefined;
145
- await sweep(this.storage, this.now(), sessionExists);
166
+ await sweep(this.storage, this.now());
146
167
  } catch {
147
168
  // sweep failure never breaks the session
148
169
  }
@@ -151,6 +172,11 @@ export class TalkCore {
151
172
  void this.writeSelf({});
152
173
  }, HEARTBEAT_MS);
153
174
  this.heartbeat.unref();
175
+ // Reclaim dead records periodically, not just at startup.
176
+ this.sweeper = setInterval(() => {
177
+ void sweep(this.storage, this.now());
178
+ }, SWEEP_INTERVAL_MS);
179
+ this.sweeper.unref();
154
180
  // Drain mail queued while offline — deferred: delivering during
155
181
  // session_start races the session's own first turn.
156
182
  const initial = setTimeout(() => {
@@ -163,6 +189,7 @@ export class TalkCore {
163
189
  if (this.heartbeat) clearInterval(this.heartbeat);
164
190
  if (this.watchPoller) clearInterval(this.watchPoller);
165
191
  if (this.inboxPoll) clearInterval(this.inboxPoll);
192
+ if (this.sweeper) clearInterval(this.sweeper);
166
193
  if (this.self) {
167
194
  try {
168
195
  await this.writeSelf({ status: "idle", offline: true });
@@ -314,38 +341,18 @@ export class TalkCore {
314
341
 
315
342
  // ── Outbound ───────────────────────────────────────────────────────────
316
343
 
344
+ /**
345
+ * Resolve a target by its exact session id (uuid). A peer must be visible
346
+ * from this session (`setPeerVisibility`); invisible peers are unreachable
347
+ * even with a known id.
348
+ */
317
349
  private async resolveTarget(to: string): Promise<TargetResult> {
318
350
  const self = this.requireSelf();
319
351
  const records = await listRecords(this.storage);
320
- const others = records.filter((r) => r.addr !== self.addr);
321
- const exact = others.filter((r) => r.name.toLowerCase() === to.toLowerCase() || r.addr === to);
322
- const matches = exact.length > 0 ? exact : others.filter((r) => r.addr.startsWith(to));
323
- if (matches.length === 0)
324
- return {
325
- ok: false,
326
- error: refusalUnknown(
327
- to,
328
- others.map((r) => recordLabel(r)),
329
- ),
330
- };
331
- if (matches.length > 1)
332
- return {
333
- ok: false,
334
- error: refusalAmbiguous(
335
- to,
336
- matches.map((r) => recordLabel(r)),
337
- ),
338
- };
339
- const record = matches[0];
340
- if (!record)
341
- return {
342
- ok: false,
343
- error: refusalUnknown(
344
- to,
345
- others.map((r) => recordLabel(r)),
346
- ),
347
- };
348
- return { ok: true, record };
352
+ const others = records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
353
+ const target = others.find((r) => r.sessionId === to);
354
+ if (!target) return { ok: false, error: refusalUnknown(to) };
355
+ return { ok: true, record: target };
349
356
  }
350
357
 
351
358
  private async sendLetter(
@@ -459,20 +466,6 @@ export class TalkCore {
459
466
 
460
467
  // ── Tool actions ───────────────────────────────────────────────────────
461
468
 
462
- /** Actively read (and consume) inbox letters. Returns delivery-formatted text. */
463
- async readMessages(from?: string): Promise<string> {
464
- const self = this.requireSelf();
465
- let items = await listInbox(this.storage, self.addr);
466
- if (from && from !== "*") {
467
- const resolved = await this.resolveTarget(from);
468
- if (!resolved.ok) return resolved.error;
469
- items = items.filter((i) => i.letter.from.addr === resolved.record.addr);
470
- }
471
- const fresh = await this.consumeFresh(items);
472
- if (fresh.length === 0) return "No messages.";
473
- return fresh.map((l) => formatDelivery(l)).join("\n\n");
474
- }
475
-
476
469
  /** Block until a message arrives (or timeout/abort). */
477
470
  async wait(timeoutMs: number, signal?: AbortSignal): Promise<string> {
478
471
  const self = this.requireSelf();
@@ -487,17 +480,33 @@ export class TalkCore {
487
480
  }
488
481
  }
489
482
 
490
- async list(): Promise<string> {
483
+ /**
484
+ * JSON listing of visible peer sessions. Defaults to sessions whose
485
+ * heartbeat is fresh (within LIST_ACTIVE_MS); pass includeOffline to show
486
+ * every visible peer regardless of last contact.
487
+ */
488
+ async list(includeOffline = false): Promise<string> {
491
489
  const self = this.requireSelf();
492
- const records = await listRecords(this.storage);
493
- return formatListing(records, self.addr, (r) => presenceOf(r));
490
+ const now = this.now();
491
+ const all = await listRecords(this.storage);
492
+ const records = all.filter(
493
+ (r) => this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
494
+ );
495
+ return formatListing(records, self.addr, (r) => presenceOf(r, now));
494
496
  }
495
497
 
496
- async listCwd(cwd: string): Promise<string> {
498
+ /** Same as list(), filtered to one working directory. */
499
+ async listCwd(cwd: string, includeOffline = false): Promise<string> {
497
500
  const self = this.requireSelf();
501
+ const now = this.now();
498
502
  const records = await listRecords(this.storage);
499
- const filtered = records.filter((r) => r.cwd === cwd);
500
- return formatListing(filtered, self.addr, (r) => presenceOf(r));
503
+ const filtered = records.filter(
504
+ (r) =>
505
+ r.cwd === cwd &&
506
+ this.isPeerVisible(r.cwd) &&
507
+ (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
508
+ );
509
+ return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
501
510
  }
502
511
 
503
512
  async send(to: string, body: string): Promise<string> {
@@ -510,6 +519,7 @@ export class TalkCore {
510
519
  const records = await listRecords(this.storage);
511
520
  const peers = records.filter((r) => {
512
521
  if (r.addr === self.addr) return false;
522
+ if (!this.isPeerVisible(r.cwd)) return false;
513
523
  return to === "*" ? true : r.cwd === self.cwd;
514
524
  });
515
525
  if (peers.length === 0) return "No other sessions to broadcast to.";
@@ -547,7 +557,7 @@ export class TalkCore {
547
557
  const inbox = await listInbox(this.storage, self.addr);
548
558
  const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
549
559
  if (fromTarget.length > 0) {
550
- return `You have ${fromTarget.length} unread message(s) from "${record.name}". Read them with talk-read-messages and reply before asking.`;
560
+ return `You have ${fromTarget.length} unread message(s) from "${record.name}" reply before asking.`;
551
561
  }
552
562
  const sent = await this.sendLetter(record, "ask", body);
553
563
  if (!sent.ok) return sent.error;
@@ -567,7 +577,7 @@ export class TalkCore {
567
577
  async reply(replyTo: string, body: string): Promise<string> {
568
578
  if (!body) return "reply requires 'message'.";
569
579
  if (!replyTo) {
570
- return "reply requires 'replyTo' (the ask/message id). Use talk-read-messages to find the id.";
580
+ return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
571
581
  }
572
582
  const self = this.requireSelf();
573
583
  const ask = await resolveAskByRef(this.storage, self.addr, replyTo);
@@ -30,25 +30,31 @@ export function formatDelivery(letter: Letter, now: number = Date.now()): string
30
30
  return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
31
31
  }
32
32
 
33
+ /** One peer session as the model sees it in a listing. `id` is the stable
34
+ * pi session uuid; `name` is the display name when one was set. */
35
+ export interface SessionListItem {
36
+ status: string;
37
+ work_dir: string;
38
+ id: string;
39
+ name?: string;
40
+ }
41
+
42
+ /** Machine-readable JSON listing of peer sessions (what the model sees). */
33
43
  export function formatListing(
34
44
  records: SessionRecord[],
35
45
  selfAddr: string,
36
46
  presence: (r: SessionRecord) => Presence,
37
47
  ): string {
38
48
  const others = records.filter((r) => r.addr !== selfAddr);
39
- if (others.length === 0) return "No other pi sessions registered.";
40
- const rows = others.map((r) => {
49
+ if (others.length === 0) return "[]";
50
+ const items: SessionListItem[] = others.map((r) => {
41
51
  const p = presence(r);
42
- const state = p === "live" ? r.status : p === "stalled" ? "not responding" : "offline";
43
- return `• ${r.name} (${shortAddr(r.addr)}) ${r.cwd} [${state}]`;
52
+ const status = p === "live" ? r.status : p === "stalled" ? "not responding" : "offline";
53
+ return { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
44
54
  });
45
- return rows.join("\n");
46
- }
47
-
48
- export function refusalUnknown(to: string, reachable: string[]): string {
49
- return `No session matches '${to}'. Reachable: ${reachable.length > 0 ? reachable.join(", ") : "(none)"}.`;
55
+ return JSON.stringify(items, null, 2);
50
56
  }
51
57
 
52
- export function refusalAmbiguous(to: string, candidates: string[]): string {
53
- return `'${to}' is ambiguous; matches: ${candidates.join(", ")}. Use a full name or address prefix.`;
58
+ export function refusalUnknown(to: string): string {
59
+ return `Unknown session id '${to}'. Get session ids with talk-list-sessions.`;
54
60
  }
package/src/talk/index.ts CHANGED
@@ -18,7 +18,7 @@ import { Box, Text } from "@earendil-works/pi-tui";
18
18
  import { Type } from "typebox";
19
19
 
20
20
  import { resolveHomePath } from "../lib/path.js";
21
- import { TalkCore } from "./core.js";
21
+ import { buildVisibilityFilter, TalkCore } from "./core.js";
22
22
  import { formatDelivery } from "./format.js";
23
23
  import type { Letter } from "./mailbox.js";
24
24
  import { deriveAddr, type SessionRecord } from "./registry.js";
@@ -28,7 +28,7 @@ const DELIVERY_TYPE = "talk:delivery";
28
28
  const LIST_TYPE = "talk:list";
29
29
  const NOTIFY_TYPE = "talk:notify";
30
30
 
31
- const ASK_TIMEOUT_MS = 120_000;
31
+ const ASK_TIMEOUT_MS = 30 * 60 * 1000;
32
32
 
33
33
  function toolResult(text: string) {
34
34
  return { content: [{ type: "text" as const, text }], details: {} };
@@ -80,48 +80,51 @@ interface DeliveryDetails {
80
80
  replyTo?: string;
81
81
  }
82
82
 
83
- // ── Resumability (for sweep) ─────────────────────────────────────────────
84
- // pi session files live at <agentDir>/sessions/<cwd-slug>/<ts>_<sessionId>.jsonl.
85
- // Collected once per sweep; a session whose file exists can be resumed and
86
- // must keep its mailbox address.
87
-
88
- function collectResumableSessionIds(): Set<string> {
89
- const ids = new Set<string>();
90
- let dirs: string[];
83
+ /**
84
+ * Read the default sqlite path and delivery mode from global settings.json:
85
+ * `{ "talk": { "db_path": "...", "deliver": "steer" | "queue" } }`.
86
+ */
87
+ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
91
88
  try {
92
- dirs = fs.readdirSync(path.join(getAgentDir(), "sessions"));
89
+ const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
90
+ const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown; deliver?: unknown } };
91
+ const talk = parsed.talk;
92
+ if (!talk) return {};
93
+ const dbPath = typeof talk.db_path === "string" ? talk.db_path : undefined;
94
+ const deliver = talk.deliver === "steer" || talk.deliver === "queue" ? talk.deliver : undefined;
95
+ return { dbPath, deliver };
93
96
  } catch {
94
- return ids;
97
+ return {};
95
98
  }
96
- for (const dir of dirs) {
97
- try {
98
- for (const file of fs.readdirSync(path.join(getAgentDir(), "sessions", dir))) {
99
- const match = /_([0-9a-f-]{36})\.jsonl$/.exec(file);
100
- if (match) ids.add(match[1]);
101
- }
102
- } catch {
103
- // skip unreadable dir
104
- }
105
- }
106
- return ids;
107
99
  }
108
100
 
109
- /** Read the default sqlite path from global settings.json: `{ "talk": { "db_path": "..." } }`. */
110
- function readDbPathFromSettings(): string | undefined {
101
+ /**
102
+ * Read the workspace visibility config from `<cwd>/.pi/talk.json`:
103
+ * `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
104
+ * (everything visible); an explicit `"allowed": []` hides every peer.
105
+ */
106
+ function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
111
107
  try {
112
- const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
113
- const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown } };
114
- return typeof parsed.talk?.db_path === "string" ? parsed.talk.db_path : undefined;
108
+ const raw = fs.readFileSync(path.join(cwd, ".pi", "talk.json"), "utf8");
109
+ const parsed = JSON.parse(raw) as { allowed?: unknown };
110
+ if (Array.isArray(parsed.allowed)) {
111
+ return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
112
+ }
113
+ return {};
115
114
  } catch {
116
- return undefined;
115
+ return {};
117
116
  }
118
117
  }
119
118
 
120
119
  export default function talk(pi: ExtensionAPI) {
121
- const configured = process.env.PI_TALK_DB ?? readDbPathFromSettings();
120
+ const settings = readTalkSettings();
121
+ const configured = process.env.PI_TALK_DB ?? settings.dbPath;
122
122
  const dbPath = configured
123
123
  ? resolveHomePath(configured, getAgentDir())
124
124
  : path.join(getAgentDir(), "talk.db");
125
+ // "queue": deliver on the session's next natural turn without waking it;
126
+ // "steer": interrupt mid-run / wake an idle session immediately.
127
+ const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
125
128
  const storage = new SqliteTalkStorage(dbPath);
126
129
 
127
130
  let self: SessionRecord | undefined;
@@ -136,12 +139,13 @@ export default function talk(pi: ExtensionAPI) {
136
139
  ...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
137
140
  };
138
141
  try {
139
- // steer lands between tool calls mid-run; triggerTurn wakes an idle
140
- // session. The core only removes the letter from the inbox after this
141
- // returns true, so a failure keeps it queued for a later poll.
142
+ // The core only removes the letter from the inbox after this returns
143
+ // true, so a failure keeps it queued for a later poll.
142
144
  pi.sendMessage(
143
145
  { customType: DELIVERY_TYPE, content: formatDelivery(letter), display: true, details },
144
- { triggerTurn: true, deliverAs: "steer" },
146
+ deliverMode === "steer"
147
+ ? { triggerTurn: true, deliverAs: "steer" }
148
+ : { deliverAs: "nextTurn" },
145
149
  );
146
150
  return true;
147
151
  } catch {
@@ -162,7 +166,6 @@ export default function talk(pi: ExtensionAPI) {
162
166
  );
163
167
  },
164
168
  },
165
- collectResumableSessionIds,
166
169
  });
167
170
 
168
171
  function requireInit(): string | undefined {
@@ -186,6 +189,7 @@ export default function talk(pi: ExtensionAPI) {
186
189
  lastSeenAt: now,
187
190
  status: "idle",
188
191
  };
192
+ core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
189
193
  void core.start(self);
190
194
  });
191
195
 
@@ -205,33 +209,23 @@ export default function talk(pi: ExtensionAPI) {
205
209
  name: "talk-list-sessions",
206
210
  label: "List Talk Sessions",
207
211
  description:
208
- "List other pi sessions on this machine with their presence (idle/working/not responding/offline), address, and working directory.",
212
+ "List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
209
213
  promptSnippet: "List other pi sessions on this machine",
210
214
  parameters: Type.Object({
211
215
  cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
212
- }),
213
- async execute(_toolCallId, params) {
214
- const initError = requireInit();
215
- if (initError) return toolResult(initError);
216
- return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
217
- },
218
- });
219
-
220
- pi.registerTool({
221
- name: "talk-read-messages",
222
- label: "Read Talk Messages",
223
- description:
224
- "Read and consume messages from your talk inbox. Use this to actively check for incoming messages instead of waiting for a steer notification.",
225
- promptSnippet: "Read incoming talk messages",
226
- parameters: Type.Object({
227
- from: Type.Optional(
228
- Type.String({ description: "Only read messages from this session (name/address/@alias)" }),
216
+ includeOffline: Type.Optional(
217
+ Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
229
218
  ),
230
219
  }),
231
220
  async execute(_toolCallId, params) {
232
221
  const initError = requireInit();
233
222
  if (initError) return toolResult(initError);
234
- return toolResult(await core.readMessages(params.from));
223
+ const includeOffline = params.includeOffline === true;
224
+ return toolResult(
225
+ params.cwd
226
+ ? await core.listCwd(params.cwd, includeOffline)
227
+ : await core.list(includeOffline),
228
+ );
235
229
  },
236
230
  });
237
231
 
@@ -303,7 +297,7 @@ export default function talk(pi: ExtensionAPI) {
303
297
  name: "talk-reply",
304
298
  label: "Reply Talk",
305
299
  description:
306
- "Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message, or from talk-read-messages).",
300
+ "Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message).",
307
301
  promptSnippet: "Reply to a talk ask",
308
302
  parameters: Type.Object({
309
303
  replyTo: Type.String({ description: "The ask/message id to reply to" }),
@@ -38,6 +38,10 @@ export type SessionRecord = Static<typeof SessionRecordSchema>;
38
38
  export type Presence = "live" | "stalled" | "offline";
39
39
 
40
40
  export const HEARTBEAT_STALE_MS = 45_000;
41
+ /** A session is shown in the default listing while its heartbeat is this fresh. */
42
+ export const LIST_ACTIVE_MS = 15 * 60 * 1000;
43
+ /** Sweep leaves a record alone until its heartbeat has been quiet this long. */
44
+ export const SWEEP_OFFLINE_GRACE_MS = 24 * 60 * 60 * 1000;
41
45
  /** A mailbox holding undelivered mail is kept this long after last contact. */
42
46
  export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
43
47
 
@@ -118,29 +122,21 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
118
122
 
119
123
  /**
120
124
  * Reclaim dead sessions' data. Rules (mail outranks tidiness):
121
- * - a running session is never touched;
125
+ * - a record whose heartbeat went quiet less than SWEEP_OFFLINE_GRACE_MS ago
126
+ * is never touched — it may be merely down or suspended, and a resume will
127
+ * re-register it under the same id anyway;
122
128
  * - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
123
- * - an offline but resumable session keeps its record (its address new
124
- * mail must remain deliverable while it's down);
125
- * - only an empty mailbox of a session that can no longer be resumed is
126
- * discarded promptly.
127
- *
128
- * `sessionExists(sessionId)` reports whether pi can still resume the session
129
- * (its session file is present). When omitted, every offline session is
130
- * treated as resumable (the conservative choice).
129
+ * - once the grace period has passed, an empty mailbox is discarded promptly
130
+ * regardless of whether pi could still resume the session (resume re-creates
131
+ * the record; with no mail nothing is lost).
131
132
  */
132
- export async function sweep(
133
- storage: TalkStorage,
134
- now: number = Date.now(),
135
- sessionExists?: (sessionId: string) => boolean,
136
- ): Promise<void> {
133
+ export async function sweep(storage: TalkStorage, now: number = Date.now()): Promise<void> {
137
134
  for (const record of await listRecords(storage)) {
138
- if (presenceOf(record, now) !== "offline") continue;
135
+ const quietFor = now - record.lastSeenAt;
136
+ if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;
139
137
  const hasMail =
140
138
  (await storage.hasKeys(inboxNs(record.addr))) || (await storage.hasKeys(asksNs(record.addr)));
141
- const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
142
- if (hasMail && !expired) continue;
143
- if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
139
+ if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS) continue;
144
140
  await storage.removeNamespace(inboxNs(record.addr));
145
141
  await storage.removeNamespace(asksNs(record.addr));
146
142
  await storage.removeKey(RECORDS_NS, recordKey(record.addr));