@trim21/personal-pi-extensions 0.0.195 → 0.0.198

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,29 @@ 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/路径/前缀匹配。
244
+
245
+ **标记废弃 session**:`/talk-dead` 把 session 的 `lastSeenAt` 置 0(从默认列表消失,下次 sweep 无 mail 即回收):无参标记当前 session(同时停止其心跳),`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
243
246
 
244
247
  ### 关键设计
245
248
 
249
+ - **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline。
250
+ - **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
246
251
  - **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
247
252
  - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
248
253
  - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
249
254
  - **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递带「来自其他 session、无权威」声明。
255
+ - **workspace 可见性**:每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 控制自己能看到哪些 session,见下方配置。
250
256
 
251
257
  ### 配置
252
258
 
@@ -261,14 +267,39 @@ sqlite 文件路径按优先级取第一个可用值:
261
267
  ```jsonc
262
268
  // ~/.pi/agent/settings.json
263
269
  {
264
- "talk": { "db_path": "~/data/talk.db" },
270
+ "talk": { "db_path": "~/data/talk.db", "deliver": "queue" },
271
+ }
272
+ ```
273
+
274
+ `talk.deliver` 控制对端消息的投递方式:
275
+
276
+ - `"steer"`:消息到达时打断当前工作(工具调用间隙注入),空闲 session 被唤醒;
277
+ - `"queue"`:消息排队,在 session 下一轮自然 turn(如用户发消息)时注入,不主动唤醒。
278
+
279
+ 默认 `"queue"`。
280
+
281
+ ### workspace 可见性
282
+
283
+ 每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 决定自己能看到哪些 session:
284
+
285
+ ```jsonc
286
+ // ~/projects/company1/.pi/talk.json
287
+ {
288
+ "allowed": ["~/projects/company1/"],
265
289
  }
266
290
  ```
267
291
 
268
- | 变量 | 默认 | 含义 |
269
- | ----------------- | --------------------------------- | ----------------------------- |
270
- | `PI_TALK_DB` | settings `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
271
- | `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
292
+ - `allowed` 是路径前缀列表:能看到前缀本身及其子目录下的 session(`~/projects/company1/*`),看不到 `~/projects/company2/` 下的 session;`company1` 不会误匹配 `company12`
293
+ - 路径支持 `~` 展开,相对路径相对该 workspace cwd 解析
294
+ - `allowed` 字段 全部可见;`"allowed": []` 谁都看不到
295
+ - 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
296
+ - 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
297
+
298
+ | 变量 | 默认 | 含义 |
299
+ | ----------------- | --------------------------------- | ------------------------------- |
300
+ | `PI_TALK_DB` | settings 或 `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
301
+ | `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
302
+ | `talk.deliver` | `queue` | 消息投递方式:`steer` / `queue` |
272
303
 
273
304
  ### 使用
274
305
 
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.198",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Minimal JSONC → JSON conversion for small user-edited config files.
3
+ *
4
+ * Strips line and block comments and trailing commas outside of string
5
+ * literals, so a stray comment in a config file does not silently void the
6
+ * whole file (the way a plain `JSON.parse` would).
7
+ */
8
+
9
+ export function jsoncToJson(raw: string): string {
10
+ let out = "";
11
+ let inString = false;
12
+ let i = 0;
13
+ while (i < raw.length) {
14
+ const c = raw[i];
15
+ if (inString) {
16
+ out += c;
17
+ if (c === "\\" && i + 1 < raw.length) {
18
+ out += raw[i + 1];
19
+ i += 2;
20
+ continue;
21
+ }
22
+ if (c === '"') inString = false;
23
+ i++;
24
+ continue;
25
+ }
26
+ switch (c) {
27
+ case '"': {
28
+ inString = true;
29
+ out += c;
30
+ i++;
31
+ continue;
32
+ }
33
+ case "/": {
34
+ if (raw[i + 1] === "/") {
35
+ while (i < raw.length && raw[i] !== "\n") i++;
36
+ continue;
37
+ }
38
+ if (raw[i + 1] === "*") {
39
+ i += 2;
40
+ while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
41
+ i += 2;
42
+ continue;
43
+ }
44
+ out += c;
45
+ i++;
46
+ continue;
47
+ }
48
+ case ",": {
49
+ // Drop a trailing comma before } or ] (outside strings).
50
+ let j = i + 1;
51
+ while (j < raw.length && /\s/.test(raw[j])) j++;
52
+ if (raw[j] === "}" || raw[j] === "]") {
53
+ i++;
54
+ continue;
55
+ }
56
+ out += c;
57
+ i++;
58
+ continue;
59
+ }
60
+ default: {
61
+ out += c;
62
+ i++;
63
+ }
64
+ }
65
+ }
66
+ return out;
67
+ }
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,20 @@ 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;
131
+ /** Manually marked dead: heartbeat stopped, lastSeenAt pinned to 0. */
132
+ private dead = false;
111
133
 
112
134
  private inboxPoll: ReturnType<typeof setInterval> | undefined;
113
135
  private heartbeat: ReturnType<typeof setInterval> | undefined;
114
136
  private watchPoller: ReturnType<typeof setInterval> | undefined;
137
+ private sweeper: ReturnType<typeof setInterval> | undefined;
115
138
  private lastDeliveryFailureAt = 0;
116
139
 
117
140
  constructor(options: TalkCoreOptions) {
118
141
  this.storage = options.storage;
119
142
  this.events = options.events;
120
- this.collectResumableSessionIds = options.collectResumableSessionIds;
121
143
  this.now = options.now ?? Date.now;
122
144
  }
123
145
 
@@ -125,6 +147,11 @@ export class TalkCore {
125
147
  return this.self?.addr;
126
148
  }
127
149
 
150
+ /** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
151
+ setPeerVisibility(filter: (peerCwd: string) => boolean): void {
152
+ this.isPeerVisible = filter;
153
+ }
154
+
128
155
  private requireSelf(): SessionRecord {
129
156
  const self = this.self;
130
157
  if (!self) throw new Error("Talk core is not started");
@@ -138,11 +165,7 @@ export class TalkCore {
138
165
  this.self = self;
139
166
  await writeRecord(this.storage, self);
140
167
  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);
168
+ await sweep(this.storage, this.now());
146
169
  } catch {
147
170
  // sweep failure never breaks the session
148
171
  }
@@ -151,6 +174,11 @@ export class TalkCore {
151
174
  void this.writeSelf({});
152
175
  }, HEARTBEAT_MS);
153
176
  this.heartbeat.unref();
177
+ // Reclaim dead records periodically, not just at startup.
178
+ this.sweeper = setInterval(() => {
179
+ void sweep(this.storage, this.now());
180
+ }, SWEEP_INTERVAL_MS);
181
+ this.sweeper.unref();
154
182
  // Drain mail queued while offline — deferred: delivering during
155
183
  // session_start races the session's own first turn.
156
184
  const initial = setTimeout(() => {
@@ -163,6 +191,7 @@ export class TalkCore {
163
191
  if (this.heartbeat) clearInterval(this.heartbeat);
164
192
  if (this.watchPoller) clearInterval(this.watchPoller);
165
193
  if (this.inboxPoll) clearInterval(this.inboxPoll);
194
+ if (this.sweeper) clearInterval(this.sweeper);
166
195
  if (this.self) {
167
196
  try {
168
197
  await this.writeSelf({ status: "idle", offline: true });
@@ -186,7 +215,8 @@ export class TalkCore {
186
215
 
187
216
  private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
188
217
  if (!this.self) return;
189
- this.self = { ...this.self, ...patch, lastSeenAt: this.now() };
218
+ // A dead session pins lastSeenAt to 0 so no later event re-freshens it.
219
+ this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
190
220
  try {
191
221
  await writeRecord(this.storage, this.self);
192
222
  } catch {
@@ -314,38 +344,18 @@ export class TalkCore {
314
344
 
315
345
  // ── Outbound ───────────────────────────────────────────────────────────
316
346
 
347
+ /**
348
+ * Resolve a target by its exact session id (uuid). A peer must be visible
349
+ * from this session (`setPeerVisibility`); invisible peers are unreachable
350
+ * even with a known id.
351
+ */
317
352
  private async resolveTarget(to: string): Promise<TargetResult> {
318
353
  const self = this.requireSelf();
319
354
  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 };
355
+ const others = records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
356
+ const target = others.find((r) => r.sessionId === to);
357
+ if (!target) return { ok: false, error: refusalUnknown(to) };
358
+ return { ok: true, record: target };
349
359
  }
350
360
 
351
361
  private async sendLetter(
@@ -459,20 +469,6 @@ export class TalkCore {
459
469
 
460
470
  // ── Tool actions ───────────────────────────────────────────────────────
461
471
 
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
472
  /** Block until a message arrives (or timeout/abort). */
477
473
  async wait(timeoutMs: number, signal?: AbortSignal): Promise<string> {
478
474
  const self = this.requireSelf();
@@ -487,17 +483,71 @@ export class TalkCore {
487
483
  }
488
484
  }
489
485
 
490
- async list(): Promise<string> {
486
+ /**
487
+ * JSON listing of visible peer sessions. Defaults to sessions whose
488
+ * heartbeat is fresh (within LIST_ACTIVE_MS); pass includeOffline to show
489
+ * every visible peer regardless of last contact.
490
+ */
491
+ async list(includeOffline = false): Promise<string> {
491
492
  const self = this.requireSelf();
493
+ const now = this.now();
494
+ const all = await listRecords(this.storage);
495
+ const records = all.filter(
496
+ (r) => this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
497
+ );
498
+ return formatListing(records, self.addr, (r) => presenceOf(r, now));
499
+ }
500
+
501
+ /** Same as list(), filtered to one working directory. */
502
+ async listCwd(cwd: string, includeOffline = false): Promise<string> {
503
+ const self = this.requireSelf();
504
+ const now = this.now();
492
505
  const records = await listRecords(this.storage);
493
- return formatListing(records, self.addr, (r) => presenceOf(r));
506
+ const filtered = records.filter(
507
+ (r) =>
508
+ r.cwd === cwd &&
509
+ this.isPeerVisible(r.cwd) &&
510
+ (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
511
+ );
512
+ return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
494
513
  }
495
514
 
496
- async listCwd(cwd: string): Promise<string> {
515
+ /** Visible peer records (excluding self), e.g. for command completions. */
516
+ async listPeers(): Promise<SessionRecord[]> {
497
517
  const self = this.requireSelf();
498
518
  const records = await listRecords(this.storage);
499
- const filtered = records.filter((r) => r.cwd === cwd);
500
- return formatListing(filtered, self.addr, (r) => presenceOf(r));
519
+ return records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
520
+ }
521
+
522
+ /**
523
+ * Mark a session as dead by pinning lastSeenAt to 0: it vanishes from the
524
+ * default listing and the next sweep reaps it (empty mailbox). Without a
525
+ * target, marks this session — its heartbeat is stopped and later
526
+ * writeSelf calls no longer refresh lastSeenAt.
527
+ */
528
+ async markDead(target?: string): Promise<string> {
529
+ if (!target) {
530
+ this.dead = true;
531
+ if (this.heartbeat) {
532
+ clearInterval(this.heartbeat);
533
+ this.heartbeat = undefined;
534
+ }
535
+ await this.writeSelf({});
536
+ return "Marked this session as dead.";
537
+ }
538
+ const resolved = await this.resolveTarget(target);
539
+ if (!resolved.ok) return resolved.error;
540
+ await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
541
+ return `Marked "${resolved.record.name}" as dead.`;
542
+ }
543
+
544
+ /** Mark every visible peer (except self) as dead. */
545
+ async markAllDead(): Promise<string> {
546
+ const peers = await this.listPeers();
547
+ for (const peer of peers) {
548
+ await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
549
+ }
550
+ return `Marked ${peers.length} session(s) as dead.`;
501
551
  }
502
552
 
503
553
  async send(to: string, body: string): Promise<string> {
@@ -510,6 +560,7 @@ export class TalkCore {
510
560
  const records = await listRecords(this.storage);
511
561
  const peers = records.filter((r) => {
512
562
  if (r.addr === self.addr) return false;
563
+ if (!this.isPeerVisible(r.cwd)) return false;
513
564
  return to === "*" ? true : r.cwd === self.cwd;
514
565
  });
515
566
  if (peers.length === 0) return "No other sessions to broadcast to.";
@@ -547,7 +598,7 @@ export class TalkCore {
547
598
  const inbox = await listInbox(this.storage, self.addr);
548
599
  const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
549
600
  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.`;
601
+ return `You have ${fromTarget.length} unread message(s) from "${record.name}" reply before asking.`;
551
602
  }
552
603
  const sent = await this.sendLetter(record, "ask", body);
553
604
  if (!sent.ok) return sent.error;
@@ -567,7 +618,7 @@ export class TalkCore {
567
618
  async reply(replyTo: string, body: string): Promise<string> {
568
619
  if (!body) return "reply requires 'message'.";
569
620
  if (!replyTo) {
570
- return "reply requires 'replyTo' (the ask/message id). Use talk-read-messages to find the id.";
621
+ return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
571
622
  }
572
623
  const self = this.requireSelf();
573
624
  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
@@ -17,8 +17,9 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
17
  import { Box, Text } from "@earendil-works/pi-tui";
18
18
  import { Type } from "typebox";
19
19
 
20
+ import { jsoncToJson } from "../lib/jsonc.js";
20
21
  import { resolveHomePath } from "../lib/path.js";
21
- import { TalkCore } from "./core.js";
22
+ import { buildVisibilityFilter, TalkCore } from "./core.js";
22
23
  import { formatDelivery } from "./format.js";
23
24
  import type { Letter } from "./mailbox.js";
24
25
  import { deriveAddr, type SessionRecord } from "./registry.js";
@@ -28,7 +29,7 @@ const DELIVERY_TYPE = "talk:delivery";
28
29
  const LIST_TYPE = "talk:list";
29
30
  const NOTIFY_TYPE = "talk:notify";
30
31
 
31
- const ASK_TIMEOUT_MS = 120_000;
32
+ const ASK_TIMEOUT_MS = 30 * 60 * 1000;
32
33
 
33
34
  function toolResult(text: string) {
34
35
  return { content: [{ type: "text" as const, text }], details: {} };
@@ -80,48 +81,54 @@ interface DeliveryDetails {
80
81
  replyTo?: string;
81
82
  }
82
83
 
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[];
84
+ /**
85
+ * Read the default sqlite path and delivery mode from global settings.json:
86
+ * `{ "talk": { "db_path": "...", "deliver": "steer" | "queue" } }`.
87
+ */
88
+ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
91
89
  try {
92
- dirs = fs.readdirSync(path.join(getAgentDir(), "sessions"));
90
+ const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
91
+ const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown; deliver?: unknown } };
92
+ const talk = parsed.talk;
93
+ if (!talk) return {};
94
+ const dbPath = typeof talk.db_path === "string" ? talk.db_path : undefined;
95
+ const deliver = talk.deliver === "steer" || talk.deliver === "queue" ? talk.deliver : undefined;
96
+ return { dbPath, deliver };
93
97
  } catch {
94
- return ids;
95
- }
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
- }
98
+ return {};
105
99
  }
106
- return ids;
107
100
  }
108
101
 
109
- /** Read the default sqlite path from global settings.json: `{ "talk": { "db_path": "..." } }`. */
110
- function readDbPathFromSettings(): string | undefined {
102
+ /**
103
+ * Read the workspace visibility config from `<cwd>/.pi/talk.json`:
104
+ * `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
105
+ * (everything visible); an explicit `"allowed": []` hides every peer.
106
+ */
107
+ function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
108
+ const configPath = path.join(cwd, ".pi", "talk.json");
111
109
  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;
115
- } catch {
116
- return undefined;
110
+ const raw = fs.readFileSync(configPath, "utf8");
111
+ const parsed = JSON.parse(jsoncToJson(raw)) as { allowed?: unknown };
112
+ if (Array.isArray(parsed.allowed)) {
113
+ return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
114
+ }
115
+ return {};
116
+ } catch (error) {
117
+ // eslint-disable-next-line no-console -- config errors must be visible, not silent
118
+ console.error(`Warning: could not parse ${configPath}: ${String(error)}`);
119
+ return {};
117
120
  }
118
121
  }
119
122
 
120
123
  export default function talk(pi: ExtensionAPI) {
121
- const configured = process.env.PI_TALK_DB ?? readDbPathFromSettings();
124
+ const settings = readTalkSettings();
125
+ const configured = process.env.PI_TALK_DB ?? settings.dbPath;
122
126
  const dbPath = configured
123
127
  ? resolveHomePath(configured, getAgentDir())
124
128
  : path.join(getAgentDir(), "talk.db");
129
+ // "queue": deliver on the session's next natural turn without waking it;
130
+ // "steer": interrupt mid-run / wake an idle session immediately.
131
+ const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
125
132
  const storage = new SqliteTalkStorage(dbPath);
126
133
 
127
134
  let self: SessionRecord | undefined;
@@ -136,12 +143,13 @@ export default function talk(pi: ExtensionAPI) {
136
143
  ...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
137
144
  };
138
145
  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.
146
+ // The core only removes the letter from the inbox after this returns
147
+ // true, so a failure keeps it queued for a later poll.
142
148
  pi.sendMessage(
143
149
  { customType: DELIVERY_TYPE, content: formatDelivery(letter), display: true, details },
144
- { triggerTurn: true, deliverAs: "steer" },
150
+ deliverMode === "steer"
151
+ ? { triggerTurn: true, deliverAs: "steer" }
152
+ : { deliverAs: "nextTurn" },
145
153
  );
146
154
  return true;
147
155
  } catch {
@@ -162,7 +170,6 @@ export default function talk(pi: ExtensionAPI) {
162
170
  );
163
171
  },
164
172
  },
165
- collectResumableSessionIds,
166
173
  });
167
174
 
168
175
  function requireInit(): string | undefined {
@@ -186,6 +193,7 @@ export default function talk(pi: ExtensionAPI) {
186
193
  lastSeenAt: now,
187
194
  status: "idle",
188
195
  };
196
+ core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
189
197
  void core.start(self);
190
198
  });
191
199
 
@@ -205,33 +213,23 @@ export default function talk(pi: ExtensionAPI) {
205
213
  name: "talk-list-sessions",
206
214
  label: "List Talk Sessions",
207
215
  description:
208
- "List other pi sessions on this machine with their presence (idle/working/not responding/offline), address, and working directory.",
216
+ "List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
209
217
  promptSnippet: "List other pi sessions on this machine",
210
218
  parameters: Type.Object({
211
219
  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)" }),
220
+ includeOffline: Type.Optional(
221
+ Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
229
222
  ),
230
223
  }),
231
224
  async execute(_toolCallId, params) {
232
225
  const initError = requireInit();
233
226
  if (initError) return toolResult(initError);
234
- return toolResult(await core.readMessages(params.from));
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
233
  },
236
234
  });
237
235
 
@@ -303,7 +301,7 @@ export default function talk(pi: ExtensionAPI) {
303
301
  name: "talk-reply",
304
302
  label: "Reply Talk",
305
303
  description:
306
- "Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message, or from talk-read-messages).",
304
+ "Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message).",
307
305
  promptSnippet: "Reply to a talk ask",
308
306
  parameters: Type.Object({
309
307
  replyTo: Type.String({ description: "The ask/message id to reply to" }),
@@ -316,7 +314,7 @@ export default function talk(pi: ExtensionAPI) {
316
314
  },
317
315
  });
318
316
 
319
- // ── /talk command ─────────────────────────────────────────────────────
317
+ // ── /talk commands ────────────────────────────────────────────────────
320
318
 
321
319
  pi.registerCommand("talk", {
322
320
  description: "List registered pi sessions",
@@ -326,6 +324,23 @@ export default function talk(pi: ExtensionAPI) {
326
324
  },
327
325
  });
328
326
 
327
+ pi.registerCommand("talk-dead", {
328
+ description:
329
+ "Mark a talk session as dead (removed from listings, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
330
+ async handler(args) {
331
+ const initError = requireInit();
332
+ const trimmed = args.trim();
333
+ const text =
334
+ initError ??
335
+ (trimmed === "--all"
336
+ ? await core.markAllDead()
337
+ : trimmed
338
+ ? await core.markDead(trimmed)
339
+ : await core.markDead());
340
+ pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
341
+ },
342
+ });
343
+
329
344
  // ── Delivery card ──────────────────────────────────────────────────────
330
345
 
331
346
  pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
@@ -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));