pi-session-mail 0.3.0 → 0.4.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 +44 -1
- package/README.md +44 -27
- package/package.json +3 -1
- package/src/index.ts +150 -53
- package/src/mail-status.ts +68 -0
- package/src/picker.ts +285 -0
- package/src/store.ts +150 -38
- package/src/tools.ts +0 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,48 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.4.0] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Bare `/mailbox` opens a picker of this session's mail. Its title carries the
|
|
14
|
+
session's name (or short id) and short address, Tab cycles unread, awaiting,
|
|
15
|
+
read, sent and all (Shift+Tab goes back), and each row shows the first line, the other session, the
|
|
16
|
+
kind and a relative time, with the status in the all and sent views and the
|
|
17
|
+
running or closed marker on awaiting rows. Enter opens the whole mail and Esc
|
|
18
|
+
goes back, then closes. It opens on unread when there is unread mail, and
|
|
19
|
+
showing mail never marks it read.
|
|
20
|
+
- In the `/mailbox` picker, `d` deletes the selected read or sent item, or
|
|
21
|
+
drops a request still awaiting a reply, after a confirmation showing its
|
|
22
|
+
first line. Deleting a read reply also deletes the `sent/` copies of the
|
|
23
|
+
requests it answered, so they do not reappear as awaiting. The picker reopens
|
|
24
|
+
on the same filter with the next row selected, or the same row when the
|
|
25
|
+
confirmation was cancelled. Unread mail cannot be deleted, and the hint line
|
|
26
|
+
leaves `d` out there.
|
|
27
|
+
- `/mailbox:send` lists the other running sessions, asks for the text and
|
|
28
|
+
sends it as a message, which expects no answer.
|
|
29
|
+
- `/mailbox:prune` removes closed sessions' mailbox folders without the age
|
|
30
|
+
limit.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Bare `/mailbox` no longer prints the address and name notice; the picker's
|
|
35
|
+
title carries them. Its tests now open the picker and assert the title.
|
|
36
|
+
- The footer shows only `📬 N unread`: mail that reached this session but has
|
|
37
|
+
not entered its conversation yet. It clears as each message enters, not when
|
|
38
|
+
the run settles. The `read` and `awaiting` counts are gone from it, because
|
|
39
|
+
`read` only grew and `awaiting` stayed up for requests a stopped session
|
|
40
|
+
still held. The status tests now pin the new footer.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- The typed `/mailbox <to> <text>` and `/mailbox prune` forms (breaking).
|
|
45
|
+
`/mailbox:send` and `/mailbox:prune` replace them, and any argument to
|
|
46
|
+
`/mailbox` now shows a usage notice naming the two new commands. The tests
|
|
47
|
+
of the typed send changed deliberately: delivery tests now use a harness
|
|
48
|
+
helper that sends a request with hops 0 through `send()`, and tests of name
|
|
49
|
+
and id resolution moved to `session_mail_send`.
|
|
50
|
+
|
|
9
51
|
## [0.3.0] - 2026-10-19
|
|
10
52
|
|
|
11
53
|
### Added
|
|
@@ -134,7 +176,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
134
176
|
- CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
|
|
135
177
|
published file list.
|
|
136
178
|
|
|
137
|
-
[Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.
|
|
179
|
+
[Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...HEAD
|
|
180
|
+
[0.4.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...v0.4.0
|
|
138
181
|
[0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
|
|
139
182
|
[0.2.2]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.1...v0.2.2
|
|
140
183
|
[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
|
|
6
|
-
running session with `/mailbox`, and the model lists, asks, answers and
|
|
7
|
-
through four `session_mail_*` tools. Mail waits on disk until the
|
|
8
|
-
starts or resumes, and the recipient's final answer goes back to the
|
|
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,
|
|
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
|
|
40
|
-
|
|
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
|
|
59
|
+
/mailbox:send
|
|
44
60
|
```
|
|
45
61
|
|
|
46
|
-
The sender sees `Sent <id> to <address>`.
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
51
|
-
|
|
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
|
|
|
@@ -119,7 +137,7 @@ The model gets four tools:
|
|
|
119
137
|
|
|
120
138
|
## Addressing
|
|
121
139
|
|
|
122
|
-
A `to
|
|
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` |
|
|
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
|
|
@@ -169,8 +187,8 @@ label depends on the kind:
|
|
|
169
187
|
`session_mail_send` to the sender's full id sends it.
|
|
170
188
|
- A reply's label names the requests it answers and, when its status is not
|
|
171
189
|
`done`, says it is not an answer. A reply to a request also says the user
|
|
172
|
-
made that request
|
|
173
|
-
|
|
190
|
+
made that request through an extension such as `delegate`, since only the
|
|
191
|
+
user makes requests.
|
|
174
192
|
|
|
175
193
|
The label is built from fields any process running as you can write: the
|
|
176
194
|
envelope's `id`, `status` and the ids in `in_reply_to`, and the sender's name
|
|
@@ -249,11 +267,10 @@ are refused before anything is written once the count has reached the limit (5
|
|
|
249
267
|
unless `session-mail.json` says otherwise; see
|
|
250
268
|
[Configuration](#configuration)). The refusal says that a person typing in
|
|
251
269
|
either session starts the count again. Automatic answers carry the count and
|
|
252
|
-
are never refused.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
other mail, so a chain that reaches the limit is refused there as a loop.
|
|
270
|
+
are never refused. A `message:send` is never refused at send time: its consumer
|
|
271
|
+
acts for the user. Emitted while the session is idle it carries 0; during a run
|
|
272
|
+
it carries that run's count, and the session that receives it counts it like
|
|
273
|
+
any other mail, so a chain that reaches the limit is refused there as a loop.
|
|
257
274
|
The limit is a loop guard, not a security boundary: a hand-written envelope
|
|
258
275
|
can claim any `hops`.
|
|
259
276
|
|
|
@@ -272,11 +289,10 @@ copies of sent requests in `sent/` go too, not only the empty `new/`. Only real
|
|
|
272
289
|
folders named like an address are considered; `running/`, symlinks and other
|
|
273
290
|
names are left alone. The work runs after `session_start` returns.
|
|
274
291
|
|
|
275
|
-
To clean up at once, type `/mailbox
|
|
292
|
+
To clean up at once, type `/mailbox:prune`. It applies the same rules without
|
|
276
293
|
the age limit, so it removes the folder (with its `cur/` and `sent/`) of every
|
|
277
294
|
closed session with no unread mail, including one that closed a minute ago. It
|
|
278
295
|
reports `Removed <N> mailbox folders of closed sessions`.
|
|
279
|
-
`/mailbox prune <text>` is still a message to a session named `prune`.
|
|
280
296
|
|
|
281
297
|
## Configuration
|
|
282
298
|
|
|
@@ -462,7 +478,8 @@ root, and other extensions depend on its hooks, so these are stable:
|
|
|
462
478
|
and a reply's statuses (`done`, `stopped`, `failed`); see
|
|
463
479
|
[Envelopes](#envelopes).
|
|
464
480
|
- The names of the tools (`session_mail_list`, `session_mail_send`,
|
|
465
|
-
`session_mail_ask`, `session_mail_reply`) and of the `/mailbox
|
|
481
|
+
`session_mail_ask`, `session_mail_reply`) and of the `/mailbox`,
|
|
482
|
+
`/mailbox:send` and `/mailbox:prune` commands.
|
|
466
483
|
- The keys of `session-mail.json`, today `hopLimit` and `pruneAfterDays`.
|
|
467
484
|
|
|
468
485
|
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
|
+
"version": "0.4.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`
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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 (
|
|
131
|
+
* request, which only the user makes (through an extension such as delegate).
|
|
137
132
|
*/
|
|
138
133
|
function inboundText(
|
|
139
134
|
envelope: Envelope,
|
|
@@ -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
|
-
/**
|
|
246
|
-
let
|
|
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
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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`. */
|
|
@@ -347,7 +407,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
347
407
|
seen.add(envelope.id);
|
|
348
408
|
return;
|
|
349
409
|
}
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
619
|
-
if (trimmed === "") {
|
|
620
|
-
const current = context.sessionManager.getSessionName?.() || undefined;
|
|
679
|
+
if (args.trim() !== "") {
|
|
621
680
|
context.ui.notify(
|
|
622
|
-
|
|
623
|
-
"
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// What state each piece of a session's mail is in. Pure: it reads no files and
|
|
2
|
+
// no clock. The disk reader supplies the files and the command handler the
|
|
3
|
+
// in-memory unread set; `picker.ts` shows the statuses.
|
|
4
|
+
//
|
|
5
|
+
// Unread is what the footer counts: files still in `new/`, plus mail claimed
|
|
6
|
+
// into `cur/` that has not entered the conversation. Read is the rest of
|
|
7
|
+
// `cur/`. A sent request or ask is awaiting when no reply in `cur/` names it,
|
|
8
|
+
// and answered when one does. Sent messages expect no answer.
|
|
9
|
+
//
|
|
10
|
+
// The counting rules use the raw parsed JSON, not the validated envelope, so
|
|
11
|
+
// they stay the same as older copies that shared this mail root. A file that
|
|
12
|
+
// is not a valid envelope can still count; the picker shows only valid ones.
|
|
13
|
+
|
|
14
|
+
/** The boxes a session's mail lives in; `sent/` holds its outgoing copies. */
|
|
15
|
+
export type MailBox = "new" | "cur" | "sent";
|
|
16
|
+
|
|
17
|
+
/** What a status needs from an envelope. */
|
|
18
|
+
export type MailEnvelope = {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: string;
|
|
21
|
+
in_reply_to: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** One file in the mailbox; `envelope` is absent when the file is not a valid envelope. */
|
|
25
|
+
export type MailFile<E extends MailEnvelope = MailEnvelope> = {
|
|
26
|
+
box: MailBox;
|
|
27
|
+
/** The file's parsed JSON, absent when it cannot be read. */
|
|
28
|
+
parsed?: unknown;
|
|
29
|
+
envelope?: E;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** What a piece of mail means to the person looking at it. */
|
|
33
|
+
export type Status = "unread" | "read" | "awaiting" | "answered" | "message";
|
|
34
|
+
|
|
35
|
+
/** One file with its status; a file that cannot be read has none. */
|
|
36
|
+
export type StatusFile<T extends MailFile> = T & { status?: Status };
|
|
37
|
+
|
|
38
|
+
type Facts = { id?: unknown; kind?: unknown; in_reply_to?: unknown };
|
|
39
|
+
|
|
40
|
+
const facts = (parsed: unknown): Facts => (typeof parsed === "object" && parsed !== null ? (parsed as Facts) : {});
|
|
41
|
+
|
|
42
|
+
/** The ids a `cur/` file answers, from its raw `in_reply_to` array when it has one. */
|
|
43
|
+
function answers(file: MailFile): readonly unknown[] {
|
|
44
|
+
if (file.box !== "cur") return [];
|
|
45
|
+
const list = facts(file.parsed ?? file.envelope).in_reply_to;
|
|
46
|
+
return Array.isArray(list) ? list : [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function statusOf(file: MailFile, answered: ReadonlySet<unknown>, unread: ReadonlySet<string>): Status | undefined {
|
|
50
|
+
const source = file.parsed ?? file.envelope;
|
|
51
|
+
if (source === undefined) return undefined;
|
|
52
|
+
const { id, kind } = facts(source);
|
|
53
|
+
if (file.box === "new") return "unread";
|
|
54
|
+
if (file.box === "cur") return typeof id === "string" && unread.has(id) ? "unread" : "read";
|
|
55
|
+
if (kind === "message") return "message";
|
|
56
|
+
if (typeof id !== "string") return undefined;
|
|
57
|
+
return answered.has(id) ? "answered" : "awaiting";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One status per file, in the order given. `unread` holds the ids claimed into
|
|
62
|
+
* `cur/` that the conversation has not seen yet; a file still in `new/` is
|
|
63
|
+
* unread without being in it. A file that cannot be read gets no status.
|
|
64
|
+
*/
|
|
65
|
+
export function mailStatus<T extends MailFile>(files: readonly T[], unread: ReadonlySet<string>): StatusFile<T>[] {
|
|
66
|
+
const answered = new Set(files.flatMap(answers));
|
|
67
|
+
return files.map((file) => ({ ...file, status: statusOf(file, answered, unread) }));
|
|
68
|
+
}
|
package/src/picker.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// The `/mailbox` picker: one list of this session's mail, the filter Tab
|
|
2
|
+
// (Shift+Tab backwards) cycles, and a detail screen for the selected item. It reads no files,
|
|
3
|
+
// running records or clock and imports none of them: the command handler
|
|
4
|
+
// passes the items, a lookup from address to label and running state, the
|
|
5
|
+
// current time, the title and the starting view. It resolves on Esc, or on `d`
|
|
6
|
+
// with the selected item for the handler to delete after a confirmation; the
|
|
7
|
+
// handler reopens it where the user was.
|
|
8
|
+
|
|
9
|
+
import { DynamicBorder, type Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
type Component,
|
|
12
|
+
type KeybindingsManager,
|
|
13
|
+
matchesKey,
|
|
14
|
+
type SelectItem,
|
|
15
|
+
SelectList,
|
|
16
|
+
type SelectListTheme,
|
|
17
|
+
stripTerminalSequences,
|
|
18
|
+
Text,
|
|
19
|
+
type TUI,
|
|
20
|
+
} from "@earendil-works/pi-tui";
|
|
21
|
+
import type { Status } from "./mail-status.ts";
|
|
22
|
+
|
|
23
|
+
/** The filters Tab cycles, in order. */
|
|
24
|
+
export type PickerFilter = "unread" | "awaiting" | "read" | "sent" | "all";
|
|
25
|
+
const FILTERS: readonly PickerFilter[] = ["unread", "awaiting", "read", "sent", "all"];
|
|
26
|
+
|
|
27
|
+
/** The most rows shown at once; `SelectList` scrolls the rest. */
|
|
28
|
+
const ROW_MAX = 10;
|
|
29
|
+
|
|
30
|
+
/** Room for a row's status prefix and its first line before the description column. */
|
|
31
|
+
const ROW_LAYOUT = { minPrimaryColumnWidth: 32, maxPrimaryColumnWidth: 50 };
|
|
32
|
+
|
|
33
|
+
const EMPTY: Record<PickerFilter, string> = {
|
|
34
|
+
unread: "No unread mail",
|
|
35
|
+
awaiting: "Nothing is waiting for a reply",
|
|
36
|
+
read: "No read mail",
|
|
37
|
+
sent: "Nothing sent",
|
|
38
|
+
all: "No mail",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** What the picker shows for one item; plain values, no envelope or file path. */
|
|
42
|
+
export type PickerItem = {
|
|
43
|
+
id: string;
|
|
44
|
+
/** `sent/` is mail this session sent; `new/` and `cur/` are mail it received. */
|
|
45
|
+
box: "new" | "cur" | "sent";
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
kind: string;
|
|
49
|
+
inReplyTo: readonly string[];
|
|
50
|
+
ts: string;
|
|
51
|
+
body: string;
|
|
52
|
+
status: Status;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** What the picker knows about an address: how to name it and whether it runs. */
|
|
56
|
+
export type Peer = { label: string; running: boolean };
|
|
57
|
+
|
|
58
|
+
/** Everything the picker needs, all known before it opens. */
|
|
59
|
+
export type PickerOptions = {
|
|
60
|
+
items: readonly PickerItem[];
|
|
61
|
+
/** The label and running state of each address, snapshotted before the picker opens. */
|
|
62
|
+
peers: ReadonlyMap<string, Peer>;
|
|
63
|
+
/** The session's name (or short id) and short address; the filter is shown after it. */
|
|
64
|
+
title: string;
|
|
65
|
+
/** The current time in ms, for relative times. */
|
|
66
|
+
now: number;
|
|
67
|
+
filter?: PickerFilter;
|
|
68
|
+
/** The item to select; when it is gone, `selectedIndex` decides instead. */
|
|
69
|
+
selectedId?: string;
|
|
70
|
+
selectedIndex?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** What the picker resolved: it closed, or the user chose to delete the selected item. */
|
|
74
|
+
export type PickResult =
|
|
75
|
+
| { action: "close" }
|
|
76
|
+
| {
|
|
77
|
+
action: "delete";
|
|
78
|
+
/** The selected item's id and box, so the handler finds its file. */
|
|
79
|
+
id: string;
|
|
80
|
+
box: PickerItem["box"];
|
|
81
|
+
/** The first line of its body, for the confirmation. */
|
|
82
|
+
firstLine: string;
|
|
83
|
+
/** Where the list was, so the handler reopens it where the user was. */
|
|
84
|
+
filter: PickerFilter;
|
|
85
|
+
index: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function listTheme(theme: Theme): SelectListTheme {
|
|
89
|
+
return {
|
|
90
|
+
selectedPrefix: (text: string): string => theme.fg("accent", text),
|
|
91
|
+
selectedText: (text: string): string => theme.bold(text),
|
|
92
|
+
description: (text: string): string => theme.fg("dim", text),
|
|
93
|
+
scrollInfo: (text: string): string => theme.fg("dim", text),
|
|
94
|
+
noMatch: (text: string): string => theme.fg("dim", text),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Untrusted text with terminal escape sequences gone and line endings made `\n`. */
|
|
99
|
+
const clean = (text: string): string => stripTerminalSequences(text).replace(/\r\n?/g, "\n");
|
|
100
|
+
|
|
101
|
+
/** One display line from untrusted text, without newlines or other control characters. */
|
|
102
|
+
const oneLine = (text: string): string =>
|
|
103
|
+
clean(text)
|
|
104
|
+
.split("\n")[0]
|
|
105
|
+
.replace(/[\p{Cc}\u2028\u2029]/gu, " ")
|
|
106
|
+
.trim();
|
|
107
|
+
|
|
108
|
+
/** A body for the detail screen: escape sequences and control characters gone, line breaks kept. */
|
|
109
|
+
const bodyText = (text: string): string => clean(text).replace(/[^\P{Cc}\n\t]/gu, " ");
|
|
110
|
+
|
|
111
|
+
const firstLine = (body: string): string => oneLine(body) || "(no text)";
|
|
112
|
+
|
|
113
|
+
/** How long ago `ts` was, as `now`, `5m ago`, `3h ago` or `2d ago`. */
|
|
114
|
+
function ago(ts: string, now: number): string {
|
|
115
|
+
const at = Date.parse(ts);
|
|
116
|
+
if (!Number.isFinite(at)) return "unknown time";
|
|
117
|
+
const minutes = Math.floor(Math.max(0, now - at) / 60_000);
|
|
118
|
+
if (minutes < 1) return "now";
|
|
119
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
120
|
+
const hours = Math.floor(minutes / 60);
|
|
121
|
+
if (hours < 24) return `${hours}h ago`;
|
|
122
|
+
const days = Math.floor(hours / 24);
|
|
123
|
+
if (days < 7) return `${days}d ago`;
|
|
124
|
+
if (days < 30) return `${Math.floor(days / 7)}w ago`;
|
|
125
|
+
if (days < 365) return `${Math.floor(days / 30)}mo ago`;
|
|
126
|
+
return `${Math.floor(days / 365)}y ago`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function shows(filter: PickerFilter, status: Status): boolean {
|
|
130
|
+
if (filter === "all") return true;
|
|
131
|
+
if (filter === "sent") return status === "awaiting" || status === "answered" || status === "message";
|
|
132
|
+
return status === filter;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `d` deletes read and sent mail; unread mail is refused, since deleting its file would not take it back from the model. */
|
|
136
|
+
const deletable = (item: PickerItem): boolean => item.box !== "new" && item.status !== "unread";
|
|
137
|
+
|
|
138
|
+
const newestFirst = (a: PickerItem, b: PickerItem): number => b.ts.localeCompare(a.ts) || a.id.localeCompare(b.id);
|
|
139
|
+
|
|
140
|
+
/** A peer from the snapshot; an address the handler did not resolve is named by its short id. */
|
|
141
|
+
const peerOf = (peers: ReadonlyMap<string, Peer>, address: string): Peer =>
|
|
142
|
+
peers.get(address) ?? { label: address.slice(0, 8), running: false };
|
|
143
|
+
|
|
144
|
+
function row(item: PickerItem, peers: ReadonlyMap<string, Peer>, now: number, filter: PickerFilter): SelectItem {
|
|
145
|
+
const sent = item.box === "sent";
|
|
146
|
+
const other = peerOf(peers, sent ? item.to : item.from);
|
|
147
|
+
// SelectList drops descriptions at narrow widths, so required markers go in the label too.
|
|
148
|
+
const markers: string[] = [];
|
|
149
|
+
if (filter === "all" || filter === "sent") markers.push(item.status);
|
|
150
|
+
if (item.status === "awaiting") markers.push(other.running ? "running" : "closed");
|
|
151
|
+
const parts = [`${sent ? "to" : "from"} ${oneLine(other.label)}`, item.kind, ago(item.ts, now), ...markers];
|
|
152
|
+
const prefix = markers.length > 0 ? `[${markers.join(" ")}] ` : "";
|
|
153
|
+
return { value: item.id, label: `${prefix}${firstLine(item.body)}`, description: parts.join(" · ") };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The detail screen: the other session, the kind, id, absolute time, what it answers and the whole body. */
|
|
157
|
+
function detailText(item: PickerItem, peers: ReadonlyMap<string, Peer>): string {
|
|
158
|
+
const sent = item.box === "sent";
|
|
159
|
+
const address = sent ? item.to : item.from;
|
|
160
|
+
const lines = [
|
|
161
|
+
`${sent ? "To" : "From"} ${oneLine(peerOf(peers, address).label)} <${address}>`,
|
|
162
|
+
`Kind ${oneLine(item.kind)}`,
|
|
163
|
+
`Id ${oneLine(item.id)}`,
|
|
164
|
+
`Sent ${new Date(item.ts).toLocaleString()}`,
|
|
165
|
+
];
|
|
166
|
+
if (item.inReplyTo.length > 0) lines.push(`In reply to ${item.inReplyTo.map(oneLine).join(", ")}`);
|
|
167
|
+
lines.push("", bodyText(item.body));
|
|
168
|
+
return lines.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** A `ctx.ui.custom` factory: the list and detail screens, till Esc closes the list. */
|
|
172
|
+
export function picker(options: PickerOptions) {
|
|
173
|
+
return (tui: TUI, theme: Theme, _keybindings: KeybindingsManager, done: (result: PickResult) => void): Component => {
|
|
174
|
+
const border = new DynamicBorder((text) => theme.fg("accent", text));
|
|
175
|
+
let filter = options.filter ?? "all";
|
|
176
|
+
let screen: "list" | "detail" = "list";
|
|
177
|
+
let shown: PickerItem[] = [];
|
|
178
|
+
let detailItem: PickerItem | undefined;
|
|
179
|
+
let list: SelectList;
|
|
180
|
+
|
|
181
|
+
const build = (selectedId: string | undefined, selectedIndex: number): void => {
|
|
182
|
+
shown = rowsFor(options.items, filter);
|
|
183
|
+
list = buildList(options, theme, filter, shown, selectedId, selectedIndex);
|
|
184
|
+
list.onSelect = (item: SelectItem): void => {
|
|
185
|
+
detailItem = shown.find((candidate) => candidate.id === item.value);
|
|
186
|
+
if (detailItem !== undefined) screen = "detail";
|
|
187
|
+
};
|
|
188
|
+
list.onCancel = (): void => done({ action: "close" });
|
|
189
|
+
};
|
|
190
|
+
build(options.selectedId, options.selectedIndex ?? 0);
|
|
191
|
+
|
|
192
|
+
const handleListInput = (data: string): void => {
|
|
193
|
+
const next = nextFilter(filter, data);
|
|
194
|
+
if (next !== undefined) {
|
|
195
|
+
filter = next;
|
|
196
|
+
build(undefined, 0);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const result = data === "d" ? deletion(shown, list, filter) : undefined;
|
|
200
|
+
if (result !== undefined) done(result);
|
|
201
|
+
else if (data !== "d") list.handleInput(data);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
render(width: number): string[] {
|
|
206
|
+
const title = oneLine(`${options.title} · ${filter}`);
|
|
207
|
+
// The all and sent views mix statuses, so the hint follows the selected row, not the filter.
|
|
208
|
+
const selected = shown.find((item) => item.id === list.getSelectedItem()?.value);
|
|
209
|
+
const deletableNow = selected !== undefined && deletable(selected);
|
|
210
|
+
const hint =
|
|
211
|
+
screen === "detail" ? "esc back" : `tab filter · enter open${deletableNow ? " · d delete" : ""} · esc close`;
|
|
212
|
+
return [
|
|
213
|
+
...border.render(width),
|
|
214
|
+
...new Text(theme.fg("accent", theme.bold(title)), 0, 0).render(width),
|
|
215
|
+
...(screen === "detail" && detailItem !== undefined
|
|
216
|
+
? new Text(detailText(detailItem, options.peers), 0, 0).render(width)
|
|
217
|
+
: listLines(width, shown, list, filter, theme)),
|
|
218
|
+
...new Text(theme.fg("dim", hint), 0, 0).render(width),
|
|
219
|
+
...border.render(width),
|
|
220
|
+
];
|
|
221
|
+
},
|
|
222
|
+
invalidate(): void {
|
|
223
|
+
list.invalidate();
|
|
224
|
+
},
|
|
225
|
+
handleInput(data: string): void {
|
|
226
|
+
if (screen === "detail") {
|
|
227
|
+
if (matchesKey(data, "escape")) screen = "list";
|
|
228
|
+
} else handleListInput(data);
|
|
229
|
+
tui.requestRender();
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The filter Tab or Shift+Tab moves to from `filter`; undefined for any other key. */
|
|
236
|
+
function nextFilter(filter: PickerFilter, data: string): PickerFilter | undefined {
|
|
237
|
+
let step = 0;
|
|
238
|
+
if (matchesKey(data, "tab")) step = 1;
|
|
239
|
+
else if (matchesKey(data, "shift+tab")) step = -1;
|
|
240
|
+
else return undefined;
|
|
241
|
+
return FILTERS[(FILTERS.indexOf(filter) + step + FILTERS.length) % FILTERS.length];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The items `filter` shows, newest first. */
|
|
245
|
+
const rowsFor = (items: readonly PickerItem[], filter: PickerFilter): PickerItem[] =>
|
|
246
|
+
items.filter((item) => shows(filter, item.status)).sort(newestFirst);
|
|
247
|
+
|
|
248
|
+
/** A list of `items`, with the selection restored by id when it is there and by index otherwise. */
|
|
249
|
+
function buildList(
|
|
250
|
+
options: PickerOptions,
|
|
251
|
+
theme: Theme,
|
|
252
|
+
filter: PickerFilter,
|
|
253
|
+
items: PickerItem[],
|
|
254
|
+
selectedId: string | undefined,
|
|
255
|
+
selectedIndex: number,
|
|
256
|
+
): SelectList {
|
|
257
|
+
const list = new SelectList(
|
|
258
|
+
items.map((item) => row(item, options.peers, options.now, filter)),
|
|
259
|
+
ROW_MAX,
|
|
260
|
+
listTheme(theme),
|
|
261
|
+
ROW_LAYOUT,
|
|
262
|
+
);
|
|
263
|
+
const found = selectedId === undefined ? -1 : items.findIndex((item) => item.id === selectedId);
|
|
264
|
+
list.setSelectedIndex(found >= 0 ? found : Math.max(0, selectedIndex));
|
|
265
|
+
return list;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The list, or the note that the filter is empty. */
|
|
269
|
+
function listLines(
|
|
270
|
+
width: number,
|
|
271
|
+
shown: readonly PickerItem[],
|
|
272
|
+
list: SelectList,
|
|
273
|
+
filter: PickerFilter,
|
|
274
|
+
theme: Theme,
|
|
275
|
+
): string[] {
|
|
276
|
+
return shown.length > 0 ? list.render(width) : new Text(theme.fg("muted", ` ${EMPTY[filter]}`), 0, 0).render(width);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** What `d` resolves with: the selected item when it may be deleted, otherwise undefined. */
|
|
280
|
+
function deletion(shown: readonly PickerItem[], list: SelectList, filter: PickerFilter): PickResult | undefined {
|
|
281
|
+
const index = shown.findIndex((item) => item.id === list.getSelectedItem()?.value);
|
|
282
|
+
const item = shown[index];
|
|
283
|
+
if (item === undefined || !deletable(item)) return undefined;
|
|
284
|
+
return { action: "delete", id: item.id, box: item.box, firstLine: firstLine(item.body), filter, index };
|
|
285
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
|
-
import { isAbsolute, join } from "node:path";
|
|
11
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { type MailFile, mailStatus } from "./mail-status.ts";
|
|
12
14
|
|
|
13
15
|
/** What an envelope is: answered at settle, waited on, plain, or an answer. */
|
|
14
16
|
export type Kind = "request" | "ask" | "message" | "reply";
|
|
@@ -137,6 +139,15 @@ function isFile(path: string): boolean {
|
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/** A real directory: `lstat`, so a symlink to one is not. False when there is nothing there. */
|
|
143
|
+
function isDirectory(path: string): boolean {
|
|
144
|
+
try {
|
|
145
|
+
return lstatSync(path).isDirectory();
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
/** `.json` names in `box` that are regular files, in readdir order. */
|
|
141
152
|
function jsonFiles(address: string, box: Box): string[] {
|
|
142
153
|
const dir = boxPath(address, box);
|
|
@@ -170,28 +181,39 @@ export type SentCopy = { envelope: Envelope; path: string };
|
|
|
170
181
|
|
|
171
182
|
const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
|
|
172
183
|
|
|
184
|
+
/** The request ids an envelope answers; throws when the list is not a short list of strings. */
|
|
185
|
+
function replyIds(value: unknown): string[] {
|
|
186
|
+
if (!Array.isArray(value) || !value.every((id) => typeof id === "string"))
|
|
187
|
+
throw new Error("in_reply_to is not a list of ids");
|
|
188
|
+
if (value.length > MAX_REPLY_IDS) throw new Error(`in_reply_to names more than ${MAX_REPLY_IDS} requests`);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
|
|
173
192
|
/**
|
|
174
|
-
* Parse an envelope
|
|
193
|
+
* Parse an envelope value; throws when it is not a well-formed envelope from a
|
|
175
194
|
* valid address. An envelope written before `kind` and `hops` existed is read
|
|
176
195
|
* as a reply when it answers something and as a request otherwise, with hops 0.
|
|
177
196
|
* A `kind` this version does not know is read as `message`, so mail from a
|
|
178
197
|
* newer copy is delivered rather than stranded; the original value is dropped.
|
|
179
198
|
*/
|
|
180
|
-
|
|
181
|
-
const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
|
|
199
|
+
function parseEnvelope(value: unknown): Envelope {
|
|
182
200
|
if (typeof value !== "object" || value === null) throw new Error("not an object");
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
201
|
+
// Copy so the raw parsed fields stay as written for `diskCounts`.
|
|
202
|
+
const envelope = { ...value } as Partial<Envelope>;
|
|
203
|
+
if (typeof envelope.id !== "string" || envelope.id === "") throw new Error("missing id");
|
|
204
|
+
if (!isAddress(envelope.from)) throw new Error(`invalid from address ${JSON.stringify(envelope.from)}`);
|
|
205
|
+
envelope.in_reply_to = replyIds(envelope.in_reply_to);
|
|
206
|
+
if (typeof envelope.body !== "string") throw new Error("missing body");
|
|
207
|
+
if (envelope.kind === undefined) envelope.kind = envelope.in_reply_to.length > 0 ? "reply" : "request";
|
|
208
|
+
else if (!KINDS.includes(envelope.kind)) envelope.kind = "message";
|
|
209
|
+
if (envelope.hops === undefined) envelope.hops = 0;
|
|
210
|
+
else if (!isHops(envelope.hops)) throw new Error(`invalid hops ${JSON.stringify(envelope.hops)}`);
|
|
211
|
+
return envelope as Envelope;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Parse an envelope file; throws when it is not a well-formed envelope. */
|
|
215
|
+
export function readEnvelope(path: string): Envelope {
|
|
216
|
+
return parseEnvelope(JSON.parse(readFileSync(path, "utf8")));
|
|
195
217
|
}
|
|
196
218
|
|
|
197
219
|
/**
|
|
@@ -226,31 +248,129 @@ export function findSent(address: string, ids: readonly string[]): Map<string, S
|
|
|
226
248
|
return found;
|
|
227
249
|
}
|
|
228
250
|
|
|
229
|
-
|
|
251
|
+
/**
|
|
252
|
+
* The same lookup as `findSent`, for deletion: an unreadable `sent/` or a
|
|
253
|
+
* matching copy that cannot be read throws, because deleting a reply whose
|
|
254
|
+
* requests stayed behind would make them reappear as awaiting. A missing
|
|
255
|
+
* `sent/` holds no copies.
|
|
256
|
+
*/
|
|
257
|
+
function sentCopiesForDeletion(address: string, ids: readonly string[]): Map<string, SentCopy> {
|
|
258
|
+
const found = new Map<string, SentCopy>();
|
|
259
|
+
if (ids.length === 0) return found;
|
|
260
|
+
const dir = boxPath(address, "sent");
|
|
261
|
+
let names: string[];
|
|
262
|
+
try {
|
|
263
|
+
names = readdirSync(dir);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return found;
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
if (!isDirectory(dir))
|
|
269
|
+
throw new Error(`refusing to delete sent copies through ${JSON.stringify(dir)}: not a real directory`);
|
|
270
|
+
for (const name of names) {
|
|
271
|
+
const id = ids.find((candidate) => name.endsWith(`-${candidate}.json`));
|
|
272
|
+
if (id === undefined || found.has(id)) continue;
|
|
273
|
+
const path = join(dir, name);
|
|
274
|
+
if (!isFile(path)) continue;
|
|
275
|
+
const envelope = readEnvelope(path);
|
|
276
|
+
if (envelope.id === id) found.set(id, { envelope, path });
|
|
277
|
+
}
|
|
278
|
+
return found;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Delete one mail file from this session's `cur/` or `sent/`, refusing any path
|
|
283
|
+
* outside those two boxes so a file still in `new/` cannot be taken from the
|
|
284
|
+
* model. The session folder and the box must be real directories, not symlinks,
|
|
285
|
+
* so the delete stays inside this mailbox however the path is spelled, and the
|
|
286
|
+
* file itself must be a regular file or a symlink, which is unlinked without
|
|
287
|
+
* its target. A `cur/` envelope that answers requests (`in_reply_to`) also
|
|
288
|
+
* loses this session's `sent/` copies of them, so they do not reappear as
|
|
289
|
+
* awaiting; a `sent/` that cannot be read throws before anything is removed.
|
|
290
|
+
*/
|
|
291
|
+
export function deleteMail(address: string, path: string): void {
|
|
292
|
+
const parent = dirname(path);
|
|
293
|
+
const cur = boxPath(address, "cur");
|
|
294
|
+
if (parent !== cur && parent !== boxPath(address, "sent"))
|
|
295
|
+
throw new Error(`refusing to delete mail outside cur/ or sent/: ${JSON.stringify(path)}`);
|
|
296
|
+
for (const dir of [join(mailRoot(), address), parent])
|
|
297
|
+
if (!isDirectory(dir)) throw new Error(`refusing to delete through ${JSON.stringify(dir)}: not a real directory`);
|
|
298
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
299
|
+
try {
|
|
300
|
+
stats = lstatSync(path);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // already gone
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
if (!stats.isFile() && !stats.isSymbolicLink())
|
|
306
|
+
throw new Error(`refusing to delete ${JSON.stringify(path)}: not a file`);
|
|
307
|
+
// Copies first, so an unlink that throws keeps the reply the picker was on.
|
|
308
|
+
const copies =
|
|
309
|
+
parent === cur && stats.isFile()
|
|
310
|
+
? [...sentCopiesForDeletion(address, readEnvelope(path).in_reply_to).values()]
|
|
311
|
+
: [];
|
|
312
|
+
for (const copy of copies) rmSync(copy.path, { force: true });
|
|
313
|
+
rmSync(path, { force: true });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Every regular `.json` file in `box`, with its path, its parsed JSON and the envelope when valid. */
|
|
317
|
+
function envelopesIn(
|
|
318
|
+
address: string,
|
|
319
|
+
box: Box,
|
|
320
|
+
): { path: string; parsed?: unknown; envelope?: Envelope; error?: string }[] {
|
|
230
321
|
const dir = boxPath(address, box);
|
|
231
|
-
const out:
|
|
322
|
+
const out: { path: string; parsed?: unknown; envelope?: Envelope; error?: string }[] = [];
|
|
232
323
|
for (const name of jsonFiles(address, box)) {
|
|
324
|
+
const path = join(dir, name);
|
|
325
|
+
let parsed: unknown;
|
|
326
|
+
let envelope: Envelope | undefined;
|
|
327
|
+
let error: string | undefined;
|
|
233
328
|
try {
|
|
234
|
-
|
|
235
|
-
} catch {
|
|
236
|
-
|
|
329
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
330
|
+
} catch (err) {
|
|
331
|
+
error = (err as Error).message;
|
|
332
|
+
}
|
|
333
|
+
if (error === undefined) {
|
|
334
|
+
try {
|
|
335
|
+
const valid = parseEnvelope(parsed);
|
|
336
|
+
// The picker shows `to` and sorts by `ts`; delivery does not need them, so only this reader requires them.
|
|
337
|
+
if (!isAddress(valid.to)) throw new Error(`invalid to address ${JSON.stringify(valid.to)}`);
|
|
338
|
+
if (typeof valid.ts !== "string") throw new Error("missing ts");
|
|
339
|
+
envelope = valid;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
error = (err as Error).message;
|
|
342
|
+
}
|
|
237
343
|
}
|
|
344
|
+
out.push({ path, parsed, envelope, error });
|
|
238
345
|
}
|
|
239
346
|
return out;
|
|
240
347
|
}
|
|
241
348
|
|
|
349
|
+
/** One file in a session's mailbox: its box, its path, and the envelope it holds when valid. */
|
|
350
|
+
export type InboxFile = MailFile<Envelope> & { path: string; error?: string };
|
|
351
|
+
|
|
352
|
+
/** Every regular file in this session's `new/`, `cur/` and `sent/`, each with its box and path. */
|
|
353
|
+
export function readMailbox(address: string): InboxFile[] {
|
|
354
|
+
const out: InboxFile[] = [];
|
|
355
|
+
for (const box of ["new", "cur", "sent"] as const)
|
|
356
|
+
for (const file of envelopesIn(address, box)) out.push({ box, ...file });
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
|
|
242
360
|
/**
|
|
243
|
-
* Counts derived from disk: files still in `new/`,
|
|
244
|
-
* requests (not messages) in `sent/` that no
|
|
361
|
+
* Counts derived from disk: files still in `new/`, files in `cur/`, and
|
|
362
|
+
* requests (not messages) in `sent/` with a string id that no `cur/` file names
|
|
363
|
+
* in its `in_reply_to`. It counts the raw parsed JSON, as older copies did, so
|
|
364
|
+
* sessions running either version agree.
|
|
245
365
|
*/
|
|
246
366
|
export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
(
|
|
252
|
-
|
|
253
|
-
|
|
367
|
+
const files = readMailbox(address);
|
|
368
|
+
const statuses = mailStatus(files, new Set());
|
|
369
|
+
return {
|
|
370
|
+
unclaimed: files.filter((file) => file.box === "new").length,
|
|
371
|
+
read: files.filter((file) => file.box === "cur").length,
|
|
372
|
+
awaiting: statuses.filter((file) => file.status === "awaiting").length,
|
|
373
|
+
};
|
|
254
374
|
}
|
|
255
375
|
|
|
256
376
|
// ---------------------------------------------------------------------------
|
|
@@ -282,14 +402,6 @@ const RUNNING = "running";
|
|
|
282
402
|
/** A folder being pruned is first renamed to this, which is no address, so a send makes a fresh folder. */
|
|
283
403
|
const ASIDE = /^\.pruning\.([A-Za-z0-9_-]+)\.[0-9a-f-]{36}$/;
|
|
284
404
|
|
|
285
|
-
const isDirectory = (path: string): boolean => {
|
|
286
|
-
try {
|
|
287
|
-
return lstatSync(path).isDirectory();
|
|
288
|
-
} catch {
|
|
289
|
-
return false;
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
|
|
293
405
|
/** Names in `aside/<box>` when it is a real directory; a symlink or file there lists nothing. Undefined when it cannot be listed. */
|
|
294
406
|
const boxNames = (aside: string, box: Box): string[] | undefined =>
|
|
295
407
|
isDirectory(join(aside, box)) ? namesIn(join(aside, box)) : [];
|
package/src/tools.ts
CHANGED
|
@@ -24,8 +24,6 @@ import { type Envelope, send } from "./store.ts";
|
|
|
24
24
|
|
|
25
25
|
/** What the tools need from the extension around them. */
|
|
26
26
|
export type ToolHooks = {
|
|
27
|
-
/** Called after a tool wrote mail, so the footer can count it. */
|
|
28
|
-
sent: () => void;
|
|
29
27
|
/** The current turn's hop count, which mail sent now carries. */
|
|
30
28
|
hops: () => number;
|
|
31
29
|
/** Report a broken `session-mail.json`; the extension shows one warning per session. */
|
|
@@ -280,7 +278,6 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
280
278
|
const hops = hooks.hops();
|
|
281
279
|
checkHops(hops, hooks);
|
|
282
280
|
const envelope = send(me, to, body, { kind: "message", hops });
|
|
283
|
-
hooks.sent();
|
|
284
281
|
const target = listRunning().find((record) => record.address === to);
|
|
285
282
|
const text =
|
|
286
283
|
target === undefined
|
|
@@ -335,7 +332,6 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
335
332
|
const hops = hooks.hops();
|
|
336
333
|
checkHops(hops, hooks);
|
|
337
334
|
const ask = send(me, to, body, { kind: "ask", hops });
|
|
338
|
-
hooks.sent();
|
|
339
335
|
const outcome = await new Promise<AskOutcome>((resolve) => {
|
|
340
336
|
const finish = (result: AskOutcome) => {
|
|
341
337
|
if (waiting?.id !== ask.id) return;
|
|
@@ -384,7 +380,6 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
|
|
|
384
380
|
const body = typeof params.message === "string" ? params.message.trim() : "";
|
|
385
381
|
if (body === "") throw new Error("the answer is empty; nothing was sent");
|
|
386
382
|
const answer = reply(id, body);
|
|
387
|
-
hooks.sent();
|
|
388
383
|
const sender = listRunning().find((record) => record.address === answer.to);
|
|
389
384
|
const who = sender === undefined ? answer.to : `${label(sender)} (${answer.to})`;
|
|
390
385
|
const result = { id: answer.id, ask: id, to: answer.to };
|