pi-session-mail 0.1.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.
package/index.ts ADDED
@@ -0,0 +1,642 @@
1
+ // pi-session-mail: a file mailbox between Pi sessions on this machine.
2
+ //
3
+ // Every session has an address (its session id) and an inbox under
4
+ // `<mail root>/<address>/`, where the mail root
5
+ // (`$XDG_STATE_HOME/pi-session-mail/`, or `~/.local/state/pi-session-mail/`)
6
+ // is shared by every agent directory, so sessions in different ones reach each other.
7
+ // Each running session also announces its name, working directory and idle or
8
+ // busy state in `<mail root>/running/<address>.json` (`running.ts`), which
9
+ // `session_mail_list` reports (`tools.ts`) and which lets a `to` name a
10
+ // session by its Pi session name or short id. `/mailbox` shows the address and
11
+ // name, and `/mailbox <to> <text>` sends a request; `session_mail_send` sends
12
+ // a message, which expects no answer. Mail in the inbox is claimed into `cur/`
13
+ // and injected with a label naming the sending session. Requests and messages
14
+ // wake an idle session and steer into a busy one; a reply quotes each request
15
+ // it answers from this session's `sent/` copy and starts no turn. When the recipient settles, its
16
+ // last answer goes back to each sender as one reply — `done` normally, or
17
+ // `stopped` when the user stopped the run, with any partial text, or `failed`
18
+ // when the run ended on an error, with the error and any partial text. A
19
+ // request is never answered `stopped`: a run the user stopped holds it, owed,
20
+ // until a run completes, and that answer says the user took over. Requests
21
+ // that never entered the conversation (an abort drops queued follow-ups) get
22
+ // a `failed` reply instead. Replies and messages are never answered. Other extensions use
23
+ // `pi.events` (below).
24
+ //
25
+ // The hook contracts live in this package's README.
26
+ import { type FSWatcher, watch } from "node:fs";
27
+
28
+ 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
+ boxPath,
32
+ claim,
33
+ diskCounts,
34
+ type Envelope,
35
+ ensureBoxes,
36
+ findSent,
37
+ isAddress,
38
+ listNew,
39
+ readEnvelope,
40
+ type SentCopy,
41
+ send,
42
+ } from "./store.ts";
43
+ import { registerTools, type Tools } from "./tools.ts";
44
+
45
+ const CUSTOM_TYPE = "mailbox";
46
+ const STATUS_KEY = "mailbox";
47
+ /** Fallback rescan interval; `fs.watch` on macOS drops and coalesces events. */
48
+ const POLL_MS = 1000;
49
+ /** Inbound bodies are cut at this many UTF-8 bytes. */
50
+ const BODY_CAP = 32 * 1024;
51
+ /** Each request quoted in a reply is cut at this many UTF-8 bytes. */
52
+ 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
+ /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
68
+ 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");
74
+ }
75
+
76
+ /** A reply quotes each request it answers: the id, the copy's path, then the capped body. */
77
+ function quoteRequest(request: SentCopy): string {
78
+ const cut = cap(request.envelope.body, QUOTE_CAP);
79
+ const body =
80
+ cut === undefined
81
+ ? 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
84
+ .split("\n")
85
+ .map((line) => `> ${line}`)
86
+ .join("\n")}`;
87
+ }
88
+
89
+ /**
90
+ * The request copies this session holds for `ids`, and one quoted block per id
91
+ * in order; an id with no copy is named by id alone.
92
+ */
93
+ function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy[]; quotes: string[] } {
94
+ const requests: SentCopy[] = [];
95
+ const quotes: string[] = [];
96
+ for (const id of ids) {
97
+ const copy = findSent(me, id);
98
+ if (copy === undefined) {
99
+ quotes.push(`[mailbox] Your request ${id} has no copy in sent/; only its id is known.`);
100
+ continue;
101
+ }
102
+ requests.push(copy);
103
+ quotes.push(quoteRequest(copy));
104
+ }
105
+ return { requests, quotes };
106
+ }
107
+
108
+ /**
109
+ * The sender's line, in the neutral style of `pi-intercom`'s `**From <name>** (<cwd>)`:
110
+ * its session name (or short id), full id and, while it runs, its working
111
+ * directory. No distrust wording: with it, models refused every peer request
112
+ * as a possible injection (spec Q37). Safety lives outside the model.
113
+ */
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 });
118
+ return { line: `[mailbox] From ${name} (${address}${where}), another Pi session on this machine.`, name };
119
+ }
120
+
121
+ /**
122
+ * The last line of injected mail. Pi hands a custom message to the model as a
123
+ * user message, and the Anthropic API joins it with the user's next typed
124
+ * prompt into one turn, so without this line that prompt reads as the mail's.
125
+ */
126
+ const endLine = (name: string) => `[mailbox] End of the mail from ${name}. Text after this line is not part of it.`;
127
+
128
+ /**
129
+ * The injected message: who it is from, how to answer it, the requests it
130
+ * answers, its capped body, then a line ending it. `byUser` marks a reply to a
131
+ * request, which only the user makes (with `/mailbox` or through an extension).
132
+ */
133
+ function inboundText(
134
+ envelope: Envelope,
135
+ path: string,
136
+ quotes: readonly string[],
137
+ opts: { lateAnswer: boolean; byUser: boolean },
138
+ ): string {
139
+ const { lateAnswer, byUser } = opts;
140
+ const sender = senderLine(envelope.from);
141
+ const header = [sender.line];
142
+ if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
143
+ if (envelope.kind === "ask")
144
+ 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.`,
146
+ );
147
+ if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
148
+ if (envelope.kind === "message")
149
+ header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
150
+ 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
+ );
154
+ if (byUser)
155
+ header.push("The user made that request, typing it with /mailbox or through an extension such as delegate.");
156
+ 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.`);
158
+ const cut = cap(envelope.body, BODY_CAP);
159
+ const body =
160
+ cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${path}`;
161
+ return [header.join(" "), ...quotes, body, endLine(sender.name)].join("\n\n");
162
+ }
163
+
164
+ /**
165
+ * `pi.events` channels. Both rely on Pi's event bus running a listener's
166
+ * synchronous code before `emit` returns; do all work before any `await`.
167
+ *
168
+ * - `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.
171
+ * - `message:inbound`: emitted for every claimed envelope, requests and
172
+ * replies alike, before injection; `requests` holds a reply's request
173
+ * copies from this session's `sent/`, and a listener sets `handled` to show
174
+ * the message itself.
175
+ */
176
+ const SEND = "message:send";
177
+ const INBOUND = "message:inbound";
178
+ /** A consumer's request to claim waiting mail now; see the README's `message:scan`. */
179
+ const SCAN = "message:scan";
180
+
181
+ type SendPayload = { to: unknown; body: unknown; envelope?: Envelope; error?: string };
182
+ type InboundPayload = { envelope: Envelope; path: string; requests: SentCopy[]; handled: boolean };
183
+
184
+ type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
185
+
186
+ /** The last assistant message in a run's transcript, if any. */
187
+ 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;
193
+ }
194
+
195
+ /** The text of the last assistant message that has any; undefined when none has. */
196
+ 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;
204
+ }
205
+
206
+ /** The text of an assistant message; undefined when it has none. */
207
+ function assistantText(message: Message): string | undefined {
208
+ const content = message.content;
209
+ const text =
210
+ typeof content === "string"
211
+ ? content
212
+ : Array.isArray(content)
213
+ ? content
214
+ .filter(
215
+ (part): part is { type: "text"; text: string } => part?.type === "text" && typeof part.text === "string",
216
+ )
217
+ .map((part) => part.text)
218
+ .join("\n")
219
+ : "";
220
+ return text.trim() === "" ? undefined : text;
221
+ }
222
+
223
+ export default function mailbox(pi: ExtensionAPI) {
224
+ let address: string | undefined;
225
+ let ctx: ExtensionContext | undefined;
226
+ let watcher: FSWatcher | undefined;
227
+ let timer: ReturnType<typeof setInterval> | undefined;
228
+ /** Envelope ids already handed to Pi in this process. */
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
+ /** Request ids awaiting this session's reply, keyed by sender. In memory only. */
233
+ 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
+ /** Owed request ids whose message entered the conversation (or a listener took over). */
237
+ let seen = new Set<string>();
238
+ /**
239
+ * Request ids held across a stop: the user stopped a run while they were
240
+ * owed, so they wait, still owed, for the next run that completes.
241
+ */
242
+ let held = new Set<string>();
243
+ /** Text of the last assistant message, remembered at `agent_end`. */
244
+ let lastAnswer: string | undefined;
245
+ /** Whether the user stopped that run, whatever its last message's `stopReason`. */
246
+ let lastAborted = false;
247
+ /** The error that ended that run when the user did not stop it; undefined when none did. */
248
+ let lastError: string | undefined;
249
+ /** Messages injected since the last settle, for the status's pending count. */
250
+ let injected = 0;
251
+ /**
252
+ * The hop count of the current turn: 0 once the user starts or steers it,
253
+ * otherwise the highest `hops` + 1 among the envelopes that started or
254
+ * steered into it. Mail sent from the turn carries it.
255
+ */
256
+ let hops = 0;
257
+ /** Whether this session has already warned about a broken `session-mail.json`. */
258
+ let configWarned = false;
259
+ /** What this session's running-session record says. */
260
+ let name: string | undefined;
261
+ let cwd = "";
262
+ let state: State = "idle";
263
+ /** The address this session's ask waits on; empty when none. */
264
+ let waitingOn = "";
265
+ /** The model-facing tools, registered below; they hold the waiting ask. */
266
+ let tools: Tools | undefined;
267
+
268
+ /** Write this session's running-session record from the fields above. */
269
+ function announce() {
270
+ if (address === undefined) return;
271
+ try {
272
+ writeRecord({
273
+ address,
274
+ ...(name === undefined ? {} : { name }),
275
+ cwd,
276
+ pid: process.pid,
277
+ state,
278
+ waitingOn,
279
+ updated: new Date().toISOString(),
280
+ });
281
+ } catch (err) {
282
+ warn(`could not write this session's running record: ${(err as Error).message}`);
283
+ }
284
+ }
285
+
286
+ const warn = (message: string) => ctx?.ui.notify(`mailbox: ${message}`, "warning");
287
+
288
+ /** Footer entry `✉ N pending · N read · N awaiting`, non-zero parts only; hidden when all are zero. */
289
+ function updateStatus() {
290
+ if (!ctx?.hasUI || address === undefined) return;
291
+ const counts = diskCounts(address);
292
+ const parts = [
293
+ [counts.unclaimed + injected, "pending"],
294
+ [counts.read, "read"],
295
+ [counts.awaiting, "awaiting"],
296
+ ]
297
+ .filter(([n]) => n !== 0)
298
+ .map(([n, label]) => `${n} ${label}`);
299
+ ctx.ui.setStatus(STATUS_KEY, parts.length > 0 ? `✉ ${parts.join(" · ")}` : undefined);
300
+ }
301
+
302
+ /** A first scan deferred to the next event-loop turn; see `session_start`. */
303
+ let pendingScan: ReturnType<typeof setImmediate> | undefined;
304
+
305
+ function stop() {
306
+ watcher?.close();
307
+ watcher = undefined;
308
+ if (timer) clearInterval(timer);
309
+ timer = undefined;
310
+ if (pendingScan !== undefined) {
311
+ clearImmediate(pendingScan);
312
+ pendingScan = undefined;
313
+ }
314
+ }
315
+
316
+ function deliver(me: string, envelope: Envelope, path: string) {
317
+ delivered.add(envelope.id);
318
+ receivedKinds.set(envelope.id, envelope.kind);
319
+ // The answer to a waiting ask is that tool call's result, and nothing else.
320
+ if (tools?.takeAnswer(envelope)) return;
321
+ const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
322
+ // An answer to an ask that stopped waiting is delivered like a message.
323
+ const lateAnswer = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "ask");
324
+ const wakes = envelope.kind !== "reply" || lateAnswer;
325
+ const byUser = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "request");
326
+ // Listeners run synchronously inside emit, so `handled` is final when it returns.
327
+ const inbound: InboundPayload = { envelope, path, requests, handled: false };
328
+ pi.events.emit(INBOUND, inbound);
329
+ // Only requests arm an answer: replies are never answered, and messages
330
+ // expect none. A request arms one even when a listener took over its display.
331
+ 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]);
333
+ // Mail that wakes or steers the session raises its count; a reply shown
334
+ // quietly starts no turn, but one a listener took over may.
335
+ if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
336
+ // `pi-session-mail` cannot observe a listener's own message, so a takeover counts as seen.
337
+ if (inbound.handled) {
338
+ seen.add(envelope.id);
339
+ return;
340
+ }
341
+ injected++;
342
+ pi.sendMessage(
343
+ {
344
+ customType: CUSTOM_TYPE,
345
+ content: inboundText(envelope, path, quotes, { lateAnswer, byUser }),
346
+ display: true,
347
+ details: { id: envelope.id },
348
+ },
349
+ // Mail for the model wakes an idle session and steers into a busy one at
350
+ // its next gap between tool calls, queued user input or not; a reply only
351
+ // shows itself, and starts no turn.
352
+ wakes ? { triggerTurn: true, deliverAs: "steer" } : { deliverAs: "followUp" },
353
+ );
354
+ }
355
+
356
+ /** Claim and deliver everything in `new/`, in name order. Synchronous, so scans never overlap. */
357
+ function scan() {
358
+ const me = address;
359
+ if (me === undefined) return;
360
+ const names = listNew(me);
361
+ for (const name of names) {
362
+ const path = claim(me, name);
363
+ if (path === null) continue; // another scanner claimed it
364
+ let envelope: Envelope;
365
+ try {
366
+ envelope = readEnvelope(path);
367
+ } catch (err) {
368
+ warn(`set aside malformed envelope ${path}: ${(err as Error).message}`);
369
+ continue;
370
+ }
371
+ if (delivered.has(envelope.id)) continue;
372
+ deliver(me, envelope, path);
373
+ }
374
+ if (names.length > 0) updateStatus();
375
+ }
376
+
377
+ // Synchronous on purpose: the caller reads the result on the payload as
378
+ // soon as `emit` returns, so nothing here may wait on an `await`.
379
+ pi.events.on(SEND, (data) => {
380
+ const payload = data as SendPayload;
381
+ if (typeof payload !== "object" || payload === null) return;
382
+ try {
383
+ if (address === undefined) throw new Error("no active session has a mailbox address");
384
+ 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 });
387
+ updateStatus();
388
+ } catch (err) {
389
+ payload.error = (err as Error).message;
390
+ }
391
+ });
392
+
393
+ // Synchronous on purpose: replies are emitted as `message:inbound` before
394
+ // `emit` returns, and only then is the payload marked scanned.
395
+ pi.events.on(SCAN, (data) => {
396
+ const payload = data as { scanned?: boolean };
397
+ if (typeof payload !== "object" || payload === null) return;
398
+ scan();
399
+ payload.scanned = true;
400
+ });
401
+
402
+ pi.on("session_start", async (_event, context) => {
403
+ stop();
404
+ ctx = context;
405
+ owed = new Map();
406
+ owedAsks = new Map();
407
+ seen = new Set();
408
+ held = new Set();
409
+ lastAnswer = undefined;
410
+ lastAborted = false;
411
+ lastError = undefined;
412
+ injected = 0;
413
+ hops = 0;
414
+ tools?.abandonAsk();
415
+ waitingOn = "";
416
+ configWarned = false;
417
+ const id = context.sessionManager.getSessionId();
418
+ if (!isAddress(id)) {
419
+ address = undefined;
420
+ warn(`session id ${JSON.stringify(id)} is not a usable address; the mailbox is off`);
421
+ return;
422
+ }
423
+ address = id;
424
+ ensureBoxes(id);
425
+ name = context.sessionManager.getSessionName?.() || undefined;
426
+ cwd = context.cwd;
427
+ state = context.isIdle?.() === false ? "busy" : "idle";
428
+ announce();
429
+ updateStatus();
430
+ // Mail already waiting is claimed on the next event-loop turn. The
431
+ // runner awaits each `session_start` handler in load order, so every
432
+ // later handler that does not wait on I/O runs first, and a listener
433
+ // that rebuilds its state there sees the mail. The watcher and poll timer keep delivering
434
+ // later mail. A microtask would still run before the next handler.
435
+ pendingScan = setImmediate(() => {
436
+ pendingScan = undefined;
437
+ scan();
438
+ });
439
+ pendingScan.unref?.();
440
+ try {
441
+ watcher = watch(boxPath(id, "new"), () => scan());
442
+ watcher.on("error", () => {});
443
+ watcher.unref?.();
444
+ } catch {
445
+ // polling alone still delivers
446
+ }
447
+ timer = setInterval(scan, POLL_MS);
448
+ timer.unref?.();
449
+ });
450
+
451
+ // An injected message counts as seen only once it enters the conversation;
452
+ // stopping a run drops queued follow-ups without a trace.
453
+ pi.on("message_end", async (event) => {
454
+ const message = event.message as { role?: string; customType?: string; details?: { id?: unknown } };
455
+ if (message?.role !== "custom" || message.customType !== CUSTOM_TYPE) return;
456
+ if (typeof message.details?.id === "string") seen.add(message.details.id);
457
+ });
458
+
459
+ // Typed text, RPC input and a prompt a command sends all come from the
460
+ // user, so each starts the chain again.
461
+ pi.on("input", async () => {
462
+ hops = 0;
463
+ });
464
+
465
+ pi.on("session_info_changed", async (event) => {
466
+ name = event.name || undefined;
467
+ announce();
468
+ });
469
+
470
+ pi.on("agent_start", async () => {
471
+ state = "busy";
472
+ announce();
473
+ });
474
+
475
+ // Esc aborts the run's signal. Stopped mid-text, the last message says
476
+ // `aborted`; stopped during a tool call, Pi 0.99.1 ends the run with an empty
477
+ // `error` message instead, so the signal is what tells a stop apart. The
478
+ // partial answer is then the last text the model wrote before the stop.
479
+ // An `error` message without an aborted signal is a real error (an API
480
+ // failure Pi's retries gave up on); its partial answer is found the same way.
481
+ pi.on("agent_end", async (event, context) => {
482
+ const last = lastAssistant(event.messages);
483
+ lastAborted = last?.stopReason === "aborted" || context.signal?.aborted === true;
484
+ lastError =
485
+ !lastAborted && last?.stopReason === "error"
486
+ ? typeof last.errorMessage === "string" && last.errorMessage.trim() !== ""
487
+ ? last.errorMessage.trim().replace(/\.+$/, "")
488
+ : "unknown error"
489
+ : undefined;
490
+ lastAnswer =
491
+ lastAborted || lastError !== undefined
492
+ ? lastAssistantText(event.messages)
493
+ : last === undefined
494
+ ? undefined
495
+ : assistantText(last);
496
+ });
497
+
498
+ // Reply only once Pi will not continue on its own: a retry, compaction or
499
+ // queued follow-up after `agent_end` would otherwise get a premature answer.
500
+ pi.on("agent_settled", async () => {
501
+ 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
+ // Answers carry the settled turn's count; the next turn counts afresh.
514
+ const answerHops = hops;
515
+ 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();
525
+ 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
+ lastAnswer = undefined;
549
+ lastAborted = false;
550
+ lastError = undefined;
551
+ injected = 0;
552
+ state = "idle";
553
+ announce();
554
+ if (me === undefined) return;
555
+ for (const reply of replies) {
556
+ const { to } = reply;
557
+ try {
558
+ // Answers are never refused by the hop limit.
559
+ send(me, to, reply.body, { kind: "reply", hops: answerHops, inReplyTo: reply.ids, status: reply.status });
560
+ } catch (err) {
561
+ warn(`could not reply to ${to}: ${(err as Error).message}`);
562
+ }
563
+ }
564
+ updateStatus();
565
+ });
566
+
567
+ pi.on("session_shutdown", async () => {
568
+ stop();
569
+ tools?.abandonAsk();
570
+ if (address !== undefined) {
571
+ try {
572
+ removeRecord(address);
573
+ } catch {
574
+ // a record left behind is dropped by the next reader once this process is gone
575
+ }
576
+ }
577
+ address = undefined;
578
+ });
579
+
580
+ tools = registerTools(pi, {
581
+ sent: () => updateStatus(),
582
+ waitingOn: (to) => {
583
+ waitingOn = to;
584
+ announce();
585
+ },
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
+ hops: () => hops,
607
+ configProblem: (message) => {
608
+ if (configWarned) return;
609
+ configWarned = true;
610
+ warn(message);
611
+ },
612
+ });
613
+
614
+ pi.registerCommand("mailbox", {
615
+ description: "Show this session's mailbox address and name, or send a request: /mailbox <name or id> <text>",
616
+ handler: async (args, context) => {
617
+ const me = context.sessionManager.getSessionId();
618
+ const trimmed = args.trim();
619
+ if (trimmed === "") {
620
+ const current = context.sessionManager.getSessionName?.() || undefined;
621
+ context.ui.notify(
622
+ `Mailbox address: ${me}\nName: ${current === undefined ? `none; other sessions see ${label({ address: me })} (set one with /name)` : current}`,
623
+ "info",
624
+ );
625
+ return;
626
+ }
627
+ const match = /^(\S+)\s+([\s\S]+)$/.exec(trimmed);
628
+ if (!match) {
629
+ context.ui.notify("Usage: /mailbox <name or id> <text>", "error");
630
+ return;
631
+ }
632
+ try {
633
+ const to = resolveTo(match[1], me);
634
+ const envelope = send(me, to, match[2].trim(), { kind: "request", hops: 0 });
635
+ context.ui.notify(`Sent ${envelope.id} to ${envelope.to}`, "info");
636
+ updateStatus();
637
+ } catch (err) {
638
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
639
+ }
640
+ },
641
+ });
642
+ }