@trim21/personal-pi-extensions 0.0.347 → 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 +3 -4
- package/package.json +1 -1
- package/src/talk/core.ts +25 -64
- package/src/talk/format.ts +1 -3
- package/src/talk/index.ts +2 -24
- 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/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
|
|
|
@@ -241,32 +240,31 @@ export class TalkCore {
|
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
/**
|
|
244
|
-
* Shared per-letter inbound handling:
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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
|
-
|
|
256
|
+
} else if (letter.kind === "message") {
|
|
257
|
+
// A plain message from a peer we are blocked asking breaks the wait: the
|
|
258
|
+
// peer is engaging, so do not keep the caller stuck waiting.
|
|
259
|
+
const myAsk = await this.findOutAskTo(letter.from.addr);
|
|
260
|
+
if (myAsk) {
|
|
261
|
+
const waiter = this.askWaiters.get(myAsk.askId);
|
|
262
|
+
if (waiter) {
|
|
263
|
+
this.askWaiters.delete(myAsk.askId);
|
|
264
|
+
waiter({ reason: "peer sent a message in response" });
|
|
265
|
+
await clearAsk(this.storage, self.addr, myAsk.askId);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
270
268
|
}
|
|
271
269
|
return true;
|
|
272
270
|
}
|
|
@@ -328,7 +326,6 @@ export class TalkCore {
|
|
|
328
326
|
target: AgentRecord,
|
|
329
327
|
kind: LetterKind,
|
|
330
328
|
body: string,
|
|
331
|
-
replyTo?: string,
|
|
332
329
|
): Promise<SendResult> {
|
|
333
330
|
const self = this.requireSelf();
|
|
334
331
|
const presence = presenceOf(target);
|
|
@@ -345,7 +342,6 @@ export class TalkCore {
|
|
|
345
342
|
body,
|
|
346
343
|
ts: this.now(),
|
|
347
344
|
};
|
|
348
|
-
if (replyTo !== undefined) letter.replyTo = replyTo;
|
|
349
345
|
await deposit(this.storage, target.addr, letter);
|
|
350
346
|
this.policy.recordSend(body, target.addr);
|
|
351
347
|
if (presence === "live") {
|
|
@@ -377,11 +373,10 @@ export class TalkCore {
|
|
|
377
373
|
resolve(outcome);
|
|
378
374
|
};
|
|
379
375
|
const timer = setTimeout(
|
|
380
|
-
() =>
|
|
381
|
-
settle({ replied: false, reason: `no reply within ${Math.round(timeoutMs / 1000)}s` }),
|
|
376
|
+
() => settle({ reason: `no response within ${Math.round(timeoutMs / 1000)}s` }),
|
|
382
377
|
timeoutMs,
|
|
383
378
|
);
|
|
384
|
-
const onAbort = () => settle({
|
|
379
|
+
const onAbort = () => settle({ reason: "aborted" });
|
|
385
380
|
this.askWaiters.set(askId, settle);
|
|
386
381
|
if (signal?.aborted) onAbort();
|
|
387
382
|
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -425,8 +420,7 @@ export class TalkCore {
|
|
|
425
420
|
if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
|
|
426
421
|
this.askWaiters.delete(myAsk.askId);
|
|
427
422
|
waiter({
|
|
428
|
-
|
|
429
|
-
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`,
|
|
430
424
|
});
|
|
431
425
|
await clearAsk(this.storage, self.addr, myAsk.askId);
|
|
432
426
|
}
|
|
@@ -681,7 +675,7 @@ export class TalkCore {
|
|
|
681
675
|
const inbox = await listInbox(this.storage, self.addr);
|
|
682
676
|
const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
|
|
683
677
|
if (fromTarget.length > 0) {
|
|
684
|
-
return `You have ${fromTarget.length} unread message(s) from "${record.name}" —
|
|
678
|
+
return `You have ${fromTarget.length} unread message(s) from "${record.name}" — respond to them before asking.`;
|
|
685
679
|
}
|
|
686
680
|
const sent = await this.sendLetter(record, "ask", body);
|
|
687
681
|
if (!sent.ok) return sent.error;
|
|
@@ -695,46 +689,13 @@ export class TalkCore {
|
|
|
695
689
|
try {
|
|
696
690
|
const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
|
|
697
691
|
await clearAsk(this.storage, self.addr, sent.letter.id);
|
|
698
|
-
|
|
699
|
-
return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
|
|
700
|
-
return `"${record.name}" replied:\n\n${outcome.body}`;
|
|
692
|
+
return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
|
|
701
693
|
} finally {
|
|
702
694
|
// The ask tool call is still part of a running agent turn.
|
|
703
695
|
this.setWorking();
|
|
704
696
|
}
|
|
705
697
|
}
|
|
706
698
|
|
|
707
|
-
async reply(replyTo: string, body: string): Promise<string> {
|
|
708
|
-
if (!body) return "reply requires 'message'.";
|
|
709
|
-
if (!replyTo) {
|
|
710
|
-
return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
|
|
711
|
-
}
|
|
712
|
-
const self = this.requireSelf();
|
|
713
|
-
const ask = await resolveAskByRef(this.storage, self.addr, replyTo);
|
|
714
|
-
if (!ask) return `No pending ask matches '${replyTo}'.`;
|
|
715
|
-
const records = await listRecords(this.storage);
|
|
716
|
-
const asker = records.find((r) => r.addr === ask.from.addr);
|
|
717
|
-
const target = asker ?? this.recordFromLetter(ask);
|
|
718
|
-
const sent = await this.sendLetter(target, "reply", body, ask.id);
|
|
719
|
-
if (!sent.ok) return sent.error;
|
|
720
|
-
await clearAsk(this.storage, self.addr, ask.id);
|
|
721
|
-
return `Replied to "${target.name}" (ask ${ask.id.slice(-8)}): ${sent.verdict}.`;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** Build a minimal record from a letter's sender when the peer record is gone. */
|
|
725
|
-
private recordFromLetter(letter: Letter): AgentRecord {
|
|
726
|
-
return {
|
|
727
|
-
addr: letter.from.addr,
|
|
728
|
-
agentId: letter.from.agentId,
|
|
729
|
-
name: letter.from.name,
|
|
730
|
-
cwd: letter.from.cwd,
|
|
731
|
-
pid: 0,
|
|
732
|
-
startedAt: letter.ts,
|
|
733
|
-
lastSeenAt: letter.ts,
|
|
734
|
-
status: "idle",
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
|
|
738
699
|
// ── Presence watch ─────────────────────────────────────────────────────
|
|
739
700
|
|
|
740
701
|
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
|
@@ -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
|
|
229
|
-
promptSnippet: "Ask another pi agent a question and wait for
|
|
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 {
|
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.
|