pi-session-mail 0.1.0 → 0.2.0

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.
@@ -23,10 +23,21 @@
23
23
  // `pi.events` (below).
24
24
  //
25
25
  // The hook contracts live in this package's README.
26
- import { type FSWatcher, watch } from "node:fs";
26
+ import { type FSWatcher, statSync, watch } from "node:fs";
27
+ import { dirname } from "node:path";
27
28
 
28
29
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
29
- import { label, listRunning, removeRecord, resolveTo, type State, writeRecord } from "./running.ts";
30
+ import {
31
+ isRunning,
32
+ label,
33
+ listRunning,
34
+ type RunningRecord,
35
+ removeRecord,
36
+ resolveTo,
37
+ type State,
38
+ writeRecord,
39
+ } from "./running.ts";
40
+ import { type SettleInput, settle } from "./settle.ts";
30
41
  import {
31
42
  boxPath,
32
43
  claim,
@@ -36,51 +47,45 @@ import {
36
47
  findSent,
37
48
  isAddress,
38
49
  listNew,
50
+ pruneClosed,
39
51
  readEnvelope,
40
52
  type SentCopy,
41
53
  send,
42
54
  } from "./store.ts";
43
- import { registerTools, type Tools } from "./tools.ts";
55
+ import { readSettings, registerTools, type Tools } from "./tools.ts";
44
56
 
45
57
  const CUSTOM_TYPE = "mailbox";
46
58
  const STATUS_KEY = "mailbox";
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
47
60
  /** Fallback rescan interval; `fs.watch` on macOS drops and coalesces events. */
48
61
  const POLL_MS = 1000;
49
62
  /** Inbound bodies are cut at this many UTF-8 bytes. */
50
63
  const BODY_CAP = 32 * 1024;
51
64
  /** Each request quoted in a reply is cut at this many UTF-8 bytes. */
52
65
  const QUOTE_CAP = 2 * 1024;
53
- /** Reply body when the run ended with no assistant text. */
54
- const NO_ANSWER = "(The session settled with no answer text.)";
55
- /** Reply body of a run the user stopped, with any partial answer text after it. */
56
- const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
57
- /** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
58
- const TOOK_OVER =
59
- "(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
60
- /** Opening line of the reply body of a run that ended on an error. */
61
- const ERRORED = (error: string) =>
62
- `(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
63
- /** Body of a `failed` reply to requests that never entered the conversation. */
64
- const UNSEEN =
65
- "(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
66
-
67
66
  /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
68
67
  function cap(text: string, max: number): string | undefined {
69
- const bytes = Buffer.from(text, "utf8");
70
- if (bytes.length <= max) return undefined;
71
- let end = max;
72
- while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
73
- return bytes.subarray(0, end).toString("utf8");
68
+ // `encodeInto` stops before a character that does not fit, so the cut is on a boundary.
69
+ const { read } = new TextEncoder().encodeInto(text, new Uint8Array(max));
70
+ return read === text.length ? undefined : text.slice(0, read);
74
71
  }
75
72
 
73
+ /**
74
+ * Text from outside this session (an id, a name, a working directory) with every
75
+ * control character (C0, DEL, C1) and the line and paragraph separators
76
+ * U+2028/U+2029 replaced by a space, so it cannot start a line of its own and
77
+ * pass for a `[mailbox]` line.
78
+ */
79
+ const plain = (text: string): string => text.replace(/[\p{Cc}\u2028\u2029]/gu, " ");
80
+
76
81
  /** A reply quotes each request it answers: the id, the copy's path, then the capped body. */
77
82
  function quoteRequest(request: SentCopy): string {
78
83
  const cut = cap(request.envelope.body, QUOTE_CAP);
79
84
  const body =
80
85
  cut === undefined
81
86
  ? request.envelope.body
82
- : `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${request.path}`;
83
- return `[mailbox] Your request ${request.envelope.id}, quoted from ${request.path}:\n${body
87
+ : `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${plain(request.path)}`;
88
+ return `[mailbox] Your request ${plain(request.envelope.id)}, quoted from ${plain(request.path)}:\n${body
84
89
  .split("\n")
85
90
  .map((line) => `> ${line}`)
86
91
  .join("\n")}`;
@@ -93,10 +98,11 @@ function quoteRequest(request: SentCopy): string {
93
98
  function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy[]; quotes: string[] } {
94
99
  const requests: SentCopy[] = [];
95
100
  const quotes: string[] = [];
101
+ const copies = findSent(me, ids);
96
102
  for (const id of ids) {
97
- const copy = findSent(me, id);
103
+ const copy = copies.get(id);
98
104
  if (copy === undefined) {
99
- quotes.push(`[mailbox] Your request ${id} has no copy in sent/; only its id is known.`);
105
+ quotes.push(`[mailbox] Your request ${plain(id)} has no copy in sent/; only its id is known.`);
100
106
  continue;
101
107
  }
102
108
  requests.push(copy);
@@ -111,10 +117,9 @@ function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy
111
117
  * directory. No distrust wording: with it, models refused every peer request
112
118
  * as a possible injection (spec Q37). Safety lives outside the model.
113
119
  */
114
- function senderLine(address: string): { line: string; name: string } {
115
- const record = listRunning().find((r) => r.address === address);
116
- const where = record === undefined ? "" : `, working in ${record.cwd}`;
117
- const name = label(record ?? { address });
120
+ function senderLine(address: string, record: RunningRecord | undefined): { line: string; name: string } {
121
+ const where = record === undefined ? "" : `, working in ${plain(record.cwd)}`;
122
+ const name = plain(label(record ?? { address }));
118
123
  return { line: `[mailbox] From ${name} (${address}${where}), another Pi session on this machine.`, name };
119
124
  }
120
125
 
@@ -135,29 +140,30 @@ function inboundText(
135
140
  path: string,
136
141
  quotes: readonly string[],
137
142
  opts: { lateAnswer: boolean; byUser: boolean },
143
+ senderRecord: RunningRecord | undefined,
138
144
  ): string {
139
145
  const { lateAnswer, byUser } = opts;
140
- const sender = senderLine(envelope.from);
146
+ const sender = senderLine(envelope.from, senderRecord);
147
+ const id = plain(envelope.id);
148
+ const replyIds = envelope.in_reply_to.map(plain);
141
149
  const header = [sender.line];
142
150
  if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
143
151
  if (envelope.kind === "ask")
144
152
  header.push(
145
- `It is waiting for your answer to its ask ${envelope.id}. Answer with session_mail_reply (ask ${envelope.id}); otherwise this run's last message is sent as the answer.`,
153
+ `It is waiting for your answer to its ask ${id}. Answer with session_mail_reply (ask ${id}); otherwise this run's last message is sent as the answer.`,
146
154
  );
147
155
  if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
148
156
  if (envelope.kind === "message")
149
157
  header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
150
158
  if (envelope.in_reply_to.length > 0)
151
- header.push(
152
- `It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${envelope.in_reply_to.join(", ")}.`,
153
- );
159
+ header.push(`It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${replyIds.join(", ")}.`);
154
160
  if (byUser)
155
161
  header.push("The user made that request, typing it with /mailbox or through an extension such as delegate.");
156
162
  if (envelope.in_reply_to.length > 0 && envelope.status !== "done")
157
- header.push(`Its status is "${envelope.status}", not "done": it is not an answer.`);
163
+ header.push(`Its status is "${plain(String(envelope.status))}", not "done": it is not an answer.`);
158
164
  const cut = cap(envelope.body, BODY_CAP);
159
165
  const body =
160
- cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${path}`;
166
+ cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${plain(path)}`;
161
167
  return [header.join(" "), ...quotes, body, endLine(sender.name)].join("\n\n");
162
168
  }
163
169
 
@@ -166,8 +172,9 @@ function inboundText(
166
172
  * synchronous code before `emit` returns; do all work before any `await`.
167
173
  *
168
174
  * - `message:send`: the caller emits `{ to, body }`; `pi-session-mail` writes a
169
- * request and sets `envelope` (or `error`) on the same object. Neither set
170
- * means no provider is installed.
175
+ * request, stamped with the run's hop count during a run and 0 when idle,
176
+ * and sets `envelope` (or `error`) on the same object. Neither set means no
177
+ * provider is installed.
171
178
  * - `message:inbound`: emitted for every claimed envelope, requests and
172
179
  * replies alike, before injection; `requests` holds a reply's request
173
180
  * copies from this session's `sent/`, and a listener sets `handled` to show
@@ -185,22 +192,15 @@ type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMe
185
192
 
186
193
  /** The last assistant message in a run's transcript, if any. */
187
194
  function lastAssistant(messages: readonly unknown[]): Message | undefined {
188
- for (let i = messages.length - 1; i >= 0; i--) {
189
- const message = messages[i] as Message;
190
- if (message?.role === "assistant") return message;
191
- }
192
- return undefined;
195
+ return (messages as Message[]).findLast((message) => message?.role === "assistant");
193
196
  }
194
197
 
195
198
  /** The text of the last assistant message that has any; undefined when none has. */
196
199
  function lastAssistantText(messages: readonly unknown[]): string | undefined {
197
- for (let i = messages.length - 1; i >= 0; i--) {
198
- const message = messages[i] as Message;
199
- if (message?.role !== "assistant") continue;
200
- const text = assistantText(message);
201
- if (text !== undefined) return text;
202
- }
203
- return undefined;
200
+ const message = (messages as Message[]).findLast(
201
+ (candidate) => candidate?.role === "assistant" && assistantText(candidate) !== undefined,
202
+ );
203
+ return message === undefined ? undefined : assistantText(message);
204
204
  }
205
205
 
206
206
  /** The text of an assistant message; undefined when it has none. */
@@ -227,12 +227,8 @@ export default function mailbox(pi: ExtensionAPI) {
227
227
  let timer: ReturnType<typeof setInterval> | undefined;
228
228
  /** Envelope ids already handed to Pi in this process. */
229
229
  const delivered = new Set<string>();
230
- /** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
231
- const receivedKinds = new Map<string, Envelope["kind"]>();
232
230
  /** Request ids awaiting this session's reply, keyed by sender. In memory only. */
233
231
  let owed = new Map<string, string[]>();
234
- /** Ask ids awaiting this session's answer, keyed by sender; answered apart from requests. */
235
- let owedAsks = new Map<string, string[]>();
236
232
  /** Owed request ids whose message entered the conversation (or a listener took over). */
237
233
  let seen = new Set<string>();
238
234
  /**
@@ -254,8 +250,8 @@ export default function mailbox(pi: ExtensionAPI) {
254
250
  * steered into it. Mail sent from the turn carries it.
255
251
  */
256
252
  let hops = 0;
257
- /** Whether this session has already warned about a broken `session-mail.json`. */
258
- let configWarned = false;
253
+ /** The problems with `session-mail.json` this session has already warned about. */
254
+ let configWarned = new Set<string>();
259
255
  /** What this session's running-session record says. */
260
256
  let name: string | undefined;
261
257
  let cwd = "";
@@ -285,6 +281,19 @@ export default function mailbox(pi: ExtensionAPI) {
285
281
 
286
282
  const warn = (message: string) => ctx?.ui.notify(`mailbox: ${message}`, "warning");
287
283
 
284
+ /** The address folder is left as found; tell the user how to tighten it. */
285
+ function warnIfLoose(me: string) {
286
+ const folder = dirname(boxPath(me, "new"));
287
+ let mode: number;
288
+ try {
289
+ mode = statSync(folder).mode & 0o777;
290
+ } catch {
291
+ return;
292
+ }
293
+ if ((mode & 0o077) === 0) return;
294
+ warn(`${folder} is mode ${mode.toString(8)}, open to other users; run: chmod 700 ${folder}`);
295
+ }
296
+
288
297
  /** Footer entry `✉ N pending · N read · N awaiting`, non-zero parts only; hidden when all are zero. */
289
298
  function updateStatus() {
290
299
  if (!ctx?.hasUI || address === undefined) return;
@@ -315,7 +324,7 @@ export default function mailbox(pi: ExtensionAPI) {
315
324
 
316
325
  function deliver(me: string, envelope: Envelope, path: string) {
317
326
  delivered.add(envelope.id);
318
- receivedKinds.set(envelope.id, envelope.kind);
327
+ tools?.recordKind(envelope);
319
328
  // The answer to a waiting ask is that tool call's result, and nothing else.
320
329
  if (tools?.takeAnswer(envelope)) return;
321
330
  const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
@@ -329,7 +338,7 @@ export default function mailbox(pi: ExtensionAPI) {
329
338
  // Only requests arm an answer: replies are never answered, and messages
330
339
  // expect none. A request arms one even when a listener took over its display.
331
340
  if (envelope.kind === "request") owed.set(envelope.from, [...(owed.get(envelope.from) ?? []), envelope.id]);
332
- if (envelope.kind === "ask") owedAsks.set(envelope.from, [...(owedAsks.get(envelope.from) ?? []), envelope.id]);
341
+ tools?.armAsk(envelope);
333
342
  // Mail that wakes or steers the session raises its count; a reply shown
334
343
  // quietly starts no turn, but one a listener took over may.
335
344
  if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
@@ -342,7 +351,13 @@ export default function mailbox(pi: ExtensionAPI) {
342
351
  pi.sendMessage(
343
352
  {
344
353
  customType: CUSTOM_TYPE,
345
- content: inboundText(envelope, path, quotes, { lateAnswer, byUser }),
354
+ content: inboundText(
355
+ envelope,
356
+ path,
357
+ quotes,
358
+ { lateAnswer, byUser },
359
+ listRunning().find((r) => r.address === envelope.from),
360
+ ),
346
361
  display: true,
347
362
  details: { id: envelope.id },
348
363
  },
@@ -382,8 +397,11 @@ export default function mailbox(pi: ExtensionAPI) {
382
397
  try {
383
398
  if (address === undefined) throw new Error("no active session has a mailbox address");
384
399
  if (typeof payload.body !== "string") throw new Error("body must be a string");
385
- // A `message:send` caller acts for the user, so its request starts a fresh chain.
386
- payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: 0 });
400
+ // During a run the caller is the model acting through an extension, so the
401
+ // request continues the run's chain; idle, it acts for the user and starts a
402
+ // fresh one. It is never refused here: the receiving side enforces the limit.
403
+ const hopsNow = state === "busy" ? hops : 0;
404
+ payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: hopsNow });
387
405
  updateStatus();
388
406
  } catch (err) {
389
407
  payload.error = (err as Error).message;
@@ -399,11 +417,27 @@ export default function mailbox(pi: ExtensionAPI) {
399
417
  payload.scanned = true;
400
418
  });
401
419
 
420
+ /** Warn about a problem with `session-mail.json`, once per session. */
421
+ function configProblem(message: string) {
422
+ if (configWarned.has(message)) return;
423
+ configWarned.add(message);
424
+ warn(message);
425
+ }
426
+
427
+ /** Remove closed sessions' folders that are older than `pruneAfterDays`; a failure warns and never throws. */
428
+ function pruneOld(me: string) {
429
+ try {
430
+ const { pruneAfterDays } = readSettings(configProblem);
431
+ pruneClosed({ own: me, isRunning, cutoffMs: Date.now() - pruneAfterDays * DAY_MS });
432
+ } catch (err) {
433
+ warn(`could not prune old mailbox folders: ${(err as Error).message}`);
434
+ }
435
+ }
436
+
402
437
  pi.on("session_start", async (_event, context) => {
403
438
  stop();
404
439
  ctx = context;
405
440
  owed = new Map();
406
- owedAsks = new Map();
407
441
  seen = new Set();
408
442
  held = new Set();
409
443
  lastAnswer = undefined;
@@ -413,15 +447,25 @@ export default function mailbox(pi: ExtensionAPI) {
413
447
  hops = 0;
414
448
  tools?.abandonAsk();
415
449
  waitingOn = "";
416
- configWarned = false;
450
+ configWarned = new Set();
417
451
  const id = context.sessionManager.getSessionId();
418
452
  if (!isAddress(id)) {
419
453
  address = undefined;
454
+ tools?.setAddress(undefined);
420
455
  warn(`session id ${JSON.stringify(id)} is not a usable address; the mailbox is off`);
421
456
  return;
422
457
  }
458
+ try {
459
+ ensureBoxes(id);
460
+ } catch (err) {
461
+ address = undefined;
462
+ tools?.setAddress(undefined);
463
+ warn(`mailbox is off: ${(err as Error).message}`);
464
+ return;
465
+ }
423
466
  address = id;
424
- ensureBoxes(id);
467
+ tools?.setAddress(id);
468
+ warnIfLoose(id);
425
469
  name = context.sessionManager.getSessionName?.() || undefined;
426
470
  cwd = context.cwd;
427
471
  state = context.isIdle?.() === false ? "busy" : "idle";
@@ -435,6 +479,7 @@ export default function mailbox(pi: ExtensionAPI) {
435
479
  pendingScan = setImmediate(() => {
436
480
  pendingScan = undefined;
437
481
  scan();
482
+ pruneOld(id);
438
483
  });
439
484
  pendingScan.unref?.();
440
485
  try {
@@ -499,52 +544,25 @@ export default function mailbox(pi: ExtensionAPI) {
499
544
  // queued follow-up after `agent_end` would otherwise get a premature answer.
500
545
  pi.on("agent_settled", async () => {
501
546
  const me = address;
502
- // A stopped or errored run notes how it ended, then gives what it had.
503
- const opening = lastAborted ? STOPPED : lastError !== undefined ? ERRORED(lastError) : undefined;
504
- const body =
505
- opening === undefined
506
- ? (lastAnswer ?? NO_ANSWER)
507
- : lastAnswer === undefined
508
- ? opening
509
- : `${opening}\n\n${lastAnswer}`;
510
- const status = lastAborted ? "stopped" : lastError !== undefined ? "failed" : "done";
511
- // A request held across a stop is answered by the run that completes it, saying the user took over.
512
- const tookOverBody = `${TOOK_OVER}\n\n${lastAnswer ?? NO_ANSWER}`;
513
547
  // Answers carry the settled turn's count; the next turn counts afresh.
514
548
  const answerHops = hops;
515
549
  hops = 0;
516
- // A request the user stopped a run on is the user's to finish, not an
517
- // answer yet: it stays owed, through stops and failed runs alike, until a
518
- // run completes. Asks are never held; their asker is blocked waiting.
519
- const requests = [...owed];
520
- const asks = [...owedAsks];
521
- const read = seen;
522
- const wasHeld = held;
523
- owed = new Map();
524
- owedAsks = new Map();
550
+ const end: SettleInput["end"] = lastAborted
551
+ ? { kind: "stopped" }
552
+ : lastError !== undefined
553
+ ? { kind: "failed", error: lastError }
554
+ : { kind: "done" };
555
+ const decision = settle({
556
+ requests: owed,
557
+ asks: tools?.takeOwedAsks() ?? new Map(),
558
+ seen,
559
+ held,
560
+ end,
561
+ answer: lastAnswer,
562
+ });
563
+ owed = decision.owed;
564
+ held = decision.held;
525
565
  seen = new Set();
526
- held = new Set();
527
- const replies: { to: string; ids: string[]; body: string; status: Envelope["status"] }[] = [];
528
- for (const [to, ids] of requests) {
529
- const unseen = ids.filter((id) => !read.has(id) && !wasHeld.has(id));
530
- const keep = ids.filter(
531
- (id) => !unseen.includes(id) && (lastAborted || (lastError !== undefined && wasHeld.has(id))),
532
- );
533
- const answer = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
534
- if (keep.length > 0) owed.set(to, keep);
535
- for (const id of keep) held.add(id);
536
- const plain = answer.filter((id) => !wasHeld.has(id));
537
- const tookOver = answer.filter((id) => wasHeld.has(id));
538
- if (plain.length > 0) replies.push({ to, ids: plain, body, status });
539
- if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
540
- if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
541
- }
542
- for (const [to, ids] of asks) {
543
- const done = ids.filter((id) => read.has(id));
544
- const failed = ids.filter((id) => !read.has(id));
545
- if (done.length > 0) replies.push({ to, ids: done, body, status });
546
- if (failed.length > 0) replies.push({ to, ids: failed, body: UNSEEN, status: "failed" });
547
- }
548
566
  lastAnswer = undefined;
549
567
  lastAborted = false;
550
568
  lastError = undefined;
@@ -552,7 +570,7 @@ export default function mailbox(pi: ExtensionAPI) {
552
570
  state = "idle";
553
571
  announce();
554
572
  if (me === undefined) return;
555
- for (const reply of replies) {
573
+ for (const reply of decision.replies) {
556
574
  const { to } = reply;
557
575
  try {
558
576
  // Answers are never refused by the hop limit.
@@ -575,6 +593,7 @@ export default function mailbox(pi: ExtensionAPI) {
575
593
  }
576
594
  }
577
595
  address = undefined;
596
+ tools?.setAddress(undefined);
578
597
  });
579
598
 
580
599
  tools = registerTools(pi, {
@@ -583,36 +602,13 @@ export default function mailbox(pi: ExtensionAPI) {
583
602
  waitingOn = to;
584
603
  announce();
585
604
  },
586
- reply: (id, body) => {
587
- const me = address;
588
- if (me === undefined) throw new Error("this session has no mailbox address; nothing was sent");
589
- const kind = receivedKinds.get(id);
590
- if (kind === undefined)
591
- throw new Error(`no mail with id ${JSON.stringify(id)} reached this session; nothing was sent`);
592
- if (kind !== "ask")
593
- throw new Error(
594
- `${id} is a ${kind}, not an ask; nothing was sent. ${kind === "request" ? "A request is answered automatically when this run settles." : kind === "message" ? "A message expects no answer; session_mail_send sends one if it is wanted." : "A reply is never answered."}`,
595
- );
596
- const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
597
- // An answered ask, by this tool or at settle, is no longer owed.
598
- if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
599
- // Answers carry the turn's count and are never refused by the hop limit.
600
- const answer = send(me, from, body, { kind: "reply", hops, inReplyTo: [id], status: "done" });
601
- const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
602
- if (rest.length > 0) owedAsks.set(from, rest);
603
- else owedAsks.delete(from);
604
- return answer;
605
- },
606
605
  hops: () => hops,
607
- configProblem: (message) => {
608
- if (configWarned) return;
609
- configWarned = true;
610
- warn(message);
611
- },
606
+ configProblem,
612
607
  });
613
608
 
614
609
  pi.registerCommand("mailbox", {
615
- description: "Show this session's mailbox address and name, or send a request: /mailbox <name or id> <text>",
610
+ description:
611
+ "Show this session's mailbox address and name, send a request: /mailbox <name or id> <text>, or remove closed sessions' empty mailbox folders: /mailbox prune",
616
612
  handler: async (args, context) => {
617
613
  const me = context.sessionManager.getSessionId();
618
614
  const trimmed = args.trim();
@@ -624,6 +620,15 @@ export default function mailbox(pi: ExtensionAPI) {
624
620
  );
625
621
  return;
626
622
  }
623
+ if (trimmed === "prune") {
624
+ try {
625
+ const removed = pruneClosed({ own: me, isRunning });
626
+ context.ui.notify(`Removed ${removed} mailbox folders of closed sessions`, "info");
627
+ } catch (err) {
628
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
629
+ }
630
+ return;
631
+ }
627
632
  const match = /^(\S+)\s+([\s\S]+)$/.exec(trimmed);
628
633
  if (!match) {
629
634
  context.ui.notify("Usage: /mailbox <name or id> <text>", "error");
@@ -1,11 +1,14 @@
1
1
  // Running-session records and `to` resolution. Internal to `pi-session-mail`: tests
2
- // reach it only through the extension's registration function.
2
+ // reach it through the extension's registration function, apart from the start-token
3
+ // probe and its parsers, which `test/start-token.test.ts` tests directly.
3
4
  //
4
5
  // Each running session announces itself in `<root>/running/<address>.json`:
5
6
  // its address, Pi session name, working directory, process id, idle or busy,
6
7
  // the address it waits on, and when the record last changed. A record whose
7
- // process is gone counts as not running, and the reader that finds it deletes
8
- // it. The root is the mail root, so every agent directory sees every session.
8
+ // process is gone, or whose pid now belongs to a process that started at
9
+ // another time, counts as not running, and the reader that finds it deletes it.
10
+ // The root is the mail root, so every agent directory sees every session.
11
+ import { execFileSync } from "node:child_process";
9
12
  import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
13
  import { join } from "node:path";
11
14
 
@@ -19,6 +22,11 @@ export type RunningRecord = {
19
22
  name?: string;
20
23
  cwd: string;
21
24
  pid: number;
25
+ /**
26
+ * An opaque token for the process's start time, so a reused pid is told from the
27
+ * original process. Absent in records written by 0.1.0 and where no probe works.
28
+ */
29
+ started?: string;
22
30
  state: State;
23
31
  /** The address this session's pending ask waits on; empty when none. */
24
32
  waitingOn: string;
@@ -27,12 +35,14 @@ export type RunningRecord = {
27
35
  };
28
36
 
29
37
  /** How many leading characters of a session id name it in lists and errors. */
30
- export const SHORT_ID = 8;
38
+ const SHORT_ID = 8;
31
39
 
32
- export const shortId = (address: string) => address.slice(0, SHORT_ID);
40
+ const shortId = (address: string) => address.slice(0, SHORT_ID);
33
41
 
34
42
  /** A record's display label: its session name, or its short id when it has none. */
35
- export const label = (record: Pick<RunningRecord, "address" | "name">) => record.name ?? shortId(record.address);
43
+ export function label(record: Pick<RunningRecord, "address" | "name">): string {
44
+ return record.name ?? shortId(record.address);
45
+ }
36
46
 
37
47
  const runningDir = () => join(mailRoot(), "running");
38
48
 
@@ -41,8 +51,78 @@ const recordPath = (address: string) => {
41
51
  return join(runningDir(), `${address}.json`);
42
52
  };
43
53
 
44
- /** Write `record` owner-only, through a temporary file so a reader never sees half of it. */
45
- export function writeRecord(record: RunningRecord): void {
54
+ /**
55
+ * The start-time token in a `/proc/<pid>/stat` line: field 22. The command name
56
+ * (field 2) is in parentheses and may itself hold spaces and parentheses, so
57
+ * the fields are counted from the last `)`.
58
+ */
59
+ export function parseProcStat(line: string): string | undefined {
60
+ const rest = line.slice(line.lastIndexOf(")") + 1).trim();
61
+ if (rest === "" || !line.includes(")")) return undefined;
62
+ const token = rest.split(/\s+/)[19]; // field 3 is the first after the name
63
+ return token !== undefined && /^\d+$/.test(token) ? token : undefined;
64
+ }
65
+
66
+ /** Parse `ps -o pid=,lstart=` output into a pid -> start token map; unparseable lines are skipped. */
67
+ export function parsePsStarts(output: string): Map<number, string> {
68
+ const out = new Map<number, string>();
69
+ for (const line of output.split("\n")) {
70
+ const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
71
+ if (match !== null) out.set(Number(match[1]), match[2]);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /**
77
+ * The start tokens of `pids`, for those the probe could read. Linux reads
78
+ * `/proc`; macOS runs one `ps` for all of them, in UTC and the C locale so
79
+ * every session renders a start time the same way; elsewhere, or on any error,
80
+ * the result lacks the pid, which callers treat as unknown.
81
+ */
82
+ export function startTokens(pids: readonly number[]): Map<number, string> {
83
+ const out = new Map<number, string>();
84
+ if (pids.length === 0) return out;
85
+ if (process.platform === "linux") {
86
+ for (const pid of pids) {
87
+ try {
88
+ const token = parseProcStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
89
+ if (token !== undefined) out.set(pid, token);
90
+ } catch {}
91
+ }
92
+ } else if (process.platform === "darwin") {
93
+ let stdout = "";
94
+ try {
95
+ stdout = execFileSync("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
96
+ encoding: "utf8",
97
+ timeout: 2000,
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
100
+ });
101
+ } catch (err) {
102
+ // ps exits 1 when some pid is gone but still prints the others.
103
+ const partial = (err as { stdout?: unknown }).stdout;
104
+ if (typeof partial === "string") stdout = partial;
105
+ }
106
+ for (const [pid, token] of parsePsStarts(stdout)) out.set(pid, token);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ let ownToken: { value: string | undefined } | undefined;
112
+
113
+ /** This process's start token, probed once; undefined when it cannot be read. */
114
+ function ownStartToken(): string | undefined {
115
+ ownToken ??= { value: startTokens([process.pid]).get(process.pid) };
116
+ return ownToken.value;
117
+ }
118
+
119
+ /**
120
+ * Write `record` owner-only, through a temporary file so a reader never sees half of it.
121
+ * A record for this process gets this process's start token.
122
+ */
123
+ export function writeRecord(given: RunningRecord): void {
124
+ const started = given.pid === process.pid && given.started === undefined ? ownStartToken() : undefined;
125
+ const record = started === undefined ? given : { ...given, started };
46
126
  mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
47
127
  const path = recordPath(record.address);
48
128
  const tmp = `${path}.${process.pid}.tmp`;
@@ -79,6 +159,7 @@ function readRecord(path: string): RunningRecord | undefined {
79
159
  ...(typeof value.name === "string" && value.name !== "" ? { name: value.name } : {}),
80
160
  cwd: value.cwd,
81
161
  pid: value.pid,
162
+ ...(typeof value.started === "string" && value.started !== "" ? { started: value.started } : {}),
82
163
  state: value.state === "busy" ? "busy" : "idle",
83
164
  waitingOn: typeof value.waitingOn === "string" ? value.waitingOn : "",
84
165
  updated: typeof value.updated === "string" ? value.updated : "",
@@ -88,6 +169,24 @@ function readRecord(path: string): RunningRecord | undefined {
88
169
  }
89
170
  }
90
171
 
172
+ /**
173
+ * Whether `address` has a live running record, read fresh from its one file. The
174
+ * rules are `listRunning`'s: the pid is alive and, when both tokens are known,
175
+ * the start token matches. A missing or unreadable record is not running; a
176
+ * failed probe falls back to the pid alone. Never throws and deletes nothing.
177
+ */
178
+ export function isRunning(address: string): boolean {
179
+ try {
180
+ const record = readRecord(recordPath(address));
181
+ if (record === undefined || record.address !== address || !alive(record.pid)) return false;
182
+ if (record.started === undefined) return true;
183
+ const live = startTokens([record.pid]).get(record.pid);
184
+ return live === undefined || live === record.started;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
91
190
  /** Every running session's record, sorted by label; records of dead processes are deleted. */
92
191
  export function listRunning(): RunningRecord[] {
93
192
  let names: string[];
@@ -96,12 +195,21 @@ export function listRunning(): RunningRecord[] {
96
195
  } catch {
97
196
  return [];
98
197
  }
99
- const out: RunningRecord[] = [];
198
+ const found: { path: string; record: RunningRecord }[] = [];
100
199
  for (const name of names) {
101
200
  const path = join(runningDir(), name);
102
201
  const record = readRecord(path);
103
202
  if (record === undefined || `${record.address}.json` !== name) continue; // not ours to judge or delete
104
- if (!alive(record.pid)) {
203
+ found.push({ path, record });
204
+ }
205
+ const tokens = startTokens(
206
+ found.filter(({ record }) => record.started !== undefined && alive(record.pid)).map(({ record }) => record.pid),
207
+ );
208
+ const out: RunningRecord[] = [];
209
+ for (const { path, record } of found) {
210
+ const live = tokens.get(record.pid);
211
+ // A token we cannot read is unknown, which falls back to the pid-only check.
212
+ if (!alive(record.pid) || (record.started !== undefined && live !== undefined && live !== record.started)) {
105
213
  rmSync(path, { force: true });
106
214
  continue;
107
215
  }