@trim21/personal-pi-extensions 0.0.197 → 0.0.199
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 -1
- package/package.json +1 -1
- package/src/lib/jsonc.ts +67 -0
- package/src/talk/core.ts +71 -13
- package/src/talk/index.ts +25 -4
- package/src/talk/registry.ts +5 -1
package/README.md
CHANGED
|
@@ -242,9 +242,11 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
242
242
|
|
|
243
243
|
**定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
|
|
244
244
|
|
|
245
|
+
**标记废弃 session**:`/talk-dead` 把 session 的 `lastSeenAt` 置 0(从默认列表消失,下次 sweep 无 mail 即回收):无参标记当前 session(同时停止其心跳),`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
|
|
246
|
+
|
|
245
247
|
### 关键设计
|
|
246
248
|
|
|
247
|
-
- **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline
|
|
249
|
+
- **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline,并显示 `working` / `waiting-talk-message`(`talk-wait` / `talk-ask` 阻塞等待中)/ `idle`。
|
|
248
250
|
- **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
|
|
249
251
|
- **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
|
|
250
252
|
- **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
|
package/package.json
CHANGED
package/src/lib/jsonc.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSONC → JSON conversion for small user-edited config files.
|
|
3
|
+
*
|
|
4
|
+
* Strips line and block comments and trailing commas outside of string
|
|
5
|
+
* literals, so a stray comment in a config file does not silently void the
|
|
6
|
+
* whole file (the way a plain `JSON.parse` would).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function jsoncToJson(raw: string): string {
|
|
10
|
+
let out = "";
|
|
11
|
+
let inString = false;
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < raw.length) {
|
|
14
|
+
const c = raw[i];
|
|
15
|
+
if (inString) {
|
|
16
|
+
out += c;
|
|
17
|
+
if (c === "\\" && i + 1 < raw.length) {
|
|
18
|
+
out += raw[i + 1];
|
|
19
|
+
i += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (c === '"') inString = false;
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
switch (c) {
|
|
27
|
+
case '"': {
|
|
28
|
+
inString = true;
|
|
29
|
+
out += c;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
case "/": {
|
|
34
|
+
if (raw[i + 1] === "/") {
|
|
35
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (raw[i + 1] === "*") {
|
|
39
|
+
i += 2;
|
|
40
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
41
|
+
i += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
out += c;
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
case ",": {
|
|
49
|
+
// Drop a trailing comma before } or ] (outside strings).
|
|
50
|
+
let j = i + 1;
|
|
51
|
+
while (j < raw.length && /\s/.test(raw[j])) j++;
|
|
52
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out += c;
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
default: {
|
|
61
|
+
out += c;
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
package/src/talk/core.ts
CHANGED
|
@@ -128,6 +128,8 @@ export class TalkCore {
|
|
|
128
128
|
private readonly deliveredIds = new Set<string>();
|
|
129
129
|
/** Visibility gate over peer working directories; defaults to everything visible. */
|
|
130
130
|
private isPeerVisible: (peerCwd: string) => boolean = () => true;
|
|
131
|
+
/** Manually marked dead: heartbeat stopped, lastSeenAt pinned to 0. */
|
|
132
|
+
private dead = false;
|
|
131
133
|
|
|
132
134
|
private inboxPoll: ReturnType<typeof setInterval> | undefined;
|
|
133
135
|
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
@@ -203,6 +205,11 @@ export class TalkCore {
|
|
|
203
205
|
void this.writeSelf({ status: "working" });
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
/** Blocked on talk-wait / talk-ask — visible as "waiting-talk-message". */
|
|
209
|
+
setWaiting(): void {
|
|
210
|
+
void this.writeSelf({ status: "waiting-talk-message" });
|
|
211
|
+
}
|
|
212
|
+
|
|
206
213
|
setIdle(): void {
|
|
207
214
|
void this.writeSelf({ status: "idle" });
|
|
208
215
|
}
|
|
@@ -213,7 +220,8 @@ export class TalkCore {
|
|
|
213
220
|
|
|
214
221
|
private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
|
|
215
222
|
if (!this.self) return;
|
|
216
|
-
|
|
223
|
+
// A dead session pins lastSeenAt to 0 so no later event re-freshens it.
|
|
224
|
+
this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
|
|
217
225
|
try {
|
|
218
226
|
await writeRecord(this.storage, this.self);
|
|
219
227
|
} catch {
|
|
@@ -470,13 +478,19 @@ export class TalkCore {
|
|
|
470
478
|
async wait(timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
471
479
|
const self = this.requireSelf();
|
|
472
480
|
const deadline = this.now() + timeoutMs;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
481
|
+
this.setWaiting();
|
|
482
|
+
try {
|
|
483
|
+
for (;;) {
|
|
484
|
+
const inbox = await listInbox(this.storage, self.addr);
|
|
485
|
+
const fresh = await this.consumeFresh(inbox);
|
|
486
|
+
if (fresh.length > 0) return fresh.map((l) => formatDelivery(l)).join("\n\n");
|
|
487
|
+
if (signal?.aborted) return "aborted";
|
|
488
|
+
if (this.now() >= deadline) return `No message within ${Math.round(timeoutMs / 1000)}s.`;
|
|
489
|
+
await sleep(WAIT_POLL_MS);
|
|
490
|
+
}
|
|
491
|
+
} finally {
|
|
492
|
+
// The tool call is still part of a running agent turn.
|
|
493
|
+
this.setWorking();
|
|
480
494
|
}
|
|
481
495
|
}
|
|
482
496
|
|
|
@@ -509,6 +523,44 @@ export class TalkCore {
|
|
|
509
523
|
return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
|
|
510
524
|
}
|
|
511
525
|
|
|
526
|
+
/** Visible peer records (excluding self), e.g. for command completions. */
|
|
527
|
+
async listPeers(): Promise<SessionRecord[]> {
|
|
528
|
+
const self = this.requireSelf();
|
|
529
|
+
const records = await listRecords(this.storage);
|
|
530
|
+
return records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Mark a session as dead by pinning lastSeenAt to 0: it vanishes from the
|
|
535
|
+
* default listing and the next sweep reaps it (empty mailbox). Without a
|
|
536
|
+
* target, marks this session — its heartbeat is stopped and later
|
|
537
|
+
* writeSelf calls no longer refresh lastSeenAt.
|
|
538
|
+
*/
|
|
539
|
+
async markDead(target?: string): Promise<string> {
|
|
540
|
+
if (!target) {
|
|
541
|
+
this.dead = true;
|
|
542
|
+
if (this.heartbeat) {
|
|
543
|
+
clearInterval(this.heartbeat);
|
|
544
|
+
this.heartbeat = undefined;
|
|
545
|
+
}
|
|
546
|
+
await this.writeSelf({});
|
|
547
|
+
return "Marked this session as dead.";
|
|
548
|
+
}
|
|
549
|
+
const resolved = await this.resolveTarget(target);
|
|
550
|
+
if (!resolved.ok) return resolved.error;
|
|
551
|
+
await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
|
|
552
|
+
return `Marked "${resolved.record.name}" as dead.`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Mark every visible peer (except self) as dead. */
|
|
556
|
+
async markAllDead(): Promise<string> {
|
|
557
|
+
const peers = await this.listPeers();
|
|
558
|
+
for (const peer of peers) {
|
|
559
|
+
await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
|
|
560
|
+
}
|
|
561
|
+
return `Marked ${peers.length} session(s) as dead.`;
|
|
562
|
+
}
|
|
563
|
+
|
|
512
564
|
async send(to: string, body: string): Promise<string> {
|
|
513
565
|
if (!to) return 'send requires "to".';
|
|
514
566
|
if (!body) return 'send requires "message".';
|
|
@@ -567,11 +619,17 @@ export class TalkCore {
|
|
|
567
619
|
body,
|
|
568
620
|
ts: sent.letter.ts,
|
|
569
621
|
});
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
622
|
+
this.setWaiting();
|
|
623
|
+
try {
|
|
624
|
+
const outcome = await this.waitForReply(sent.letter.id, Math.max(1000, timeoutMs), signal);
|
|
625
|
+
await clearAsk(this.storage, self.addr, sent.letter.id);
|
|
626
|
+
if (!outcome.replied)
|
|
627
|
+
return `Ask ${sent.letter.id.slice(0, 8)} to "${record.name}": ${outcome.reason}.`;
|
|
628
|
+
return `"${record.name}" replied:\n\n${outcome.body}`;
|
|
629
|
+
} finally {
|
|
630
|
+
// The ask tool call is still part of a running agent turn.
|
|
631
|
+
this.setWorking();
|
|
632
|
+
}
|
|
575
633
|
}
|
|
576
634
|
|
|
577
635
|
async reply(replyTo: string, body: string): Promise<string> {
|
package/src/talk/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
|
|
20
|
+
import { jsoncToJson } from "../lib/jsonc.js";
|
|
20
21
|
import { resolveHomePath } from "../lib/path.js";
|
|
21
22
|
import { buildVisibilityFilter, TalkCore } from "./core.js";
|
|
22
23
|
import { formatDelivery } from "./format.js";
|
|
@@ -104,14 +105,17 @@ function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
|
|
|
104
105
|
* (everything visible); an explicit `"allowed": []` hides every peer.
|
|
105
106
|
*/
|
|
106
107
|
function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
|
|
108
|
+
const configPath = path.join(cwd, ".pi", "talk.json");
|
|
107
109
|
try {
|
|
108
|
-
const raw = fs.readFileSync(
|
|
109
|
-
const parsed = JSON.parse(raw) as { allowed?: unknown };
|
|
110
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
111
|
+
const parsed = JSON.parse(jsoncToJson(raw)) as { allowed?: unknown };
|
|
110
112
|
if (Array.isArray(parsed.allowed)) {
|
|
111
113
|
return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
|
|
112
114
|
}
|
|
113
115
|
return {};
|
|
114
|
-
} catch {
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// eslint-disable-next-line no-console -- config errors must be visible, not silent
|
|
118
|
+
console.error(`Warning: could not parse ${configPath}: ${String(error)}`);
|
|
115
119
|
return {};
|
|
116
120
|
}
|
|
117
121
|
}
|
|
@@ -310,7 +314,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
310
314
|
},
|
|
311
315
|
});
|
|
312
316
|
|
|
313
|
-
// ── /talk
|
|
317
|
+
// ── /talk commands ────────────────────────────────────────────────────
|
|
314
318
|
|
|
315
319
|
pi.registerCommand("talk", {
|
|
316
320
|
description: "List registered pi sessions",
|
|
@@ -320,6 +324,23 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
320
324
|
},
|
|
321
325
|
});
|
|
322
326
|
|
|
327
|
+
pi.registerCommand("talk-dead", {
|
|
328
|
+
description:
|
|
329
|
+
"Mark a talk session as dead (removed from listings, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
|
|
330
|
+
async handler(args) {
|
|
331
|
+
const initError = requireInit();
|
|
332
|
+
const trimmed = args.trim();
|
|
333
|
+
const text =
|
|
334
|
+
initError ??
|
|
335
|
+
(trimmed === "--all"
|
|
336
|
+
? await core.markAllDead()
|
|
337
|
+
: trimmed
|
|
338
|
+
? await core.markDead(trimmed)
|
|
339
|
+
: await core.markDead());
|
|
340
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
323
344
|
// ── Delivery card ──────────────────────────────────────────────────────
|
|
324
345
|
|
|
325
346
|
pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
|
package/src/talk/registry.ts
CHANGED
|
@@ -30,7 +30,11 @@ export const SessionRecordSchema = Type.Object({
|
|
|
30
30
|
pid: Type.Number(),
|
|
31
31
|
startedAt: Type.Number(),
|
|
32
32
|
lastSeenAt: Type.Number(),
|
|
33
|
-
status: Type.Union([
|
|
33
|
+
status: Type.Union([
|
|
34
|
+
Type.Literal("idle"),
|
|
35
|
+
Type.Literal("working"),
|
|
36
|
+
Type.Literal("waiting-talk-message"),
|
|
37
|
+
]),
|
|
34
38
|
offline: Type.Optional(Type.Boolean()),
|
|
35
39
|
});
|
|
36
40
|
export type SessionRecord = Static<typeof SessionRecordSchema>;
|