@trim21/personal-pi-extensions 0.0.203 → 0.0.204
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 +9 -9
- package/package.json +1 -1
- package/src/talk/core.ts +30 -61
- package/src/talk/format.ts +1 -1
- package/src/talk/index.ts +8 -19
- package/src/talk/mailbox.ts +8 -4
- package/src/talk/policy.ts +1 -2
- package/src/talk/registry.ts +48 -14
- package/src/talk/skills/multi-agent-dev/SKILL.md +10 -10
package/README.md
CHANGED
|
@@ -230,23 +230,23 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
230
230
|
|
|
231
231
|
### 工具(LLM 可见)
|
|
232
232
|
|
|
233
|
-
| 工具 | 作用
|
|
234
|
-
| -------------------- |
|
|
235
|
-
| `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true
|
|
236
|
-
| `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时)
|
|
237
|
-
| `talk-send` |
|
|
238
|
-
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断)
|
|
233
|
+
| 工具 | 作用 |
|
|
234
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `talk-list-sessions` | 列出会话,返回 JSON 数组(`status` / `work_dir` / `id` / `name`,自己带 `self: true`);始终列出所有可见会话,`status` 区分 live(`idle` / `working` / `waiting-talk-message`)与 `offline` |
|
|
236
|
+
| `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
|
|
237
|
+
| `talk-send` | 发送纯文本消息到单个 session(`to` 只接受明确的 session id,不支持广播) |
|
|
238
|
+
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
|
|
239
239
|
|
|
240
240
|
对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
|
|
241
241
|
|
|
242
242
|
**定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
|
|
243
243
|
|
|
244
|
-
**标记废弃 session**:`/talk-dead`
|
|
244
|
+
**标记废弃 session**:`/talk-dead` 给 session 打 `offline` 标志并把 `lastSeenAt` 置 0(列表显示为 offline,下次 sweep 无 mail 即回收):无参标记当前 session,`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
|
|
245
245
|
|
|
246
246
|
### 关键设计
|
|
247
247
|
|
|
248
|
-
-
|
|
249
|
-
-
|
|
248
|
+
- **presence 不靠心跳**:presence 由 `offline` 标志 + 进程 pid 存活判定;pid 存活时还校验进程启动时间(`/proc/<pid>/stat`),排除 pid 回卷复用造成的误判。未标记 offline 且进程存活即 live,否则 offline;没有心跳,进程挂死(wedged)与健康空闲不可区分。`status` 在 live 时显示 `working` / `waiting-talk-message`(`talk-ask` 阻塞等待回复中)/ `idle`。
|
|
249
|
+
- **定期清理**:进程仍存活的记录永不回收;进程已死且最后活跃超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
|
|
250
250
|
- **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
|
|
251
251
|
- **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
|
|
252
252
|
- **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
|
package/package.json
CHANGED
package/src/talk/core.ts
CHANGED
|
@@ -34,11 +34,11 @@ import {
|
|
|
34
34
|
} from "./mailbox.js";
|
|
35
35
|
import { inboundAccepts, OutboundPolicy } from "./policy.js";
|
|
36
36
|
import {
|
|
37
|
-
LIST_ACTIVE_MS,
|
|
38
37
|
listRecords,
|
|
39
38
|
type Presence,
|
|
40
39
|
presenceOf,
|
|
41
40
|
readRecord,
|
|
41
|
+
readStartTime,
|
|
42
42
|
type SessionRecord,
|
|
43
43
|
sweep,
|
|
44
44
|
writeRecord,
|
|
@@ -64,7 +64,6 @@ export interface TalkCoreOptions {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const INBOX_POLL_MS = 3000;
|
|
67
|
-
const HEARTBEAT_MS = 15_000;
|
|
68
67
|
const WATCH_POLL_MS = 5000;
|
|
69
68
|
const DELIVERY_BACKOFF_MS = 5000;
|
|
70
69
|
const INITIAL_DRAIN_DELAY_MS = 1200;
|
|
@@ -124,11 +123,10 @@ export class TalkCore {
|
|
|
124
123
|
private readonly deliveredIds = new Set<string>();
|
|
125
124
|
/** Visibility gate over peer working directories; defaults to everything visible. */
|
|
126
125
|
private isPeerVisible: (peerCwd: string) => boolean = () => true;
|
|
127
|
-
/** Manually marked dead:
|
|
126
|
+
/** Manually marked dead: offline flag set, lastSeenAt pinned to 0. */
|
|
128
127
|
private dead = false;
|
|
129
128
|
|
|
130
129
|
private inboxPoll: ReturnType<typeof setInterval> | undefined;
|
|
131
|
-
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
132
130
|
private watchPoller: ReturnType<typeof setInterval> | undefined;
|
|
133
131
|
private sweeper: ReturnType<typeof setInterval> | undefined;
|
|
134
132
|
private lastDeliveryFailureAt = 0;
|
|
@@ -158,18 +156,16 @@ export class TalkCore {
|
|
|
158
156
|
|
|
159
157
|
async start(self: SessionRecord): Promise<void> {
|
|
160
158
|
await this.storage.init();
|
|
161
|
-
|
|
162
|
-
|
|
159
|
+
// Record the process start time so presence can rule out pid reuse later.
|
|
160
|
+
const pidStart = readStartTime(self.pid);
|
|
161
|
+
this.self = pidStart === undefined ? self : { ...self, pidStart };
|
|
162
|
+
await writeRecord(this.storage, this.self);
|
|
163
163
|
try {
|
|
164
164
|
await sweep(this.storage, this.now());
|
|
165
165
|
} catch {
|
|
166
166
|
// sweep failure never breaks the session
|
|
167
167
|
}
|
|
168
168
|
this.startInboxPoll();
|
|
169
|
-
this.heartbeat = setInterval(() => {
|
|
170
|
-
void this.writeSelf({});
|
|
171
|
-
}, HEARTBEAT_MS);
|
|
172
|
-
this.heartbeat.unref();
|
|
173
169
|
// Reclaim dead records periodically, not just at startup.
|
|
174
170
|
this.sweeper = setInterval(() => {
|
|
175
171
|
void sweep(this.storage, this.now());
|
|
@@ -184,7 +180,6 @@ export class TalkCore {
|
|
|
184
180
|
}
|
|
185
181
|
|
|
186
182
|
async stop(): Promise<void> {
|
|
187
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
188
183
|
if (this.watchPoller) clearInterval(this.watchPoller);
|
|
189
184
|
if (this.inboxPoll) clearInterval(this.inboxPoll);
|
|
190
185
|
if (this.sweeper) clearInterval(this.sweeper);
|
|
@@ -221,7 +216,7 @@ export class TalkCore {
|
|
|
221
216
|
try {
|
|
222
217
|
await writeRecord(this.storage, this.self);
|
|
223
218
|
} catch {
|
|
224
|
-
//
|
|
219
|
+
// registration failures never break the session
|
|
225
220
|
}
|
|
226
221
|
}
|
|
227
222
|
|
|
@@ -375,7 +370,7 @@ export class TalkCore {
|
|
|
375
370
|
return {
|
|
376
371
|
ok: true,
|
|
377
372
|
letter,
|
|
378
|
-
verdict: `queued (target
|
|
373
|
+
verdict: `queued (target is offline — waits on disk)`,
|
|
379
374
|
};
|
|
380
375
|
}
|
|
381
376
|
|
|
@@ -441,7 +436,7 @@ export class TalkCore {
|
|
|
441
436
|
this.askWaiters.delete(myAsk.askId);
|
|
442
437
|
waiter({
|
|
443
438
|
replied: false,
|
|
444
|
-
reason: `peer asked first (their ask id ${letter.id.slice(
|
|
439
|
+
reason: `peer asked first (their ask id ${letter.id.slice(-8)}) — answer it with talk-reply before re-asking`,
|
|
445
440
|
});
|
|
446
441
|
await clearAsk(this.storage, self.addr, myAsk.askId);
|
|
447
442
|
}
|
|
@@ -450,31 +445,29 @@ export class TalkCore {
|
|
|
450
445
|
|
|
451
446
|
/**
|
|
452
447
|
* JSON listing of visible sessions, including self (marked `self: true`).
|
|
453
|
-
*
|
|
454
|
-
*
|
|
448
|
+
* Every visible record is listed, live or offline; presence decides the
|
|
449
|
+
* per-session status.
|
|
455
450
|
*/
|
|
456
|
-
async list(
|
|
451
|
+
async list(): Promise<string> {
|
|
457
452
|
const self = this.requireSelf();
|
|
458
|
-
const now = this.now();
|
|
459
453
|
const all = await listRecords(this.storage);
|
|
460
454
|
const records = all.filter((r) => {
|
|
461
455
|
if (r.addr === self.addr) return !this.dead;
|
|
462
|
-
return this.isPeerVisible(r.cwd)
|
|
456
|
+
return this.isPeerVisible(r.cwd);
|
|
463
457
|
});
|
|
464
|
-
return formatListing(records, self.addr,
|
|
458
|
+
return formatListing(records, self.addr, presenceOf);
|
|
465
459
|
}
|
|
466
460
|
|
|
467
461
|
/** Same as list(), filtered to one working directory. */
|
|
468
|
-
async listCwd(cwd: string
|
|
462
|
+
async listCwd(cwd: string): Promise<string> {
|
|
469
463
|
const self = this.requireSelf();
|
|
470
|
-
const now = this.now();
|
|
471
464
|
const records = await listRecords(this.storage);
|
|
472
465
|
const filtered = records.filter((r) => {
|
|
473
466
|
if (r.cwd !== cwd) return false;
|
|
474
467
|
if (r.addr === self.addr) return !this.dead;
|
|
475
|
-
return this.isPeerVisible(r.cwd)
|
|
468
|
+
return this.isPeerVisible(r.cwd);
|
|
476
469
|
});
|
|
477
|
-
return formatListing(filtered, self.addr,
|
|
470
|
+
return formatListing(filtered, self.addr, presenceOf);
|
|
478
471
|
}
|
|
479
472
|
|
|
480
473
|
/** Visible peer records (excluding self), e.g. for command completions. */
|
|
@@ -485,24 +478,20 @@ export class TalkCore {
|
|
|
485
478
|
}
|
|
486
479
|
|
|
487
480
|
/**
|
|
488
|
-
* Mark a session as dead
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
481
|
+
* Mark a session as dead: set its offline flag and pin lastSeenAt to 0 so
|
|
482
|
+
* the next sweep reaps it (empty mailbox). Without a target, marks this
|
|
483
|
+
* session — later writeSelf calls no longer refresh lastSeenAt, and the
|
|
484
|
+
* record stays offline for peers.
|
|
492
485
|
*/
|
|
493
486
|
async markDead(target?: string): Promise<string> {
|
|
494
487
|
if (!target) {
|
|
495
488
|
this.dead = true;
|
|
496
|
-
|
|
497
|
-
clearInterval(this.heartbeat);
|
|
498
|
-
this.heartbeat = undefined;
|
|
499
|
-
}
|
|
500
|
-
await this.writeSelf({});
|
|
489
|
+
await this.writeSelf({ offline: true });
|
|
501
490
|
return "Marked this session as dead.";
|
|
502
491
|
}
|
|
503
492
|
const resolved = await this.resolveTarget(target);
|
|
504
493
|
if (!resolved.ok) return resolved.error;
|
|
505
|
-
await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
|
|
494
|
+
await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0, offline: true });
|
|
506
495
|
return `Marked "${resolved.record.name}" as dead.`;
|
|
507
496
|
}
|
|
508
497
|
|
|
@@ -510,7 +499,7 @@ export class TalkCore {
|
|
|
510
499
|
async markAllDead(): Promise<string> {
|
|
511
500
|
const peers = await this.listPeers();
|
|
512
501
|
for (const peer of peers) {
|
|
513
|
-
await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
|
|
502
|
+
await writeRecord(this.storage, { ...peer, lastSeenAt: 0, offline: true });
|
|
514
503
|
}
|
|
515
504
|
return `Marked ${peers.length} session(s) as dead.`;
|
|
516
505
|
}
|
|
@@ -518,40 +507,21 @@ export class TalkCore {
|
|
|
518
507
|
async send(to: string, body: string): Promise<string> {
|
|
519
508
|
if (!to) return 'send requires "to".';
|
|
520
509
|
if (!body) return 'send requires "message".';
|
|
521
|
-
const self = this.requireSelf();
|
|
522
|
-
// Broadcast: N atomic deposits through the existing deposit path so
|
|
523
|
-
// rate/dedupe caps still bind (per-peer dedupe; rate caps total fan-out).
|
|
524
510
|
if (to === "*" || to === "cwd") {
|
|
525
|
-
|
|
526
|
-
const peers = records.filter((r) => {
|
|
527
|
-
if (r.addr === self.addr) return false;
|
|
528
|
-
if (!this.isPeerVisible(r.cwd)) return false;
|
|
529
|
-
return to === "*" ? true : r.cwd === self.cwd;
|
|
530
|
-
});
|
|
531
|
-
if (peers.length === 0) return "No other sessions to broadcast to.";
|
|
532
|
-
const ok: string[] = [];
|
|
533
|
-
const failed: string[] = [];
|
|
534
|
-
for (const peer of peers) {
|
|
535
|
-
const sent = await this.sendLetter(peer, "message", body);
|
|
536
|
-
if (sent.ok) ok.push(`"${peer.name}"`);
|
|
537
|
-
else failed.push(`"${peer.name}": ${sent.error}`);
|
|
538
|
-
}
|
|
539
|
-
const head = `Broadcast to ${ok.length}/${peers.length} session${peers.length === 1 ? "" : "s"}.`;
|
|
540
|
-
const detail = failed.length > 0 ? ` Refused: ${failed.join("; ")}.` : "";
|
|
541
|
-
return head + detail;
|
|
511
|
+
return "send requires a single session id; broadcasting is disabled.";
|
|
542
512
|
}
|
|
543
513
|
const resolved = await this.resolveTarget(to);
|
|
544
514
|
if (!resolved.ok) return resolved.error;
|
|
545
515
|
const sent = await this.sendLetter(resolved.record, "message", body);
|
|
546
516
|
if (!sent.ok) return sent.error;
|
|
547
|
-
return `Sent to "${resolved.record.name}" (${shortAddr(resolved.record.addr)}) [id ${sent.letter.id.slice(
|
|
517
|
+
return `Sent to "${resolved.record.name}" (${shortAddr(resolved.record.addr)}) [id ${sent.letter.id.slice(-8)}]: ${sent.verdict}.`;
|
|
548
518
|
}
|
|
549
519
|
|
|
550
520
|
async ask(to: string, body: string, timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
551
521
|
if (!to) return 'ask requires "to".';
|
|
552
522
|
if (!body) return 'ask requires "message".';
|
|
553
523
|
if (to === "*" || to === "cwd") {
|
|
554
|
-
return
|
|
524
|
+
return "ask is 1:1 and cannot broadcast.";
|
|
555
525
|
}
|
|
556
526
|
const self = this.requireSelf();
|
|
557
527
|
const resolved = await this.resolveTarget(to);
|
|
@@ -578,7 +548,7 @@ export class TalkCore {
|
|
|
578
548
|
const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
|
|
579
549
|
await clearAsk(this.storage, self.addr, sent.letter.id);
|
|
580
550
|
if (!outcome.replied)
|
|
581
|
-
return `Ask ${sent.letter.id.slice(
|
|
551
|
+
return `Ask ${sent.letter.id.slice(-8)} to "${record.name}": ${outcome.reason}.`;
|
|
582
552
|
return `"${record.name}" replied:\n\n${outcome.body}`;
|
|
583
553
|
} finally {
|
|
584
554
|
// The ask tool call is still part of a running agent turn.
|
|
@@ -600,7 +570,7 @@ export class TalkCore {
|
|
|
600
570
|
const sent = await this.sendLetter(target, "reply", body, ask.id);
|
|
601
571
|
if (!sent.ok) return sent.error;
|
|
602
572
|
await clearAsk(this.storage, self.addr, ask.id);
|
|
603
|
-
return `Replied to "${target.name}" (ask ${ask.id.slice(
|
|
573
|
+
return `Replied to "${target.name}" (ask ${ask.id.slice(-8)}): ${sent.verdict}.`;
|
|
604
574
|
}
|
|
605
575
|
|
|
606
576
|
/** Build a minimal record from a letter's sender when the peer record is gone. */
|
|
@@ -644,8 +614,7 @@ export class TalkCore {
|
|
|
644
614
|
if (now === prev) continue;
|
|
645
615
|
this.watched.set(addr, now);
|
|
646
616
|
const label = rec ? `"${rec.name}"` : shortAddr(addr);
|
|
647
|
-
const state =
|
|
648
|
-
now === "live" ? (rec?.status ?? "idle") : now === "stalled" ? "not responding" : "offline";
|
|
617
|
+
const state = now === "live" ? (rec?.status ?? "idle") : "offline";
|
|
649
618
|
this.events.notify(`talk watch: ${label} is now ${state}.`);
|
|
650
619
|
}
|
|
651
620
|
}
|
package/src/talk/format.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function formatListing(
|
|
|
51
51
|
if (records.length === 0) return "[]";
|
|
52
52
|
const items: SessionListItem[] = records.map((r) => {
|
|
53
53
|
const p = presence(r);
|
|
54
|
-
const status = p === "live" ? r.status :
|
|
54
|
+
const status = p === "live" ? r.status : "offline";
|
|
55
55
|
const item: SessionListItem = { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
|
|
56
56
|
if (r.addr === selfAddr) item.self = true;
|
|
57
57
|
return item;
|
package/src/talk/index.ts
CHANGED
|
@@ -224,24 +224,15 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
224
224
|
pi.registerTool({
|
|
225
225
|
name: "talk-list-sessions",
|
|
226
226
|
label: "List Talk Sessions",
|
|
227
|
-
description:
|
|
228
|
-
"List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
|
|
227
|
+
description: "List visible pi sessions (id, status, work_dir, name).",
|
|
229
228
|
promptSnippet: "List other pi sessions on this machine",
|
|
230
229
|
parameters: Type.Object({
|
|
231
230
|
cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
|
|
232
|
-
includeOffline: Type.Optional(
|
|
233
|
-
Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
|
|
234
|
-
),
|
|
235
231
|
}),
|
|
236
232
|
async execute(_toolCallId, params) {
|
|
237
233
|
const initError = requireInit();
|
|
238
234
|
if (initError) return toolResult(initError);
|
|
239
|
-
|
|
240
|
-
return toolResult(
|
|
241
|
-
params.cwd
|
|
242
|
-
? await core.listCwd(params.cwd, includeOffline)
|
|
243
|
-
: await core.list(includeOffline),
|
|
244
|
-
);
|
|
235
|
+
return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
|
|
245
236
|
},
|
|
246
237
|
});
|
|
247
238
|
|
|
@@ -276,12 +267,10 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
276
267
|
name: "talk-send",
|
|
277
268
|
label: "Send Talk Message",
|
|
278
269
|
description:
|
|
279
|
-
|
|
270
|
+
"Send a plain-text message to a single pi session. Plain text only, ≤32KB — send a summary and a path, never file contents.",
|
|
280
271
|
promptSnippet: "Send a message to another pi session",
|
|
281
272
|
parameters: Type.Object({
|
|
282
|
-
to: Type.String({
|
|
283
|
-
description: 'Target session (name/address/@alias; "*" or "cwd" to broadcast)',
|
|
284
|
-
}),
|
|
273
|
+
to: Type.String({ description: "Target session id (from talk-list-sessions)" }),
|
|
285
274
|
message: Type.String({ description: "Message body" }),
|
|
286
275
|
}),
|
|
287
276
|
async execute(_toolCallId, params) {
|
|
@@ -320,7 +309,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
320
309
|
|
|
321
310
|
pi.registerCommand("talk-dead", {
|
|
322
311
|
description:
|
|
323
|
-
"Mark a talk session as dead (
|
|
312
|
+
"Mark a talk session as dead (shown offline, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
|
|
324
313
|
async handler(args) {
|
|
325
314
|
const initError = requireInit();
|
|
326
315
|
const trimmed = args.trim();
|
|
@@ -348,13 +337,13 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
348
337
|
) {
|
|
349
338
|
return; // pre-renderer entries: keep pi's default custom-message box
|
|
350
339
|
}
|
|
351
|
-
const
|
|
340
|
+
const idTail = d.id.slice(-8);
|
|
352
341
|
const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
|
|
353
342
|
const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
|
|
354
|
-
const footer = theme.fg("dim", `id ${
|
|
343
|
+
const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${relativeTime(d.ts)}`);
|
|
355
344
|
const out = [header, sanitizeTerminal(d.body), "", footer];
|
|
356
345
|
if (d.kind === "ask") {
|
|
357
|
-
out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${
|
|
346
|
+
out.push(theme.fg("dim", `└─ reply via talk-reply, replyTo: "${idTail}"`));
|
|
358
347
|
}
|
|
359
348
|
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
360
349
|
box.addChild(new Text(out.join("\n"), 0, 0));
|
package/src/talk/mailbox.ts
CHANGED
|
@@ -247,7 +247,7 @@ export async function pendingAsks(storage: TalkStorage, addr: string): Promise<L
|
|
|
247
247
|
return out;
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
-
/** Outgoing ask ids, used by the
|
|
250
|
+
/** Outgoing ask ids, used by the core for interlock arbitration. */
|
|
251
251
|
export async function outgoingAskIds(storage: TalkStorage, addr: string): Promise<string[]> {
|
|
252
252
|
const out: string[] = [];
|
|
253
253
|
for (const key of await storage.listKeys(asksNs(addr))) {
|
|
@@ -257,15 +257,19 @@ export async function outgoingAskIds(storage: TalkStorage, addr: string): Promis
|
|
|
257
257
|
return out;
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
/**
|
|
260
|
+
/**
|
|
261
|
+
* Resolve a pending ask by explicit replyTo id or unique suffix. No inference.
|
|
262
|
+
* Matching is on the suffix: the prefix of a time-ordered (v7-style) id is the
|
|
263
|
+
* timestamp part and collides easily, the suffix is the random part.
|
|
264
|
+
*/
|
|
261
265
|
export async function resolveAskByRef(
|
|
262
266
|
storage: TalkStorage,
|
|
263
267
|
addr: string,
|
|
264
268
|
replyTo: string,
|
|
265
269
|
): Promise<Letter | null> {
|
|
266
|
-
if (!replyTo) return null; // empty
|
|
270
|
+
if (!replyTo) return null; // empty suffix matches every id — an explicit ref is required
|
|
267
271
|
const asks = await pendingAsks(storage, addr);
|
|
268
|
-
return asks.find((a) => a.id === replyTo || a.id.
|
|
272
|
+
return asks.find((a) => a.id === replyTo || a.id.endsWith(replyTo)) ?? null;
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
// ── Audit log ────────────────────────────────────────────────────────────
|
package/src/talk/policy.ts
CHANGED
|
@@ -28,8 +28,7 @@ export class OutboundPolicy {
|
|
|
28
28
|
* Gate one outbound letter. `unreadBacklog` is the target's current
|
|
29
29
|
* unread count (report 0 when the target is idle — an idle agent has by
|
|
30
30
|
* definition worked through what it was handed). `target` scopes the
|
|
31
|
-
* identical-body dedupe to a single peer (loop-breaking)
|
|
32
|
-
* of one body to N peers is not deduped after the first.
|
|
31
|
+
* identical-body dedupe to a single peer (loop-breaking).
|
|
33
32
|
*/
|
|
34
33
|
check(body: string, unreadBacklog: number, target?: string): OutboundVerdict {
|
|
35
34
|
if (body.length > MAX_BODY_CHARS) {
|
package/src/talk/registry.ts
CHANGED
|
@@ -8,14 +8,18 @@
|
|
|
8
8
|
* two sessions on one directory never share an inbox.
|
|
9
9
|
* - A record outlives the process that wrote it — that's what makes a session
|
|
10
10
|
* addressable while it's down (mail waits on disk).
|
|
11
|
-
* - Presence is
|
|
12
|
-
*
|
|
11
|
+
* - Presence is the offline flag plus the pid and its start time: a record
|
|
12
|
+
* whose process is alive (pid + matching start time, ruling out pid reuse)
|
|
13
|
+
* and not flagged offline is live; everything else is offline. There is no
|
|
14
|
+
* heartbeat, so a wedged process is indistinguishable from a healthy idle
|
|
15
|
+
* one.
|
|
13
16
|
* - Listing has NO side effects.
|
|
14
17
|
*
|
|
15
18
|
* All values read from storage are validated with TypeBox before use.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
import { createHash } from "node:crypto";
|
|
22
|
+
import { readFileSync } from "node:fs";
|
|
19
23
|
|
|
20
24
|
import { type Static, Type } from "typebox";
|
|
21
25
|
import { Value } from "typebox/value";
|
|
@@ -28,6 +32,7 @@ export const SessionRecordSchema = Type.Object({
|
|
|
28
32
|
name: Type.String(),
|
|
29
33
|
cwd: Type.String(),
|
|
30
34
|
pid: Type.Number(),
|
|
35
|
+
pidStart: Type.Optional(Type.Number()),
|
|
31
36
|
startedAt: Type.Number(),
|
|
32
37
|
lastSeenAt: Type.Number(),
|
|
33
38
|
status: Type.Union([
|
|
@@ -39,12 +44,9 @@ export const SessionRecordSchema = Type.Object({
|
|
|
39
44
|
});
|
|
40
45
|
export type SessionRecord = Static<typeof SessionRecordSchema>;
|
|
41
46
|
|
|
42
|
-
export type Presence = "live" | "
|
|
47
|
+
export type Presence = "live" | "offline";
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
/** A session is shown in the default listing while its heartbeat is this fresh. */
|
|
46
|
-
export const LIST_ACTIVE_MS = 15 * 60 * 1000;
|
|
47
|
-
/** Sweep leaves a record alone until its heartbeat has been quiet this long. */
|
|
49
|
+
/** Sweep leaves a record alone until its last activity was this long ago. */
|
|
48
50
|
export const SWEEP_OFFLINE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
49
51
|
/** A mailbox holding undelivered mail is kept this long after last contact. */
|
|
50
52
|
export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -105,29 +107,58 @@ export async function listRecords(storage: TalkStorage): Promise<SessionRecord[]
|
|
|
105
107
|
return out.toSorted((a, b) => a.startedAt - b.startedAt);
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Process start time (field 22 of /proc/<pid>/stat), used to rule out pid
|
|
112
|
+
* reuse: pids wrap around, so an alive pid is only the same session when its
|
|
113
|
+
* start time matches the recorded one. Returns undefined on non-Linux or when
|
|
114
|
+
* the stat file is unreadable.
|
|
115
|
+
*/
|
|
116
|
+
export function readStartTime(pid: number): number | undefined {
|
|
117
|
+
try {
|
|
118
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
119
|
+
// comm sits in parens and may contain spaces: split after the last ')'.
|
|
120
|
+
// starttime is field 22 (1-indexed); pid and (comm) consumed two tokens,
|
|
121
|
+
// so it sits at index 19 of the remainder.
|
|
122
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
123
|
+
const starttime = Number(fields[19]);
|
|
124
|
+
return Number.isFinite(starttime) ? starttime : undefined;
|
|
125
|
+
} catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Whether the recorded process is really alive. `pidStart` guards against
|
|
132
|
+
* pid wrap-around: when present, an alive pid only counts as live if its
|
|
133
|
+
* current start time matches. Without it (legacy records) — or when /proc is
|
|
134
|
+
* unreadable — we fall back to the bare pid check.
|
|
135
|
+
*/
|
|
136
|
+
function pidAlive(pid: number, pidStart?: number): boolean {
|
|
109
137
|
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
110
138
|
try {
|
|
111
139
|
process.kill(pid, 0);
|
|
112
|
-
return true;
|
|
113
140
|
} catch (error) {
|
|
114
141
|
// EPERM means the process exists but isn't ours — still alive
|
|
115
142
|
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
116
143
|
}
|
|
144
|
+
if (pidStart === undefined) return true;
|
|
145
|
+
const start = readStartTime(pid);
|
|
146
|
+
return start === undefined ? true : start === pidStart;
|
|
117
147
|
}
|
|
118
148
|
|
|
119
|
-
export function presenceOf(record: SessionRecord
|
|
149
|
+
export function presenceOf(record: SessionRecord): Presence {
|
|
120
150
|
if (record.offline) return "offline";
|
|
121
|
-
if (!pidAlive(record.pid)) return "offline";
|
|
122
|
-
return
|
|
151
|
+
if (!pidAlive(record.pid, record.pidStart)) return "offline";
|
|
152
|
+
return "live";
|
|
123
153
|
}
|
|
124
154
|
|
|
125
155
|
// ── Sweep ────────────────────────────────────────────────────────────────
|
|
126
156
|
|
|
127
157
|
/**
|
|
128
158
|
* Reclaim dead sessions' data. Rules (mail outranks tidiness):
|
|
129
|
-
* - a record whose
|
|
130
|
-
*
|
|
159
|
+
* - a record whose process is still alive is never touched;
|
|
160
|
+
* - a record whose last activity was less than SWEEP_OFFLINE_GRACE_MS ago is
|
|
161
|
+
* never touched — it may be merely down or suspended, and a resume will
|
|
131
162
|
* re-register it under the same id anyway;
|
|
132
163
|
* - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
|
|
133
164
|
* - once the grace period has passed, an empty mailbox is discarded promptly
|
|
@@ -136,6 +167,9 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
|
|
|
136
167
|
*/
|
|
137
168
|
export async function sweep(storage: TalkStorage, now: number = Date.now()): Promise<void> {
|
|
138
169
|
for (const record of await listRecords(storage)) {
|
|
170
|
+
// Without a heartbeat, lastSeenAt only tracks the last event, so an idle
|
|
171
|
+
// live session would look long-quiet — never reap a live process.
|
|
172
|
+
if (pidAlive(record.pid, record.pidStart)) continue;
|
|
139
173
|
const quietFor = now - record.lastSeenAt;
|
|
140
174
|
if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;
|
|
141
175
|
const hasMail =
|
|
@@ -35,8 +35,8 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
|
|
|
35
35
|
### Status
|
|
36
36
|
|
|
37
37
|
- `idle` / `working` (agent actively running) / `waiting-talk-message` (blocked in `talk-ask` waiting for a reply)
|
|
38
|
-
- `
|
|
39
|
-
-
|
|
38
|
+
- `offline` (process exited or marked dead)
|
|
39
|
+
- `talk-list-sessions` lists every visible session — live or offline — with its current status.
|
|
40
40
|
|
|
41
41
|
### Visibility
|
|
42
42
|
|
|
@@ -45,14 +45,14 @@ The core rule: **a peer only knows what you tell it.** Messages must be self-con
|
|
|
45
45
|
|
|
46
46
|
## Tools
|
|
47
47
|
|
|
48
|
-
| Tool | Purpose
|
|
49
|
-
| -------------------- |
|
|
50
|
-
| `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`)
|
|
51
|
-
| `talk-send` | Send a plain message (async — the main collaboration primitive)
|
|
52
|
-
| `talk-ask` | Ask a question and block for the reply (default 30 min timeout)
|
|
53
|
-
| `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message
|
|
48
|
+
| Tool | Purpose |
|
|
49
|
+
| -------------------- | -------------------------------------------------------------------------------------- |
|
|
50
|
+
| `talk-list-sessions` | List visible sessions (`id` / `status` / `work_dir` / `name`) |
|
|
51
|
+
| `talk-send` | Send a plain message to a single session id (async — the main collaboration primitive) |
|
|
52
|
+
| `talk-ask` | Ask a question and block for the reply (default 30 min timeout) |
|
|
53
|
+
| `talk-reply` | Reply to a received ask; `replyTo` is the ask id shown in the delivered message |
|
|
54
54
|
|
|
55
|
-
In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (
|
|
55
|
+
In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (shown offline, swept soon).
|
|
56
56
|
|
|
57
57
|
## Collaboration workflows
|
|
58
58
|
|
|
@@ -90,5 +90,5 @@ In the TUI: `/talk` lists sessions, `/talk-dead` marks a session as dead (remove
|
|
|
90
90
|
|
|
91
91
|
- **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.
|
|
92
92
|
- **Address from known ids**: only run `talk-list-sessions` to discover sessions 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 session id`.
|
|
93
|
-
- **Respect status**: asking an offline
|
|
93
|
+
- **Respect status**: asking an offline session blocks until the 30 min timeout. Prefer `talk-send` there — the message queues on disk and the peer receives it when it resumes.
|
|
94
94
|
- **Visibility boundary**: you can only collaborate with sessions you can see; invisible sessions are unreachable by design.
|