@trim21/personal-pi-extensions 0.0.348 → 0.0.349

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
@@ -263,9 +263,8 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
263
263
  | 工具 | 作用 |
264
264
  | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
265
265
  | `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);只列出同组成员(未入组时只有自己),`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
266
- | `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
266
+ | `talk-ask` | 向某个 session 提问并阻塞等待回应(默认 30 分钟超时);对方发来任何 `talk-send` 消息都会解除等待 |
267
267
  | `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
268
- | `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
269
268
 
270
269
  对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
271
270
 
@@ -275,10 +274,10 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
275
274
 
276
275
  ### 关键设计
277
276
 
278
- - **presence 不靠心跳**:presence 由 `offline` 标志 + 进程 pid 存活判定;pid 存活时还校验进程启动时间(`/proc/<pid>/stat`),排除 pid 回卷复用造成的误判。未标记 offline 且进程存活即 live,否则 offline;没有心跳,进程挂死(wedged)与健康空闲不可区分。`status` 在 live 时显示 `working` / `waiting-talk-message`(`talk-ask` 阻塞等待回复中)/ `idle`。
277
+ - **presence 不靠心跳**:presence 由 `offline` 标志 + 进程 pid 存活判定;pid 存活时还校验进程启动时间(`/proc/<pid>/stat`),排除 pid 回卷复用造成的误判。未标记 offline 且进程存活即 live,否则 offline;没有心跳,进程挂死(wedged)与健康空闲不可区分。`status` 在 live 时显示 `working` / `waiting-talk-message`(`talk-ask` 阻塞等待回应中)/ `idle`。
279
278
  - **定期清理**:进程仍存活的记录永不回收;进程已死且最后活跃超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
280
279
  - **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
281
- - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
280
+ - **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask,按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
282
281
  - **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
283
282
  - **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递标注来源(来自另一个 pi session,非用户)。
284
283
  - **group 可见性**:可见性完全由 group 决定——组内 session 只能看到同组成员,不在任何 group 的 session 只能看到自己。用 `/talk-group-*` 命令建组/入组,见下方「group 可见性」。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.348",
3
+ "version": "0.0.349",
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
@@ -38,8 +38,6 @@ import {
38
38
  previewBody,
39
39
  readOutgoingAsk,
40
40
  removeLetter,
41
- resolveAskByRef,
42
- trackIncomingAsk,
43
41
  trackOutgoingAsk,
44
42
  unreadCount,
45
43
  } from "./mailbox.js";
@@ -56,8 +54,9 @@ import {
56
54
  } from "./registry.js";
57
55
  import type { TalkStorage } from "./storage.js";
58
56
 
59
- type AskOutcome =
60
- { replied: true; body: string; from: string } | { replied: false; reason: string };
57
+ interface AskOutcome {
58
+ reason: string;
59
+ }
61
60
  type TargetResult = { ok: true; record: AgentRecord } | { ok: false; error: string };
62
61
  type SendResult = { ok: true; letter: Letter; verdict: string } | { ok: false; error: string };
63
62
 
@@ -241,44 +240,28 @@ export class TalkCore {
241
240
  }
242
241
 
243
242
  /**
244
- * Shared per-letter inbound handling: route replies/cancels to their
245
- * waiters, and run ask interlock arbitration + incoming-ask tracking.
246
- * Returns false when the letter was fully handled here (routed to a waiter)
247
- * and must not be handed to the model.
243
+ * Shared per-letter inbound handling: run ask interlock arbitration on
244
+ * incoming asks, and break the wait of an outstanding ask to the peer when
245
+ * the peer sends a plain message (ask = send + wait; any peer message is a
246
+ * response). Returns false when the letter was fully handled here (routed to
247
+ * a waiter) and must not be handed to the model.
248
248
  */
249
249
  private async processIncoming(letter: Letter): Promise<boolean> {
250
250
  const self = this.requireSelf();
251
- if ((letter.kind === "reply" || letter.kind === "cancel") && letter.replyTo) {
252
- const waiter = this.askWaiters.get(letter.replyTo);
253
- await clearAsk(this.storage, self.addr, letter.replyTo);
254
- if (waiter) {
255
- this.askWaiters.delete(letter.replyTo);
256
- waiter(
257
- letter.kind === "reply"
258
- ? { replied: true, body: letter.body, from: letter.from.name }
259
- : { replied: false, reason: `cancelled by ${letter.from.name}` },
260
- );
261
- return false;
262
- }
263
- }
264
251
  if (letter.kind === "ask") {
265
252
  // Both sides asking each other: timestamp arbitration before delivering,
266
253
  // so the later asker yields and answers the earlier ask instead of
267
254
  // both timing out.
268
255
  await this.resolveInterlock(letter);
269
- await trackIncomingAsk(this.storage, self.addr, letter);
270
256
  } else if (letter.kind === "message") {
271
257
  // A plain message from a peer we are blocked asking breaks the wait: the
272
- // peer is engaging, so do not keep the caller stuck waiting for a reply.
258
+ // peer is engaging, so do not keep the caller stuck waiting.
273
259
  const myAsk = await this.findOutAskTo(letter.from.addr);
274
260
  if (myAsk) {
275
261
  const waiter = this.askWaiters.get(myAsk.askId);
276
262
  if (waiter) {
277
263
  this.askWaiters.delete(myAsk.askId);
278
- waiter({
279
- replied: false,
280
- reason: "peer sent a message instead of replying to your ask",
281
- });
264
+ waiter({ reason: "peer sent a message in response" });
282
265
  await clearAsk(this.storage, self.addr, myAsk.askId);
283
266
  }
284
267
  }
@@ -343,7 +326,6 @@ export class TalkCore {
343
326
  target: AgentRecord,
344
327
  kind: LetterKind,
345
328
  body: string,
346
- replyTo?: string,
347
329
  ): Promise<SendResult> {
348
330
  const self = this.requireSelf();
349
331
  const presence = presenceOf(target);
@@ -360,7 +342,6 @@ export class TalkCore {
360
342
  body,
361
343
  ts: this.now(),
362
344
  };
363
- if (replyTo !== undefined) letter.replyTo = replyTo;
364
345
  await deposit(this.storage, target.addr, letter);
365
346
  this.policy.recordSend(body, target.addr);
366
347
  if (presence === "live") {
@@ -392,11 +373,10 @@ export class TalkCore {
392
373
  resolve(outcome);
393
374
  };
394
375
  const timer = setTimeout(
395
- () =>
396
- settle({ replied: false, reason: `no reply within ${Math.round(timeoutMs / 1000)}s` }),
376
+ () => settle({ reason: `no response within ${Math.round(timeoutMs / 1000)}s` }),
397
377
  timeoutMs,
398
378
  );
399
- const onAbort = () => settle({ replied: false, reason: "aborted" });
379
+ const onAbort = () => settle({ reason: "aborted" });
400
380
  this.askWaiters.set(askId, settle);
401
381
  if (signal?.aborted) onAbort();
402
382
  else signal?.addEventListener("abort", onAbort, { once: true });
@@ -440,8 +420,7 @@ export class TalkCore {
440
420
  if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
441
421
  this.askWaiters.delete(myAsk.askId);
442
422
  waiter({
443
- replied: false,
444
- reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — answer it with talk-reply before re-asking`,
423
+ reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — respond with a message before re-asking`,
445
424
  });
446
425
  await clearAsk(this.storage, self.addr, myAsk.askId);
447
426
  }
@@ -696,7 +675,7 @@ export class TalkCore {
696
675
  const inbox = await listInbox(this.storage, self.addr);
697
676
  const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
698
677
  if (fromTarget.length > 0) {
699
- return `You have ${fromTarget.length} unread message(s) from "${record.name}" — reply before asking.`;
678
+ return `You have ${fromTarget.length} unread message(s) from "${record.name}" — respond to them before asking.`;
700
679
  }
701
680
  const sent = await this.sendLetter(record, "ask", body);
702
681
  if (!sent.ok) return sent.error;
@@ -710,46 +689,13 @@ export class TalkCore {
710
689
  try {
711
690
  const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
712
691
  await clearAsk(this.storage, self.addr, sent.letter.id);
713
- if (!outcome.replied)
714
- return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
715
- return `"${record.name}" replied:\n\n${outcome.body}`;
692
+ return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
716
693
  } finally {
717
694
  // The ask tool call is still part of a running agent turn.
718
695
  this.setWorking();
719
696
  }
720
697
  }
721
698
 
722
- async reply(replyTo: string, body: string): Promise<string> {
723
- if (!body) return "reply requires 'message'.";
724
- if (!replyTo) {
725
- return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
726
- }
727
- const self = this.requireSelf();
728
- const ask = await resolveAskByRef(this.storage, self.addr, replyTo);
729
- if (!ask) return `No pending ask matches '${replyTo}'.`;
730
- const records = await listRecords(this.storage);
731
- const asker = records.find((r) => r.addr === ask.from.addr);
732
- const target = asker ?? this.recordFromLetter(ask);
733
- const sent = await this.sendLetter(target, "reply", body, ask.id);
734
- if (!sent.ok) return sent.error;
735
- await clearAsk(this.storage, self.addr, ask.id);
736
- return `Replied to "${target.name}" (ask ${ask.id.slice(-8)}): ${sent.verdict}.`;
737
- }
738
-
739
- /** Build a minimal record from a letter's sender when the peer record is gone. */
740
- private recordFromLetter(letter: Letter): AgentRecord {
741
- return {
742
- addr: letter.from.addr,
743
- agentId: letter.from.agentId,
744
- name: letter.from.name,
745
- cwd: letter.from.cwd,
746
- pid: 0,
747
- startedAt: letter.ts,
748
- lastSeenAt: letter.ts,
749
- status: "idle",
750
- };
751
- }
752
-
753
699
  // ── Presence watch ─────────────────────────────────────────────────────
754
700
 
755
701
  async watch(to: string): Promise<string> {
@@ -25,9 +25,7 @@ export function formatDelivery(letter: Letter, now: number = Date.now()): string
25
25
  const from = letter.from;
26
26
  const header = `From pi agent ${from.agentId} (${from.cwd}) — "${from.name}"`;
27
27
  const meta = `_id ${letter.id} · ${letter.kind} · sent ${age(letter.ts, now)}_`;
28
- const hint =
29
- letter.kind === "ask" ? `\n\nReply with the talk-reply tool, replyTo: "${letter.id}"` : "";
30
- return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
28
+ return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}`;
31
29
  }
32
30
 
33
31
  /** One agent as the model sees it in a listing. `id` is the stable pi
package/src/talk/index.ts CHANGED
@@ -83,7 +83,6 @@ interface DeliveryDetails {
83
83
  from: Letter["from"];
84
84
  ts: number;
85
85
  body: string;
86
- replyTo?: string;
87
86
  }
88
87
 
89
88
  /**
@@ -126,7 +125,6 @@ export default function talk(pi: ExtensionAPI) {
126
125
  from: letter.from,
127
126
  ts: letter.ts,
128
127
  body: letter.body,
129
- ...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
130
128
  };
131
129
  try {
132
130
  // The core only removes the letter from the inbox after this returns
@@ -225,8 +223,8 @@ export default function talk(pi: ExtensionAPI) {
225
223
  name: "talk-ask",
226
224
  label: "Ask Talk",
227
225
  description:
228
- "Ask another pi agent a question and block until it replies (or times out). Before asking, it checks whether that agent already sent you something; if so, you are told to read and reply first instead of asking.",
229
- promptSnippet: "Ask another pi agent a question and wait for the reply",
226
+ "Ask another pi agent a question and block until it responds (or times out). Before asking, it checks whether that agent already sent you something; if so, you are told to read and respond first instead of asking.",
227
+ promptSnippet: "Ask another pi agent a question and wait for a response",
230
228
  parameters: Type.Object({
231
229
  to: Type.String({ description: "Target agent (name/address/@alias)" }),
232
230
  message: Type.String({ description: "The question" }),
@@ -265,23 +263,6 @@ export default function talk(pi: ExtensionAPI) {
265
263
  },
266
264
  });
267
265
 
268
- pi.registerTool({
269
- name: "talk-reply",
270
- label: "Reply Talk",
271
- description:
272
- "Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message).",
273
- promptSnippet: "Reply to a talk ask",
274
- parameters: Type.Object({
275
- replyTo: Type.String({ description: "The ask/message id to reply to" }),
276
- message: Type.String({ description: "The reply body" }),
277
- }),
278
- async execute(_toolCallId, params) {
279
- const initError = requireInit();
280
- if (initError) return toolResult(initError);
281
- return toolResult(await core.reply(params.replyTo, params.message));
282
- },
283
- });
284
-
285
266
  // ── /talk commands ────────────────────────────────────────────────────
286
267
 
287
268
  type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
@@ -496,9 +477,6 @@ export default function talk(pi: ExtensionAPI) {
496
477
  const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
497
478
  const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${relativeTime(d.ts)}`);
498
479
  const out = [header, sanitizeTerminal(d.body), "", footer];
499
- if (d.kind === "ask") {
500
- out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${idTail}"`));
501
- }
502
480
  // plain 组件:不引入 pi-tui,直接输出带背景色的文本行
503
481
  const text = out.join("\n");
504
482
  return {
@@ -202,28 +202,14 @@ export async function awaitReceipt(
202
202
  }
203
203
 
204
204
  // ── Ask tracking ─────────────────────────────────────────────────────────
205
- // Received asks live at asks/<addr>/<id>.json until we reply; our outgoing
206
- // asks live at asks/<addr>/out-<id>.json until a reply/cancel arrives or we
207
- // time out.
208
-
209
- function askKey(askId: string): string {
210
- assertMessageId(askId);
211
- return `${askId}.json`;
212
- }
205
+ // Our outgoing asks live at asks/<addr>/out-<id>.json until a message from
206
+ // the peer breaks the wait, the peer answers first, or we time out.
213
207
 
214
208
  function outAskKey(askId: string): string {
215
209
  assertMessageId(askId);
216
210
  return `out-${askId}.json`;
217
211
  }
218
212
 
219
- export async function trackIncomingAsk(
220
- storage: TalkStorage,
221
- addr: string,
222
- letter: Letter,
223
- ): Promise<void> {
224
- await storage.writeJson(asksNs(addr), askKey(letter.id), letter);
225
- }
226
-
227
213
  export async function trackOutgoingAsk(
228
214
  storage: TalkStorage,
229
215
  addr: string,
@@ -232,15 +218,6 @@ export async function trackOutgoingAsk(
232
218
  await storage.writeJson(asksNs(addr), outAskKey(out.askId), out);
233
219
  }
234
220
 
235
- export async function readIncomingAsk(
236
- storage: TalkStorage,
237
- addr: string,
238
- askId: string,
239
- ): Promise<Letter | null> {
240
- const raw = await storage.readJson(asksNs(addr), askKey(askId));
241
- return normalizeLetter(raw);
242
- }
243
-
244
221
  export async function readOutgoingAsk(
245
222
  storage: TalkStorage,
246
223
  addr: string,
@@ -250,24 +227,11 @@ export async function readOutgoingAsk(
250
227
  return Value.Check(OutAskSchema, raw) ? raw : null;
251
228
  }
252
229
 
253
- /** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
230
+ /** Remove our outstanding ask. Idempotent. */
254
231
  export async function clearAsk(storage: TalkStorage, addr: string, askId: string): Promise<void> {
255
- await storage.removeKey(asksNs(addr), askKey(askId));
256
232
  await storage.removeKey(asksNs(addr), outAskKey(askId));
257
233
  }
258
234
 
259
- /** Asks we have received and not yet answered, oldest first. */
260
- export async function pendingAsks(storage: TalkStorage, addr: string): Promise<Letter[]> {
261
- const out: Letter[] = [];
262
- for (const key of await storage.listKeys(asksNs(addr))) {
263
- if (key.startsWith("out-")) continue;
264
- const raw = await storage.readJson(asksNs(addr), key);
265
- const letter = normalizeLetter(raw);
266
- if (letter) out.push(letter);
267
- }
268
- return out;
269
- }
270
-
271
235
  /** Outgoing ask ids, used by the core for interlock arbitration. */
272
236
  export async function outgoingAskIds(storage: TalkStorage, addr: string): Promise<string[]> {
273
237
  const out: string[] = [];
@@ -278,21 +242,6 @@ export async function outgoingAskIds(storage: TalkStorage, addr: string): Promis
278
242
  return out;
279
243
  }
280
244
 
281
- /**
282
- * Resolve a pending ask by explicit replyTo id or unique suffix. No inference.
283
- * Matching is on the suffix: the prefix of a time-ordered (v7-style) id is the
284
- * timestamp part and collides easily, the suffix is the random part.
285
- */
286
- export async function resolveAskByRef(
287
- storage: TalkStorage,
288
- addr: string,
289
- replyTo: string,
290
- ): Promise<Letter | null> {
291
- if (!replyTo) return null; // empty suffix matches every id — an explicit ref is required
292
- const asks = await pendingAsks(storage, addr);
293
- return asks.find((a) => a.id === replyTo || a.id.endsWith(replyTo)) ?? null;
294
- }
295
-
296
245
  // ── Audit log ────────────────────────────────────────────────────────────
297
246
 
298
247
  const AUDIT_LOG = "audit";
@@ -34,7 +34,7 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
34
34
 
35
35
  ### Status
36
36
 
37
- - `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a reply)
37
+ - `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a response)
38
38
  - `offline` (process exited or marked dead)
39
39
  - `talk-list-agents` lists every visible agent — live or offline — with its current status.
40
40
 
@@ -51,8 +51,7 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
51
51
  | ------------------ | -------------------------------------------------------------------------------------------------------------------- |
52
52
  | `talk-list-agents` | List visible agents (`id` / `status` / `work_dir` / `name`); only group co-members (or only yourself when ungrouped) |
53
53
  | `talk-send` | Send a plain message to a single agent id (async — the main collaboration primitive) |
54
- | `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
55
- | `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
54
+ | `talk-ask` | Ask a question and block for a response (default 30 min timeout); any message from the peer breaks the wait |
56
55
 
57
56
  Pairing into groups is a user action (`/talk-group-*` in the TUI); you only observe its effect through `talk-list-agents`.
58
57
 
@@ -68,7 +67,7 @@ Pairing into groups is a user action (`/talk-group-*` in the TUI); you only obse
68
67
  ### Synchronous question/answer (need the answer to continue)
69
68
 
70
69
  - Use `talk-ask` when the next step depends on the peer's information and the peer is reachable.
71
- - On receiving an ask, reply with `talk-reply` using the `replyTo` id from the delivered message.
70
+ - On receiving an ask, respond with `talk-send` any message you send breaks the peer's wait, so a plain `talk-send` back is the reply.
72
71
  - If two agents ask each other simultaneously: the later asker yields — answer the peer's ask first, then re-ask.
73
72
 
74
73
  ### Async notifications
@@ -93,5 +92,5 @@ Pairing into groups is a user action (`/talk-group-*` in the TUI); you only obse
93
92
 
94
93
  - **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.
95
94
  - **Address from known ids**: only run `talk-list-agents` to discover agents 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 agent id`.
96
- - **Respect status**: asking an offline agent blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
95
+ - **Respect status**: asking an offline agent blocks until the 30 min timeout (nothing breaks the wait). Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
97
96
  - **Visibility boundary**: you can only collaborate with agents that share your group; ungrouped agents and other groups' members are unreachable by design. Ask the user to pair agents before collaborating.