@trim21/personal-pi-extensions 0.0.197 → 0.0.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -242,6 +242,8 @@ 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
249
  - **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.197",
3
+ "version": "0.0.198",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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;
@@ -213,7 +215,8 @@ export class TalkCore {
213
215
 
214
216
  private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
215
217
  if (!this.self) return;
216
- this.self = { ...this.self, ...patch, lastSeenAt: this.now() };
218
+ // A dead session pins lastSeenAt to 0 so no later event re-freshens it.
219
+ this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
217
220
  try {
218
221
  await writeRecord(this.storage, this.self);
219
222
  } catch {
@@ -509,6 +512,44 @@ export class TalkCore {
509
512
  return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
510
513
  }
511
514
 
515
+ /** Visible peer records (excluding self), e.g. for command completions. */
516
+ async listPeers(): Promise<SessionRecord[]> {
517
+ const self = this.requireSelf();
518
+ const records = await listRecords(this.storage);
519
+ return records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
520
+ }
521
+
522
+ /**
523
+ * Mark a session as dead by pinning lastSeenAt to 0: it vanishes from the
524
+ * default listing and the next sweep reaps it (empty mailbox). Without a
525
+ * target, marks this session — its heartbeat is stopped and later
526
+ * writeSelf calls no longer refresh lastSeenAt.
527
+ */
528
+ async markDead(target?: string): Promise<string> {
529
+ if (!target) {
530
+ this.dead = true;
531
+ if (this.heartbeat) {
532
+ clearInterval(this.heartbeat);
533
+ this.heartbeat = undefined;
534
+ }
535
+ await this.writeSelf({});
536
+ return "Marked this session as dead.";
537
+ }
538
+ const resolved = await this.resolveTarget(target);
539
+ if (!resolved.ok) return resolved.error;
540
+ await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
541
+ return `Marked "${resolved.record.name}" as dead.`;
542
+ }
543
+
544
+ /** Mark every visible peer (except self) as dead. */
545
+ async markAllDead(): Promise<string> {
546
+ const peers = await this.listPeers();
547
+ for (const peer of peers) {
548
+ await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
549
+ }
550
+ return `Marked ${peers.length} session(s) as dead.`;
551
+ }
552
+
512
553
  async send(to: string, body: string): Promise<string> {
513
554
  if (!to) return 'send requires "to".';
514
555
  if (!body) return 'send requires "message".';
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(path.join(cwd, ".pi", "talk.json"), "utf8");
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 command ─────────────────────────────────────────────────────
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) => {