pi-session-mail 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -6,6 +6,60 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-23
10
+
11
+ ### Changed
12
+
13
+ - `session_mail_ask` no longer holds the turn of a session a person is
14
+ watching (a TUI without `PI_UNATTENDED=1`): it returns at once with outcome
15
+ `background`, and the answer arrives as a message that starts a turn, so the
16
+ user can keep talking to the asker. Unattended sessions, such as delegates,
17
+ still wait for the answer. The label on an answer to an ask that is not
18
+ waiting now reads "is not waiting for it" instead of "has stopped waiting",
19
+ and the test pinning it changed with it.
20
+
21
+ ## [0.4.0] - 2026-10-20
22
+
23
+ ### Added
24
+
25
+ - Bare `/mailbox` opens a picker of this session's mail. Its title carries the
26
+ session's name (or short id) and short address, Tab cycles unread, awaiting,
27
+ read, sent and all (Shift+Tab goes back), and each row shows the first line, the other session, the
28
+ kind and a relative time, with the status in the all and sent views and the
29
+ running or closed marker on awaiting rows. Enter opens the whole mail and Esc
30
+ goes back, then closes. It opens on unread when there is unread mail, and
31
+ showing mail never marks it read.
32
+ - In the `/mailbox` picker, `d` deletes the selected read or sent item, or
33
+ drops a request still awaiting a reply, after a confirmation showing its
34
+ first line. Deleting a read reply also deletes the `sent/` copies of the
35
+ requests it answered, so they do not reappear as awaiting. The picker reopens
36
+ on the same filter with the next row selected, or the same row when the
37
+ confirmation was cancelled. Unread mail cannot be deleted, and the hint line
38
+ leaves `d` out there.
39
+ - `/mailbox:send` lists the other running sessions, asks for the text and
40
+ sends it as a message, which expects no answer.
41
+ - `/mailbox:prune` removes closed sessions' mailbox folders without the age
42
+ limit.
43
+
44
+ ### Changed
45
+
46
+ - Bare `/mailbox` no longer prints the address and name notice; the picker's
47
+ title carries them. Its tests now open the picker and assert the title.
48
+ - The footer shows only `📬 N unread`: mail that reached this session but has
49
+ not entered its conversation yet. It clears as each message enters, not when
50
+ the run settles. The `read` and `awaiting` counts are gone from it, because
51
+ `read` only grew and `awaiting` stayed up for requests a stopped session
52
+ still held. The status tests now pin the new footer.
53
+
54
+ ### Removed
55
+
56
+ - The typed `/mailbox <to> <text>` and `/mailbox prune` forms (breaking).
57
+ `/mailbox:send` and `/mailbox:prune` replace them, and any argument to
58
+ `/mailbox` now shows a usage notice naming the two new commands. The tests
59
+ of the typed send changed deliberately: delivery tests now use a harness
60
+ helper that sends a request with hops 0 through `send()`, and tests of name
61
+ and id resolution moved to `session_mail_send`.
62
+
9
63
  ## [0.3.0] - 2026-10-19
10
64
 
11
65
  ### Added
@@ -134,7 +188,9 @@ author's dotfiles; the changes below are against that copy.
134
188
  - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
135
189
  published file list.
136
190
 
137
- [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...HEAD
191
+ [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.5.0...HEAD
192
+ [0.5.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...v0.5.0
193
+ [0.4.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...v0.4.0
138
194
  [0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
139
195
  [0.2.2]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.1...v0.2.2
140
196
  [0.2.1]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  A file mailbox between Pi sessions on the same machine.
4
4
 
5
- Each Pi session gets an address and an inbox. You send a request to another
6
- running session with `/mailbox`, and the model lists, asks, answers and sends
7
- through four `session_mail_*` tools. Mail waits on disk until the recipient
8
- starts or resumes, and the recipient's final answer goes back to the sender
9
- automatically.
5
+ Each Pi session gets an address and an inbox. You send a message to another
6
+ running session with `/mailbox:send`, and the model lists, asks, answers and
7
+ sends through four `session_mail_*` tools. Mail waits on disk until the
8
+ recipient starts or resumes, and the recipient's final answer goes back to the
9
+ sender automatically.
10
10
 
11
11
  ## Install
12
12
 
@@ -27,28 +27,46 @@ pi install git:github.com/gvanderclay/pi-session-mail
27
27
 
28
28
  ## First use
29
29
 
30
- In a Pi session, show its address and name:
30
+ In a Pi session, open a picker of this session's mail:
31
31
 
32
32
  ```text
33
33
  /mailbox
34
34
  ```
35
35
 
36
+ Its title names the session, its short address and the filter. The picker opens
37
+ on unread when there is unread mail, and on all otherwise. Tab cycles the filter
38
+ through unread, awaiting, read, sent and all, and Shift+Tab goes back. Each row shows the first line of
39
+ the mail, the other session (`from` for mail it received, `to` for mail it
40
+ sent), the kind and how long ago it was sent; the all and sent views also show
41
+ its status, and an awaiting row says whether its target is still running or
42
+ has closed. Enter opens the whole mail: the other session's name and full
43
+ address, the kind, the id, the absolute time, what it answers and the full
44
+ text. Esc goes back to the list, and Esc there closes the picker. `d` deletes
45
+ the selected read or sent item, or drops a request still awaiting a reply,
46
+ after a confirmation showing its first line; deleting a read reply also deletes
47
+ the `sent/` copies of the requests it answered. The picker reopens on the same
48
+ filter with the row that took the deleted one's place selected. `d` is left out
49
+ on unread mail. Showing mail never marks it read, so the footer's count stays
50
+ until the model sees it.
51
+
36
52
  The session's id is its address. Other sessions see its Pi session name (set it
37
53
  with `/name`), or the first 8 characters of its id when it has none.
38
54
 
39
- In another session, send a request to that name or to an id prefix of at least
40
- 8 characters:
55
+ In another session, send a message by picking a running session from a list
56
+ and then typing the text:
41
57
 
42
58
  ```text
43
- /mailbox <to> <text>
59
+ /mailbox:send
44
60
  ```
45
61
 
46
- The sender sees `Sent <id> to <address>`. The recipient's model receives the
47
- text and works on it, and its last answer comes back to the sender as a reply
48
- when that run settles. [Addressing](#addressing) lists how `<to>` resolves.
62
+ The sender sees `Sent <id> to <address>`. A message wakes the recipient's model
63
+ and expects no answer. The model sends requests, asks and answers through the
64
+ `session_mail_*` tools, and a request's last answer comes back to the sender as
65
+ a reply when that run settles.
49
66
 
50
- The footer shows `✉ N pending · N read · N awaiting`, non-zero counts only.
51
- `awaiting` counts requests and asks with no answer yet, never messages.
67
+ The footer shows `📬 N unread` while mail has reached this session but not yet
68
+ entered its conversation, such as mail queued behind a busy run; it is hidden
69
+ at zero.
52
70
 
53
71
  ## How mail works
54
72
 
@@ -113,13 +131,13 @@ The model gets four tools:
113
131
  | Tool | Parameters | What it does |
114
132
  | --- | --- | --- |
115
133
  | `session_mail_list` | none | Lists every running session, in every agent directory: its name (or short id, the first 8 characters of its id, when it has no name), full id, working directory, idle or busy state and whom it is waiting on, and marks the calling session. |
116
- | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`) and waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the [hop limit](#hop-limit), and when `to` does not resolve, is ambiguous or is this session. |
134
+ | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the [hop limit](#hop-limit), and when `to` does not resolve, is ambiguous or is this session. |
117
135
  | `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done` and the turn's hop count, without ending the run; the asker's `session_mail_ask` returns with it. The answer at settle then leaves that ask out, while the sender's other requests and asks are still answered. Refused, sending nothing, for an ask already answered (by this tool or at settle), for a request (answered at settle), a message or a reply, for an id that never reached this session, and for an empty text. |
118
136
  | `session_mail_send` | `to`, `message` | Writes a message (`kind: "message"`) and returns its id. To a running session it is delivered at once; to a closed session, by full id, it waits in that session's inbox and the result says so. Refused when `to` does not resolve, is ambiguous or is this session, when the text is empty, and at the [hop limit](#hop-limit). |
119
137
 
120
138
  ## Addressing
121
139
 
122
- A `to`, typed after `/mailbox` or given to a tool, resolves as follows:
140
+ A `to` given to a tool resolves as follows:
123
141
 
124
142
  1. A running session's address, or any full session id (a UUID), resolves as
125
143
  given, so a closed session is still reached by its full id and its mail
@@ -137,9 +155,9 @@ A `to`, typed after `/mailbox` or given to a tool, resolves as follows:
137
155
 
138
156
  | Kind | Written by | Delivered as | Answered |
139
157
  | --- | --- | --- | --- |
140
- | `request` | `/mailbox <to> <text>`, `message:send` | `triggerTurn`, `deliverAs: "steer"` | yes, when a run completes; held while the user has taken over |
158
+ | `request` | `message:send` | `triggerTurn`, `deliverAs: "steer"` | yes, when a run completes; held while the user has taken over |
141
159
  | `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, when the run settles |
142
- | `message` | `session_mail_send` | `triggerTurn`, `deliverAs: "steer"` | never |
160
+ | `message` | `session_mail_send`, `/mailbox:send` | `triggerTurn`, `deliverAs: "steer"` | never |
143
161
  | `reply` | the answering session | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
144
162
 
145
163
  Mail for the model wakes an idle session, and reaches a busy one at its next
@@ -150,9 +168,12 @@ listener takes it over, as pi-squire's `delegate` does for its tasks.
150
168
 
151
169
  A reply to this session's waiting ask goes to the waiting tool call as its
152
170
  result: it is neither injected nor emitted on `message:inbound`. A reply to an
153
- ask that is no longer waiting (it timed out, the user stopped it, or the
154
- session restarted) is delivered like a message: it starts a turn, and its label
155
- says the ask stopped waiting.
171
+ ask that is not waiting (a background ask, or one that timed out, that the
172
+ user stopped, or whose session restarted) is delivered like a message: it
173
+ starts a turn, and its label says the ask is not waiting for it. An
174
+ unattended session waits rather than asking in the background because ending
175
+ its turn ends its work: a delegate's settle is its result, and print mode
176
+ exits.
156
177
 
157
178
  Every injected message opens with a neutral label: `[mailbox] From <name>
158
179
  (<full id>, working in <cwd>), another Pi session on this machine.` The short
@@ -169,8 +190,8 @@ label depends on the kind:
169
190
  `session_mail_send` to the sender's full id sends it.
170
191
  - A reply's label names the requests it answers and, when its status is not
171
192
  `done`, says it is not an answer. A reply to a request also says the user
172
- made that request, typing it with `/mailbox` or through an extension such as
173
- `delegate`, since only the user makes requests.
193
+ made that request through an extension such as `delegate`, since only the
194
+ user makes requests.
174
195
 
175
196
  The label is built from fields any process running as you can write: the
176
197
  envelope's `id`, `status` and the ids in `in_reply_to`, and the sender's name
@@ -249,11 +270,10 @@ are refused before anything is written once the count has reached the limit (5
249
270
  unless `session-mail.json` says otherwise; see
250
271
  [Configuration](#configuration)). The refusal says that a person typing in
251
272
  either session starts the count again. Automatic answers carry the count and
252
- are never refused. Requests from `/mailbox` carry 0 and are never refused:
253
- their sender acts for the user. A `message:send` emitted while the session is
254
- idle carries 0 too. One emitted during a run carries that run's count and is
255
- not refused at send time; the session that receives it counts it like any
256
- other mail, so a chain that reaches the limit is refused there as a loop.
273
+ are never refused. A `message:send` is never refused at send time: its consumer
274
+ acts for the user. Emitted while the session is idle it carries 0; during a run
275
+ it carries that run's count, and the session that receives it counts it like
276
+ any other mail, so a chain that reaches the limit is refused there as a loop.
257
277
  The limit is a loop guard, not a security boundary: a hand-written envelope
258
278
  can claim any `hops`.
259
279
 
@@ -272,11 +292,10 @@ copies of sent requests in `sent/` go too, not only the empty `new/`. Only real
272
292
  folders named like an address are considered; `running/`, symlinks and other
273
293
  names are left alone. The work runs after `session_start` returns.
274
294
 
275
- To clean up at once, type `/mailbox prune`. It applies the same rules without
295
+ To clean up at once, type `/mailbox:prune`. It applies the same rules without
276
296
  the age limit, so it removes the folder (with its `cur/` and `sent/`) of every
277
297
  closed session with no unread mail, including one that closed a minute ago. It
278
298
  reports `Removed <N> mailbox folders of closed sessions`.
279
- `/mailbox prune <text>` is still a message to a session named `prune`.
280
299
 
281
300
  ## Configuration
282
301
 
@@ -462,7 +481,8 @@ root, and other extensions depend on its hooks, so these are stable:
462
481
  and a reply's statuses (`done`, `stopped`, `failed`); see
463
482
  [Envelopes](#envelopes).
464
483
  - The names of the tools (`session_mail_list`, `session_mail_send`,
465
- `session_mail_ask`, `session_mail_reply`) and of the `/mailbox` command.
484
+ `session_mail_ask`, `session_mail_reply`) and of the `/mailbox`,
485
+ `/mailbox:send` and `/mailbox:prune` commands.
466
486
  - The keys of `session-mail.json`, today `hopLimit` and `pruneAfterDays`.
467
487
 
468
488
  Changing any of them needs a major version from 1.0 on, and a minor version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mail",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "A file mailbox between Pi sessions on the same machine",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -45,11 +45,13 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*",
48
49
  "typebox": "*"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@biomejs/biome": "2.5.14",
52
53
  "@earendil-works/pi-coding-agent": "1.0.0",
54
+ "@earendil-works/pi-tui": "1.0.0",
53
55
  "@types/node": "26.6.3",
54
56
  "typebox": "1.3.27",
55
57
  "typescript": "7.0.2"
package/src/index.ts CHANGED
@@ -7,10 +7,11 @@
7
7
  // Each running session also announces its name, working directory and idle or
8
8
  // busy state in `<mail root>/running/<address>.json` (`running.ts`), which
9
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
10
+ // session by its Pi session name or short id. `/mailbox` opens a picker of
11
+ // this session's mail, `/mailbox:send` sends a message and `/mailbox:prune`
12
+ // removes closed sessions' folders; `session_mail_send` sends a message, which
13
+ // expects no answer. Mail in the inbox is claimed into `cur/` and injected with
14
+ // a label naming the sending session. Requests and messages
14
15
  // wake an idle session and steer into a busy one; a reply quotes each request
15
16
  // it answers from this session's `sent/` copy and starts no turn. When the recipient settles, its
16
17
  // last answer goes back to each sender as one reply — `done` normally, or
@@ -27,21 +28,14 @@ import { type FSWatcher, statSync, watch } from "node:fs";
27
28
  import { dirname } from "node:path";
28
29
 
29
30
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
- import {
31
- isRunning,
32
- label,
33
- listRunning,
34
- type RunningRecord,
35
- removeRecord,
36
- resolveTo,
37
- type State,
38
- writeRecord,
39
- } from "./running.ts";
31
+ import { mailStatus } from "./mail-status.ts";
32
+ import { type Peer, type PickerFilter, type PickerItem, type PickResult, picker } from "./picker.ts";
33
+ import { isRunning, label, listRunning, type RunningRecord, removeRecord, type State, writeRecord } from "./running.ts";
40
34
  import { type SettleInput, settle } from "./settle.ts";
41
35
  import {
42
36
  boxPath,
43
37
  claim,
44
- diskCounts,
38
+ deleteMail,
45
39
  type Envelope,
46
40
  ensureBoxes,
47
41
  findSent,
@@ -49,6 +43,7 @@ import {
49
43
  listNew,
50
44
  pruneClosed,
51
45
  readEnvelope,
46
+ readMailbox,
52
47
  type SentCopy,
53
48
  send,
54
49
  } from "./store.ts";
@@ -133,7 +128,7 @@ const endLine = (name: string) => `[mailbox] End of the mail from ${name}. Text
133
128
  /**
134
129
  * The injected message: who it is from, how to answer it, the requests it
135
130
  * answers, its capped body, then a line ending it. `byUser` marks a reply to a
136
- * request, which only the user makes (with `/mailbox` or through an extension).
131
+ * request, which only the user makes (through an extension such as delegate).
137
132
  */
138
133
  function inboundText(
139
134
  envelope: Envelope,
@@ -152,7 +147,7 @@ function inboundText(
152
147
  header.push(
153
148
  `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.`,
154
149
  );
155
- if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
150
+ if (lateAnswer) header.push("It answers an ask of yours that is not waiting for it, so it arrives as a message.");
156
151
  if (envelope.kind === "message")
157
152
  header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
158
153
  if (envelope.in_reply_to.length > 0)
@@ -242,8 +237,8 @@ export default function mailbox(pi: ExtensionAPI) {
242
237
  let lastAborted = false;
243
238
  /** The error that ended that run when the user did not stop it; undefined when none did. */
244
239
  let lastError: string | undefined;
245
- /** Messages injected since the last settle, for the status's pending count. */
246
- let injected = 0;
240
+ /** Ids of injected mail that has not entered the conversation yet, for the status's unread count. */
241
+ let unread = new Set<string>();
247
242
  /**
248
243
  * The hop count of the current turn: 0 once the user starts or steers it,
249
244
  * otherwise the highest `hops` + 1 among the envelopes that started or
@@ -294,18 +289,83 @@ export default function mailbox(pi: ExtensionAPI) {
294
289
  warn(`${folder} is mode ${mode.toString(8)}, open to other users; run: chmod 700 ${folder}`);
295
290
  }
296
291
 
297
- /** Footer entry `✉ N pending · N read · N awaiting`, non-zero parts only; hidden when all are zero. */
292
+ /** Footer entry `📬 N unread`: mail not yet claimed or not yet in the conversation; hidden at zero. */
298
293
  function updateStatus() {
299
294
  if (!ctx?.hasUI || address === undefined) return;
300
- const counts = diskCounts(address);
301
- const parts = [
302
- [counts.unclaimed + injected, "pending"],
303
- [counts.read, "read"],
304
- [counts.awaiting, "awaiting"],
305
- ]
306
- .filter(([n]) => n !== 0)
307
- .map(([n, label]) => `${n} ${label}`);
308
- ctx.ui.setStatus(STATUS_KEY, parts.length > 0 ? `✉ ${parts.join(" · ")}` : undefined);
295
+ const count = listNew(address).length + unread.size;
296
+ ctx.ui.setStatus(STATUS_KEY, count > 0 ? `📬 ${count} unread` : undefined);
297
+ }
298
+
299
+ /**
300
+ * Open the picker on this session's mail. It opens on unread when anything is
301
+ * unread, otherwise on all; `d` deletes the selected read or sent item after a
302
+ * confirmation, and the picker reopens on the same filter with the row that
303
+ * took the deleted one's place selected. It never touches the unread set, so
304
+ * the footer keeps saying the model has not seen that mail yet.
305
+ */
306
+ async function openMailbox(me: string, context: ExtensionContext) {
307
+ let filter: PickerFilter | undefined;
308
+ let selectedId: string | undefined;
309
+ let selectedIndex = 0;
310
+ for (;;) {
311
+ let result: PickResult;
312
+ try {
313
+ const files = readMailbox(me);
314
+ const unreadable = files.filter((file) => file.error !== undefined).length;
315
+ if (unreadable > 0) context.ui.notify(`mailbox: ${unreadable} files could not be read`, "warning");
316
+ const records = listRunning();
317
+ const items: PickerItem[] = [];
318
+ for (const file of mailStatus(files, unread)) {
319
+ const envelope = file.envelope;
320
+ if (envelope === undefined || file.status === undefined) continue;
321
+ items.push({
322
+ id: envelope.id,
323
+ box: file.box,
324
+ from: envelope.from,
325
+ to: envelope.to,
326
+ kind: envelope.kind,
327
+ inReplyTo: envelope.in_reply_to,
328
+ ts: envelope.ts,
329
+ body: envelope.body,
330
+ status: file.status,
331
+ });
332
+ }
333
+ const labels = new Map(records.map((record) => [record.address, label(record)]));
334
+ const running = new Set(records.map((record) => record.address));
335
+ const peers = new Map<string, Peer>();
336
+ for (const item of items)
337
+ for (const target of [item.from, item.to]) {
338
+ if (peers.has(target)) continue;
339
+ peers.set(target, {
340
+ label: labels.get(target) ?? label({ address: target }),
341
+ running: running.has(target),
342
+ });
343
+ }
344
+ const current = context.sessionManager.getSessionName?.() || undefined;
345
+ const short = me.slice(0, 8);
346
+ const title = `Mailbox · ${current === undefined ? short : `${current} · ${short}`}`;
347
+ filter ??= items.some((item) => item.status === "unread") ? "unread" : "all";
348
+ result = await context.ui.custom<PickResult>(
349
+ picker({ items, peers, title, now: Date.now(), filter, selectedId, selectedIndex }),
350
+ );
351
+ } catch (err) {
352
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
353
+ return;
354
+ }
355
+ if (result.action === "close") return;
356
+ try {
357
+ if (await context.ui.confirm("Delete this mail?", result.firstLine)) {
358
+ const file = readMailbox(me).find((f) => f.box === result.box && f.envelope?.id === result.id);
359
+ if (file !== undefined) deleteMail(me, file.path);
360
+ }
361
+ } catch (err) {
362
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
363
+ }
364
+ // A cancelled confirm leaves the item in place, so the picker reselects it by id.
365
+ filter = result.filter;
366
+ selectedId = result.id;
367
+ selectedIndex = result.index;
368
+ }
309
369
  }
310
370
 
311
371
  /** A first scan deferred to the next event-loop turn; see `session_start`. */
@@ -328,7 +388,7 @@ export default function mailbox(pi: ExtensionAPI) {
328
388
  // The answer to a waiting ask is that tool call's result, and nothing else.
329
389
  if (tools?.takeAnswer(envelope)) return;
330
390
  const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
331
- // An answer to an ask that stopped waiting is delivered like a message.
391
+ // An answer to an ask that is not waiting (a background ask, or one that stopped) is delivered like a message.
332
392
  const lateAnswer = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "ask");
333
393
  const wakes = envelope.kind !== "reply" || lateAnswer;
334
394
  const byUser = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "request");
@@ -347,7 +407,8 @@ export default function mailbox(pi: ExtensionAPI) {
347
407
  seen.add(envelope.id);
348
408
  return;
349
409
  }
350
- injected++;
410
+ // Pi appends a reply to an idle session at once and fires no `message_end` for it.
411
+ if (wakes || state === "busy") unread.add(envelope.id);
351
412
  pi.sendMessage(
352
413
  {
353
414
  customType: CUSTOM_TYPE,
@@ -402,7 +463,6 @@ export default function mailbox(pi: ExtensionAPI) {
402
463
  // fresh one. It is never refused here: the receiving side enforces the limit.
403
464
  const hopsNow = state === "busy" ? hops : 0;
404
465
  payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: hopsNow });
405
- updateStatus();
406
466
  } catch (err) {
407
467
  payload.error = (err as Error).message;
408
468
  }
@@ -443,7 +503,7 @@ export default function mailbox(pi: ExtensionAPI) {
443
503
  lastAnswer = undefined;
444
504
  lastAborted = false;
445
505
  lastError = undefined;
446
- injected = 0;
506
+ unread = new Set();
447
507
  hops = 0;
448
508
  tools?.abandonAsk();
449
509
  waitingOn = "";
@@ -498,7 +558,9 @@ export default function mailbox(pi: ExtensionAPI) {
498
558
  pi.on("message_end", async (event) => {
499
559
  const message = event.message as { role?: string; customType?: string; details?: { id?: unknown } };
500
560
  if (message?.role !== "custom" || message.customType !== CUSTOM_TYPE) return;
501
- if (typeof message.details?.id === "string") seen.add(message.details.id);
561
+ if (typeof message.details?.id !== "string") return;
562
+ seen.add(message.details.id);
563
+ if (unread.delete(message.details.id)) updateStatus();
502
564
  });
503
565
 
504
566
  // Typed text, RPC input and a prompt a command sends all come from the
@@ -566,7 +628,7 @@ export default function mailbox(pi: ExtensionAPI) {
566
628
  lastAnswer = undefined;
567
629
  lastAborted = false;
568
630
  lastError = undefined;
569
- injected = 0;
631
+ unread = new Set();
570
632
  state = "idle";
571
633
  announce();
572
634
  if (me === undefined) return;
@@ -597,7 +659,6 @@ export default function mailbox(pi: ExtensionAPI) {
597
659
  });
598
660
 
599
661
  tools = registerTools(pi, {
600
- sent: () => updateStatus(),
601
662
  waitingOn: (to) => {
602
663
  waitingOn = to;
603
664
  announce();
@@ -608,41 +669,77 @@ export default function mailbox(pi: ExtensionAPI) {
608
669
 
609
670
  pi.registerCommand("mailbox", {
610
671
  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",
672
+ "Open this session's mail: tab changes the filter, enter opens an item, esc closes. Send a message with /mailbox:send; remove closed sessions' folders with /mailbox:prune",
612
673
  handler: async (args, context) => {
613
674
  const me = address;
614
675
  if (me === undefined) {
615
676
  context.ui.notify("mailbox: this session has no mailbox address; the mailbox is off", "error");
616
677
  return;
617
678
  }
618
- const trimmed = args.trim();
619
- if (trimmed === "") {
620
- const current = context.sessionManager.getSessionName?.() || undefined;
679
+ if (args.trim() !== "") {
621
680
  context.ui.notify(
622
- `Mailbox address: ${me}\nName: ${current === undefined ? `none; other sessions see ${label({ address: me })} (set one with /name)` : current}`,
623
- "info",
681
+ "Usage: /mailbox:send to send a message, or /mailbox:prune to remove closed sessions' folders",
682
+ "error",
624
683
  );
625
684
  return;
626
685
  }
627
- if (trimmed === "prune") {
628
- try {
629
- const removed = pruneClosed({ own: me, isRunning });
630
- context.ui.notify(`Removed ${removed} mailbox folders of closed sessions`, "info");
631
- } catch (err) {
632
- context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
633
- }
686
+ await openMailbox(me, context);
687
+ },
688
+ });
689
+
690
+ pi.registerCommand("mailbox:send", {
691
+ description:
692
+ "Send a message to another running session: pick it from a list, then type the text. A message expects no answer.",
693
+ handler: async (_args, context) => {
694
+ const me = address;
695
+ if (me === undefined) {
696
+ context.ui.notify("mailbox: this session has no mailbox address; the mailbox is off", "error");
634
697
  return;
635
698
  }
636
- const match = /^(\S+)\s+([\s\S]+)$/.exec(trimmed);
637
- if (!match) {
638
- context.ui.notify("Usage: /mailbox <name or id> <text>", "error");
699
+ let running: RunningRecord[];
700
+ try {
701
+ running = listRunning().filter((record) => record.address !== me);
702
+ } catch (err) {
703
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
639
704
  return;
640
705
  }
706
+ if (running.length === 0) {
707
+ context.ui.notify("No other session is running", "info");
708
+ return;
709
+ }
710
+ const base = (record: RunningRecord) => `${label(record)} (${record.cwd})`;
711
+ // `select` returns the option text, so two same-named sessions in one folder need the full id to tell them apart.
712
+ const options = running.map((record) =>
713
+ running.some((other) => other !== record && base(other) === base(record))
714
+ ? `${base(record)} ${record.address}`
715
+ : base(record),
716
+ );
717
+ const picked = await context.ui.select("Send a message to", options);
718
+ if (picked === undefined) return;
719
+ const target = running[options.indexOf(picked)];
720
+ if (target === undefined) return;
721
+ const text = (await context.ui.input("Message", "type a message"))?.trim();
722
+ if (text === undefined || text === "") return;
641
723
  try {
642
- const to = resolveTo(match[1], me);
643
- const envelope = send(me, to, match[2].trim(), { kind: "request", hops: 0 });
724
+ const envelope = send(me, target.address, text, { kind: "message", hops: 0 });
644
725
  context.ui.notify(`Sent ${envelope.id} to ${envelope.to}`, "info");
645
- updateStatus();
726
+ } catch (err) {
727
+ context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
728
+ }
729
+ },
730
+ });
731
+
732
+ pi.registerCommand("mailbox:prune", {
733
+ description: "Remove closed sessions' mailbox folders, without the age limit",
734
+ handler: async (_args, context) => {
735
+ const me = address;
736
+ if (me === undefined) {
737
+ context.ui.notify("mailbox: this session has no mailbox address; the mailbox is off", "error");
738
+ return;
739
+ }
740
+ try {
741
+ const removed = pruneClosed({ own: me, isRunning });
742
+ context.ui.notify(`Removed ${removed} mailbox folders of closed sessions`, "info");
646
743
  } catch (err) {
647
744
  context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
648
745
  }