@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 +3 -4
- package/package.json +1 -1
- package/src/lib/lsp/client.ts +4 -2
- package/src/talk/core.ts +80 -92
- package/src/talk/format.ts +1 -3
- package/src/talk/index.ts +12 -26
- package/src/talk/mailbox.ts +3 -54
- package/src/talk/skills/multi-agent-dev/SKILL.md +4 -5
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
|
|
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`
|
|
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
|
|
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
package/src/lib/lsp/client.ts
CHANGED
|
@@ -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
|
|
646
|
-
//
|
|
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
|
-
|
|
60
|
-
|
|
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:
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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}" —
|
|
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
|
-
|
|
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> {
|
package/src/talk/format.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
229
|
-
promptSnippet: "Ask another pi agent a question and wait for
|
|
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 {
|
package/src/talk/mailbox.ts
CHANGED
|
@@ -202,28 +202,14 @@ export async function awaitReceipt(
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
// ── Ask tracking ─────────────────────────────────────────────────────────
|
|
205
|
-
//
|
|
206
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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.
|