@trim21/personal-pi-extensions 0.0.348 → 0.0.351

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.351",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -642,8 +642,10 @@ export async function create(input: CreateInput): Promise<LspClient> {
642
642
 
643
643
  const document = files[resolvedPath];
644
644
  if (document !== undefined) {
645
- // didChange:不清空既有诊断(如 clangd 只在内容变化时重发),
646
- // 让服务器下一次 push/pull 自然覆盖。
645
+ // didChange:内容已变,旧诊断立即失效。清空缓存避免等待窗口内服务器
646
+ // 重算未完成时(大项目可远超窗口)聚合到过期诊断;新 push 到达即填充。
647
+ pushDiagnostics.delete(resolvedPath);
648
+ pullDiagnostics.delete(resolvedPath);
647
649
  await connection.sendNotification("workspace/didChangeWatchedFiles", {
648
650
  changes: [{ uri, type: FILE_CHANGE_CHANGED }],
649
651
  });
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
 
@@ -66,6 +65,12 @@ export interface TalkCoreEvents {
66
65
  deliver(letter: Letter): boolean | Promise<boolean>;
67
66
  /** Surface a notification (e.g. a presence transition) without waking a busy agent. */
68
67
  notify(content: string): void;
68
+ /**
69
+ * The agent's talk identity (agentId) was committed to a group by join or
70
+ * leave. The adapter persists it onto the session branch so a fork/resume
71
+ * of this session keeps the same identity.
72
+ */
73
+ identityChange?(agentId: string): void;
69
74
  }
70
75
 
71
76
  export interface TalkCoreOptions {
@@ -80,6 +85,35 @@ const DELIVERY_BACKOFF_MS = 5000;
80
85
  const INITIAL_DRAIN_DELAY_MS = 1200;
81
86
  const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
82
87
 
88
+ /**
89
+ * custom entry type that pins an agent's talk identity to the session branch.
90
+ * join/leave commit the agentId here; fork/branch/resume copy the entry, and
91
+ * restoreTalkAgentId recovers the identity on the next session_start.
92
+ */
93
+ export const TALK_JOIN_ENTRY_TYPE = "talk:join";
94
+
95
+ /**
96
+ * Recover the talk identity (agentId) pinned to a session branch by its most
97
+ * recent join/leave record, or undefined when the branch never joined a group
98
+ * (a fresh session or one rewound before its join). Mirrors how the file tools
99
+ * rebuild their reads state from the current branch's history.
100
+ */
101
+ export function restoreTalkAgentId(branchEntries: readonly unknown[]): string | undefined {
102
+ let agentId: string | undefined;
103
+ for (const entry of branchEntries) {
104
+ if (typeof entry !== "object" || entry === null) continue;
105
+ const { type, customType, data } = entry as {
106
+ type?: unknown;
107
+ customType?: unknown;
108
+ data?: unknown;
109
+ };
110
+ if (type !== "custom" || customType !== TALK_JOIN_ENTRY_TYPE) continue;
111
+ const recorded = (data as { agentId?: unknown } | undefined)?.agentId;
112
+ if (typeof recorded === "string" && recorded.length > 0) agentId = recorded;
113
+ }
114
+ return agentId;
115
+ }
116
+
83
117
  /**
84
118
  * Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
85
119
  * the two ask letters are fixed values inside the letters, so both sides
@@ -241,44 +275,28 @@ export class TalkCore {
241
275
  }
242
276
 
243
277
  /**
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.
278
+ * Shared per-letter inbound handling: run ask interlock arbitration on
279
+ * incoming asks, and break the wait of an outstanding ask to the peer when
280
+ * the peer sends a plain message (ask = send + wait; any peer message is a
281
+ * response). Returns false when the letter was fully handled here (routed to
282
+ * a waiter) and must not be handed to the model.
248
283
  */
249
284
  private async processIncoming(letter: Letter): Promise<boolean> {
250
285
  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
286
  if (letter.kind === "ask") {
265
287
  // Both sides asking each other: timestamp arbitration before delivering,
266
288
  // so the later asker yields and answers the earlier ask instead of
267
289
  // both timing out.
268
290
  await this.resolveInterlock(letter);
269
- await trackIncomingAsk(this.storage, self.addr, letter);
270
291
  } else if (letter.kind === "message") {
271
292
  // 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.
293
+ // peer is engaging, so do not keep the caller stuck waiting.
273
294
  const myAsk = await this.findOutAskTo(letter.from.addr);
274
295
  if (myAsk) {
275
296
  const waiter = this.askWaiters.get(myAsk.askId);
276
297
  if (waiter) {
277
298
  this.askWaiters.delete(myAsk.askId);
278
- waiter({
279
- replied: false,
280
- reason: "peer sent a message instead of replying to your ask",
281
- });
299
+ waiter({ reason: "peer sent a message in response" });
282
300
  await clearAsk(this.storage, self.addr, myAsk.askId);
283
301
  }
284
302
  }
@@ -343,7 +361,6 @@ export class TalkCore {
343
361
  target: AgentRecord,
344
362
  kind: LetterKind,
345
363
  body: string,
346
- replyTo?: string,
347
364
  ): Promise<SendResult> {
348
365
  const self = this.requireSelf();
349
366
  const presence = presenceOf(target);
@@ -360,7 +377,6 @@ export class TalkCore {
360
377
  body,
361
378
  ts: this.now(),
362
379
  };
363
- if (replyTo !== undefined) letter.replyTo = replyTo;
364
380
  await deposit(this.storage, target.addr, letter);
365
381
  this.policy.recordSend(body, target.addr);
366
382
  if (presence === "live") {
@@ -392,11 +408,10 @@ export class TalkCore {
392
408
  resolve(outcome);
393
409
  };
394
410
  const timer = setTimeout(
395
- () =>
396
- settle({ replied: false, reason: `no reply within ${Math.round(timeoutMs / 1000)}s` }),
411
+ () => settle({ reason: `no response within ${Math.round(timeoutMs / 1000)}s` }),
397
412
  timeoutMs,
398
413
  );
399
- const onAbort = () => settle({ replied: false, reason: "aborted" });
414
+ const onAbort = () => settle({ reason: "aborted" });
400
415
  this.askWaiters.set(askId, settle);
401
416
  if (signal?.aborted) onAbort();
402
417
  else signal?.addEventListener("abort", onAbort, { once: true });
@@ -440,8 +455,7 @@ export class TalkCore {
440
455
  if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
441
456
  this.askWaiters.delete(myAsk.askId);
442
457
  waiter({
443
- replied: false,
444
- reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — answer it with talk-reply before re-asking`,
458
+ reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — respond with a message before re-asking`,
445
459
  });
446
460
  await clearAsk(this.storage, self.addr, myAsk.askId);
447
461
  }
@@ -549,31 +563,36 @@ export class TalkCore {
549
563
  }
550
564
  const nameNote = agentName === undefined ? "" : ` You are visible as "${agentName}".`;
551
565
  const existing = await readGroup(this.storage, name);
566
+ let text: string;
552
567
  if (existing?.members.includes(self.agentId)) {
553
- return `Already in group ${name} (${existing.members.length} member(s)). Members: ${await this.groupMemberNames(existing.members)}.${nameNote}`;
554
- }
555
- await this.leaveCurrentGroup();
556
- if (existing) {
557
- await writeGroup(this.storage, {
558
- ...existing,
559
- members: [...existing.members, self.agentId],
560
- updatedAt: this.now(),
561
- });
562
- return `Joined group ${name} (${
563
- existing.members.length + 1
564
- } member(s)). Members: ${await this.groupMemberNames([
565
- ...existing.members,
566
- self.agentId,
567
- ])}. You now see only co-members.${nameNote}`;
568
+ text = `Already in group ${name} (${existing.members.length} member(s)). Members: ${await this.groupMemberNames(existing.members)}.${nameNote}`;
569
+ } else {
570
+ await this.leaveCurrentGroup();
571
+ if (existing) {
572
+ await writeGroup(this.storage, {
573
+ ...existing,
574
+ members: [...existing.members, self.agentId],
575
+ updatedAt: this.now(),
576
+ });
577
+ text = `Joined group ${name} (${
578
+ existing.members.length + 1
579
+ } member(s)). Members: ${await this.groupMemberNames([
580
+ ...existing.members,
581
+ self.agentId,
582
+ ])}. You now see only co-members.${nameNote}`;
583
+ } else {
584
+ const now = this.now();
585
+ await writeGroup(this.storage, {
586
+ id: name,
587
+ members: [self.agentId],
588
+ createdAt: now,
589
+ updatedAt: now,
590
+ });
591
+ text = `Created group ${name}. Members: ${await this.groupMemberNames([self.agentId])}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
592
+ }
568
593
  }
569
- const now = this.now();
570
- await writeGroup(this.storage, {
571
- id: name,
572
- members: [self.agentId],
573
- createdAt: now,
574
- updatedAt: now,
575
- });
576
- return `Created group ${name}. Members: ${await this.groupMemberNames([self.agentId])}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
594
+ this.events.identityChange?.(self.agentId);
595
+ return text;
577
596
  }
578
597
 
579
598
  /**
@@ -595,9 +614,11 @@ export class TalkCore {
595
614
  const others = group.members.filter((m) => m !== self.agentId);
596
615
  if (others.length === 0) {
597
616
  await deleteGroup(this.storage, group.id);
617
+ this.events.identityChange?.(self.agentId);
598
618
  return `Left group ${group.id} (deleted — it was empty).`;
599
619
  }
600
620
  await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
621
+ this.events.identityChange?.(self.agentId);
601
622
  return `Left group ${group.id} (${others.length} member(s) remain).`;
602
623
  }
603
624
 
@@ -696,7 +717,7 @@ export class TalkCore {
696
717
  const inbox = await listInbox(this.storage, self.addr);
697
718
  const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
698
719
  if (fromTarget.length > 0) {
699
- return `You have ${fromTarget.length} unread message(s) from "${record.name}" — reply before asking.`;
720
+ return `You have ${fromTarget.length} unread message(s) from "${record.name}" — respond to them before asking.`;
700
721
  }
701
722
  const sent = await this.sendLetter(record, "ask", body);
702
723
  if (!sent.ok) return sent.error;
@@ -710,46 +731,13 @@ export class TalkCore {
710
731
  try {
711
732
  const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
712
733
  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}`;
734
+ return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
716
735
  } finally {
717
736
  // The ask tool call is still part of a running agent turn.
718
737
  this.setWorking();
719
738
  }
720
739
  }
721
740
 
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
741
  // ── Presence watch ─────────────────────────────────────────────────────
754
742
 
755
743
  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
@@ -24,7 +24,7 @@ import { type TObject, Type } from "typebox";
24
24
 
25
25
  import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
26
26
  import { resolveHomePath } from "../lib/path.js";
27
- import { TalkCore } from "./core.js";
27
+ import { restoreTalkAgentId, TALK_JOIN_ENTRY_TYPE, TalkCore } from "./core.js";
28
28
  import { formatDelivery } from "./format.js";
29
29
  import type { Letter } from "./mailbox.js";
30
30
  import { type AgentRecord, deriveAddr } from "./registry.js";
@@ -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
@@ -152,6 +150,11 @@ export default function talk(pi: ExtensionAPI) {
152
150
  // in LLM context.
153
151
  pi.appendEntry(NOTIFY_TYPE, content);
154
152
  },
153
+ identityChange(agentId) {
154
+ // Pin the identity to the session branch so a fork/resume of this
155
+ // session keeps the same talk address and group membership.
156
+ pi.appendEntry(TALK_JOIN_ENTRY_TYPE, { agentId, ts: Date.now() });
157
+ },
155
158
  },
156
159
  });
157
160
 
@@ -176,9 +179,12 @@ export default function talk(pi: ExtensionAPI) {
176
179
  // ── Lifecycle ──────────────────────────────────────────────────────────
177
180
 
178
181
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
179
- const agentId = ctx.sessionManager.getSessionId();
180
182
  const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
181
183
  const now = Date.now();
184
+ // A fork/branch/resume of a session that joined a group keeps that talk
185
+ // identity (agentId); a fresh session gets the new session id.
186
+ const agentId =
187
+ restoreTalkAgentId(ctx.sessionManager.getBranch()) ?? ctx.sessionManager.getSessionId();
182
188
  self = {
183
189
  addr: deriveAddr(cwd, agentId),
184
190
  agentId,
@@ -225,8 +231,8 @@ export default function talk(pi: ExtensionAPI) {
225
231
  name: "talk-ask",
226
232
  label: "Ask Talk",
227
233
  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",
234
+ "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.",
235
+ promptSnippet: "Ask another pi agent a question and wait for a response",
230
236
  parameters: Type.Object({
231
237
  to: Type.String({ description: "Target agent (name/address/@alias)" }),
232
238
  message: Type.String({ description: "The question" }),
@@ -265,23 +271,6 @@ export default function talk(pi: ExtensionAPI) {
265
271
  },
266
272
  });
267
273
 
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
274
  // ── /talk commands ────────────────────────────────────────────────────
286
275
 
287
276
  type OkResult<TFlags extends TObject> = Extract<CommandResult<TFlags>, { kind: "ok" }>;
@@ -496,9 +485,6 @@ export default function talk(pi: ExtensionAPI) {
496
485
  const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
497
486
  const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${relativeTime(d.ts)}`);
498
487
  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
488
  // plain 组件:不引入 pi-tui,直接输出带背景色的文本行
503
489
  const text = out.join("\n");
504
490
  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.