pi-session-mail 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,47 +1,112 @@
1
1
  # pi-session-mail
2
2
 
3
- A file mailbox between Pi sessions on the same machine, with a `/mailbox`
4
- command and `session_mail_*` tools.
3
+ A file mailbox between Pi sessions on the same machine.
5
4
 
6
- Every session has an address — its session id — and an inbox under
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.
10
+
11
+ ## Install
12
+
13
+ Requires Pi 0.80.4 or later; tested with 1.0.0. You also need a writable
14
+ state directory. The package has no runtime dependencies and no build step.
15
+
16
+ Install the package from npm:
17
+
18
+ ```bash
19
+ pi install npm:pi-session-mail
20
+ ```
21
+
22
+ To follow the latest commit instead, install it from GitHub:
23
+
24
+ ```bash
25
+ pi install git:github.com/gvanderclay/pi-session-mail
26
+ ```
27
+
28
+ ## First use
29
+
30
+ In a Pi session, show its address and name:
31
+
32
+ ```text
33
+ /mailbox
34
+ ```
35
+
36
+ The session's id is its address. Other sessions see its Pi session name (set it
37
+ with `/name`), or the first 8 characters of its id when it has none.
38
+
39
+ In another session, send a request to that name or to an id prefix of at least
40
+ 8 characters:
41
+
42
+ ```text
43
+ /mailbox <to> <text>
44
+ ```
45
+
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.
49
+
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.
52
+
53
+ ## How mail works
54
+
55
+ Every session has an address, its session id, and an inbox under
7
56
  `<mail root>/<address>/{tmp,new,cur,sent}/`. The mail root is
8
57
  `$XDG_STATE_HOME/pi-session-mail/`, or `~/.local/state/pi-session-mail/` when
9
- `XDG_STATE_HOME` is unset or not an absolute path. It is created owner-only
10
- (mode `0700`) and shared by every Pi agent directory on the machine, so sessions in
11
- different agent directories reach each other. `/mailbox` shows this session's
12
- address and name, and `/mailbox <to> <text>` sends a request, with `to`
13
- resolved as described under [Addressing](#addressing). Mail waits
14
- on disk until a session with that address starts or resumes; a running session
15
- claims it into `cur/` and injects it once, with the body cut at 32 KiB plus
16
- the envelope's path (see [Delivery](#delivery)). A reply also quotes each
17
- request it answers, capped at 2 KiB each with the `sent/` copy's path, or the
18
- request's id alone when this session has no copy.
58
+ `XDG_STATE_HOME` is unset or not an absolute path. Mail files are mode `0600`
59
+ and folders `0700`, and a session sets the root to `0700` even if it already
60
+ existed, so other users cannot read or write mail, while any process running
61
+ as you can. A session warns at start if its own address folder is open to
62
+ other users; it does not change the folder, so run the `chmod 700` it names.
63
+ The root is shared by every Pi agent directory on the machine, so sessions in
64
+ different agent directories reach each other. It is safe to delete while no
65
+ session is running.
66
+
67
+ Nothing calls `fsync`, so mail that was written just before a power failure or
68
+ an operating-system crash can be lost. The mail root must be on a local
69
+ filesystem, not NFS: an NFS `rename` that is retried after a lost reply can
70
+ report that the file is missing although it was moved, and watching a folder
71
+ for new mail is unreliable on a network filesystem.
72
+
73
+ Mail waits on disk until a session with that address starts or resumes. A
74
+ running session claims it into `cur/` and injects it once, with the body cut at
75
+ 32 KiB plus the envelope's path (see [Delivery](#delivery)). A reply also
76
+ quotes each request it answers, capped at 2 KiB each with the `sent/` copy's
77
+ path, or the request's id alone when this session has no copy.
19
78
 
20
79
  When the recipient's agent settles, its last answer goes back to each sender as
21
- one reply listing the requests it answers, and as a separate reply to each
22
- sender's asks not already answered with `session_mail_reply`: `done`
23
- normally, `stopped` when the user stopped the run, with the partial text, or
24
- `failed` when the run ended on an error, with the error and the partial text.
25
- A request is never answered `stopped`: when the user stops a run, the
26
- requests it read are held for the next run that completes, which answers them
27
- `done`, saying the user took over (see [Delivery](#delivery)). A request the
28
- session was stopped before reading gets a `failed` reply instead. Replies and
29
- messages are never answered. The answer to an ask its sender is still waiting
30
- on becomes that sender's `session_mail_ask` result.
31
- The footer shows `✉ N pending · N read · N awaiting`, non-zero counts only;
32
- `awaiting` counts requests with no answer yet, never messages.
80
+ one reply listing the requests it answers. Each of the sender's asks not
81
+ already answered with `session_mail_reply` gets a separate reply. The status is
82
+ `done` normally, `stopped` when the user stopped the run (with the partial
83
+ text), or `failed` when the run ended on an error (with the error and the
84
+ partial text).
85
+
86
+ A request is never answered `stopped`. When the user stops a run, the requests
87
+ it read are held for the next run that completes, which answers them `done` and
88
+ says the user took over (see [Delivery](#delivery)). A request the session was
89
+ stopped before reading gets a `failed` reply instead. Replies and messages are
90
+ never answered. The answer to an ask its sender is still waiting on becomes
91
+ that sender's `session_mail_ask` result.
33
92
 
34
93
  ## Running sessions
35
94
 
36
- At session start each session writes a record to `<mail root>/running/<address>.json`
37
- (owner-only): its address, Pi session name (when one is set), working
38
- directory, process id, `idle` or `busy`, `waitingOn` (the address its waiting
39
- ask waits on, or empty), and `updated`, when the record
40
- last changed. The record is rewritten when the session is renamed
41
- (`session_info_changed`), when a run starts (`busy`) and when it settles
42
- (`idle`), when an ask starts or stops waiting, and removed at `session_shutdown`. A record whose process no longer
43
- exists counts as not running, and whoever reads it deletes it; so does a
44
- session left behind by a crash.
95
+ At session start each session writes an owner-only record to
96
+ `<mail root>/running/<address>.json`. It holds the session's address, Pi
97
+ session name (when one is set), working directory, process id, `idle` or `busy`
98
+ state, `waitingOn` (the address its waiting ask waits on, or empty), and
99
+ `updated`, when the record last changed.
100
+
101
+ The record is rewritten when the session is renamed (`session_info_changed`),
102
+ when a run starts (`busy`) and when it settles (`idle`), and when an ask starts
103
+ or stops waiting. It is removed at `session_shutdown`. A record whose process
104
+ no longer exists counts as not running, and whoever reads it deletes it, so a
105
+ session left behind by a crash is cleaned up too. The record also holds
106
+ `started`, an opaque token for the process's start time (from `/proc` on Linux,
107
+ `ps` on macOS), so a record whose process id the operating system has reused
108
+ for another process is treated as gone too. A record without `started`, or a
109
+ platform where the start time cannot be read, is judged by the process id alone.
45
110
 
46
111
  The model gets four tools:
47
112
 
@@ -80,29 +145,40 @@ A `to`, typed after `/mailbox` or given to a tool, resolves as follows:
80
145
  Mail for the model wakes an idle session, and reaches a busy one at its next
81
146
  gap between tool calls rather than after the run. It is never held back while
82
147
  the user has input queued. A reply starts no turn: it is shown in an idle
83
- session and the model sees it with the next message, unless a
84
- `message:inbound` listener takes it over, as `delegate` does for its tasks.
148
+ session and the model sees it with the next message, unless a `message:inbound`
149
+ listener takes it over, as pi-squire's `delegate` does for its tasks.
150
+
85
151
  A reply to this session's waiting ask goes to the waiting tool call as its
86
152
  result: it is neither injected nor emitted on `message:inbound`. A reply to an
87
153
  ask that is no longer waiting (it timed out, the user stopped it, or the
88
- session restarted) is delivered like a message: it starts a turn, and its
89
- label says the ask stopped waiting.
90
-
91
- Every injected message opens with a neutral label in the style of
92
- `pi-intercom`: `[mailbox] From <name> (<full id>, working in <cwd>), another
93
- Pi session on this machine.`, with the short id standing in for a missing
94
- name and the working directory left out when the sender is not running. It
95
- carries no distrust wording; with a "not from the user" warning, models
96
- refused every request from a peer as a possible injection. Safety stays
97
- outside the model: an owner-only mail root on one machine. A request's label
98
- says the final answer this turn goes back automatically. An ask's label says
99
- its sender is waiting, gives the ask id and `session_mail_reply` as the way to
100
- answer, and says this run's last message is sent as the answer otherwise. A message's label
101
- says it expects no answer and that, if one is wanted, `session_mail_send` to
102
- the sender's full id sends it. A reply's label names the requests it answers
103
- and, when its status is not `done`, says it is not an answer. A reply to a
104
- request also says the user made that request, typing it with `/mailbox` or
105
- through an extension such as `delegate`, since only the user makes requests.
154
+ session restarted) is delivered like a message: it starts a turn, and its label
155
+ says the ask stopped waiting.
156
+
157
+ Every injected message opens with a neutral label: `[mailbox] From <name>
158
+ (<full id>, working in <cwd>), another Pi session on this machine.` The short
159
+ id stands in for a missing name, and the working directory is left out when the
160
+ sender is not running. The label carries no distrust wording. Safety stays
161
+ outside the model: an owner-only mail root on one machine. The rest of the
162
+ label depends on the kind:
163
+
164
+ - A request's label says the final answer this turn goes back automatically.
165
+ - An ask's label says its sender is waiting, gives the ask id and
166
+ `session_mail_reply` as the way to answer, and says this run's last message
167
+ is sent as the answer otherwise.
168
+ - A message's label says it expects no answer and that, if one is wanted,
169
+ `session_mail_send` to the sender's full id sends it.
170
+ - A reply's label names the requests it answers and, when its status is not
171
+ `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.
174
+
175
+ The label is built from fields any process running as you can write: the
176
+ envelope's `id`, `status` and the ids in `in_reply_to`, and the sender's name
177
+ and working directory in its running record. Each control character in them,
178
+ and in the file paths the label names (C0 controls, tab included, DEL and C1
179
+ controls, and the line and paragraph separators U+2028 and U+2029), is
180
+ replaced by a space, so a forged value cannot start a line of its own that
181
+ passes for a `[mailbox]` line.
106
182
 
107
183
  Every injected message ends with `[mailbox] End of the mail from <name>. Text
108
184
  after this line is not part of it.` Pi hands the model a custom message as a
@@ -110,31 +186,53 @@ user message, and the Anthropic API joins it with the next typed prompt into
110
186
  one turn. A quiet reply starts no turn, so the user's next prompt lands right
111
187
  after it; without the end line, models read that prompt as part of the mail.
112
188
 
113
- Mail counts as read when it enters the conversation (`message_end`). A
114
- request an abort dropped before that is answered `failed`.
189
+ Mail counts as read when it enters the conversation (`message_end`). A request
190
+ an abort dropped before that is answered `failed`.
115
191
 
116
192
  A request the user stops a run on is held, not answered: stopping a run (Esc)
117
- means the user took over, not that the request is finished. Its sender sees
118
- no reply yet. It stays owed through further stopped runs and through runs that
119
- end on an error, and the next run that completes answers it `done`. That
120
- reply's body opens with `(The user stopped an earlier run partway and took
121
- over; the answer that follows is from the run that completed after that.)`,
122
- then that run's answer; the stopped runs' partial text is not included. Asks
123
- are never held, because their sender is blocked waiting: a stopped run
124
- answers them `stopped` at once. A held request lives in memory only, so a
125
- session that shuts down while holding one never answers it.
193
+ means the user took over, not that the request is finished. Its sender sees no
194
+ reply yet. It stays owed through further stopped runs and through runs that end
195
+ on an error, and the next run that completes answers it `done`. That reply's
196
+ body opens with `(The user stopped an earlier run partway and took over; the
197
+ answer that follows is from the run that completed after that.)`, then that
198
+ run's answer; the stopped runs' partial text is not included. Asks are never
199
+ held, because their sender is blocked waiting: a stopped run answers them
200
+ `stopped` at once. A held request lives in memory only, so a session that shuts
201
+ down while holding one never answers it.
126
202
 
127
203
  ## Envelopes
128
204
 
129
205
  Every envelope is a JSON file with `id`, `from`, `to`, `kind`, `hops`,
130
- `in_reply_to`, `status`, `ts` and `body`. `kind` is `request` (answered when
131
- the recipient settles), `reply` (an answer, naming what it answers in
132
- `in_reply_to`), `message` (plain mail that expects no answer), or `ask` (a
133
- question whose sender waits for the answer, answered like a request). `hops` is a non-negative integer counting how many times a
134
- chain of mail has woken or steered a session with no person typing (see
206
+ `in_reply_to`, `status`, `ts` and `body`. `kind` is one of:
207
+
208
+ - `request`, answered when the recipient settles.
209
+ - `reply`, an answer that names what it answers in `in_reply_to`.
210
+ - `message`, plain mail that expects no answer.
211
+ - `ask`, a question whose sender waits for the answer, answered like a request.
212
+
213
+ A `kind` this version does not know is read as `message`: the envelope is
214
+ delivered, wakes the session and expects no answer. The original value is not
215
+ kept. This lets a copy of the package that is older than the sender's still
216
+ deliver its mail.
217
+
218
+ An envelope whose `in_reply_to` names more than 50 requests (50 is allowed) is
219
+ set aside with a warning, like any other envelope that cannot be read: it stays in `cur/`
220
+ and is not delivered.
221
+
222
+ `hops` is a non-negative integer counting how many times a chain of mail has
223
+ woken or steered a session with no person typing (see
135
224
  [Hop limit](#hop-limit)). An envelope written before `kind` and `hops` existed
136
- is read as a reply when `in_reply_to` is non-empty and as a request
137
- otherwise, with hops 0.
225
+ is read as a reply when `in_reply_to` is non-empty and as a request otherwise,
226
+ with hops 0.
227
+
228
+ A reply's `status` says how the run ended, not whether the job succeeded.
229
+ Requests, asks and messages carry no status.
230
+
231
+ | `status` | Meaning |
232
+ | --- | --- |
233
+ | `done` | the run settled; the body is its answer, opening with a note that the user took over when the request was held across a stop |
234
+ | `stopped` | the user stopped the run before it settled; the body says so, then the partial text. Only asks are answered `stopped`; requests are held instead |
235
+ | `failed` | the run did not do the job, and the body says why: either the run ended on an error (such as an API error Pi's retries gave up on), and the body gives the error, then the partial text; or the session was stopped before it read the request or ask, and nothing was done |
138
236
 
139
237
  ## Hop limit
140
238
 
@@ -147,49 +245,38 @@ quietly starts no turn and does not count. The count starts afresh when the
147
245
  run settles.
148
246
 
149
247
  `session_mail_send` and `session_mail_ask` stamp the count on their mail, and
150
- are refused before anything is written once the count has reached the limit
151
- (5 unless `session-mail.json` says otherwise; see
152
- [Configuration](#configuration)). The
153
- refusal says that a person typing in either session starts the count again.
154
- Automatic answers carry the count and are never refused. Requests from
155
- `/mailbox` and `message:send` carry 0 and are never refused: their senders act
156
- for the user. The limit is a loop guard, not a security boundary: a
157
- hand-written envelope can claim any `hops`.
158
-
159
- Other extensions integrate through the `pi.events` hooks below. They never
160
- import this package or read its files.
161
-
162
- ## Install
163
-
164
- Install the package from npm:
165
-
166
- ```bash
167
- pi install npm:pi-session-mail
168
- ```
169
-
170
- To follow the latest commit instead, install it from GitHub:
171
-
172
- ```bash
173
- pi install git:github.com/gvanderclay/pi-session-mail
174
- ```
175
-
176
- The package has no runtime dependencies and no build step. The `pi` manifest loads only
177
- `./index.ts`, and the tests under `test/` are neither loaded by Pi nor included
178
- in the npm tarball.
179
- Run them with `pnpm install && pnpm test`.
180
-
181
- ## Requirements
182
-
183
- - Pi, with `pi.events`, `pi.sendMessage`, `pi.on`, `pi.registerCommand` and
184
- `pi.registerTool`.
185
- - `typebox` for the tools' parameter schemas, a host-provided package
186
- declared as a peer dependency and supplied by Pi.
187
- - `@earendil-works/pi-coding-agent` for the extension types, `getAgentDir`
188
- (to find `session-mail.json`) and the test harness's event bus, declared as
189
- a peer dependency and supplied by Pi.
190
- - A writable state directory. The mail root lives under
191
- `$XDG_STATE_HOME/pi-session-mail/` (see above) and is safe to delete while
192
- no session is running.
248
+ are refused before anything is written once the count has reached the limit (5
249
+ unless `session-mail.json` says otherwise; see
250
+ [Configuration](#configuration)). The refusal says that a person typing in
251
+ 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.
257
+ The limit is a loop guard, not a security boundary: a hand-written envelope
258
+ can claim any `hops`.
259
+
260
+ ## Pruning
261
+
262
+ Folders of closed sessions would otherwise pile up in the mail root. At session
263
+ start, a session removes an address folder when all of these hold:
264
+
265
+ - it is not this session's, and no running session has that address;
266
+ - nothing is waiting in its `new/`, so unread mail is never deleted;
267
+ - its last activity, the newest change to the folder or its four boxes, is
268
+ older than `pruneAfterDays` days.
269
+
270
+ Removing a folder removes everything in it: the read mail in `cur/` and the
271
+ copies of sent requests in `sent/` go too, not only the empty `new/`. Only real
272
+ folders named like an address are considered; `running/`, symlinks and other
273
+ names are left alone. The work runs after `session_start` returns.
274
+
275
+ To clean up at once, type `/mailbox prune`. It applies the same rules without
276
+ the age limit, so it removes the folder (with its `cur/` and `sent/`) of every
277
+ closed session with no unread mail, including one that closed a minute ago. It
278
+ reports `Removed <N> mailbox folders of closed sessions`.
279
+ `/mailbox prune <text>` is still a message to a session named `prune`.
193
280
 
194
281
  ## Configuration
195
282
 
@@ -198,39 +285,32 @@ Run them with `pnpm install && pnpm test`.
198
285
  (`PI_CODING_AGENT_DIR`, or `~/.pi/agent`), so each agent directory sets its own:
199
286
 
200
287
  ```json
201
- { "hopLimit": 5 }
288
+ { "hopLimit": 5, "pruneAfterDays": 30 }
202
289
  ```
203
290
 
204
291
  | Key | Effect |
205
292
  | --- | --- |
206
293
  | `hopLimit` | A positive integer: how many hops a chain of sessions waking each other may reach before `session_mail_send` and `session_mail_ask` are refused. Default 5. |
294
+ | `pruneAfterDays` | A positive integer: how many days a closed session's mailbox folder is kept before it is [pruned](#pruning). Default 30. |
207
295
 
208
- The file is read at each send. A missing file, or one without `hopLimit`,
209
- means 5. An unreadable or invalid file also means 5, with one warning per
210
- session. `pi-session-mail` sets no environment variable of its own and reads one
211
- standard one:
212
-
213
- | Variable | Effect |
214
- | --- | --- |
215
- | `XDG_STATE_HOME` | The mail root is `$XDG_STATE_HOME/pi-session-mail/` when this is an absolute path, and `~/.local/state/pi-session-mail/` otherwise. Read at every call. |
216
-
217
- ## Statuses
296
+ The file is read at each send and at session start. A missing file, or one
297
+ without a key, means that key's default. An unreadable or invalid file, or an
298
+ invalid value, also means the default (5 hops, 30 days), with one warning per
299
+ session.
218
300
 
219
- A reply's `status` says how the run ended, not whether the job succeeded.
220
- Requests, asks and messages carry no status.
221
-
222
- | `status` | Meaning |
223
- | --- | --- |
224
- | `done` | the run settled; the body is its answer, opening with a note that the user took over when the request was held across a stop |
225
- | `stopped` | the user stopped the run before it settled; the body says so, then the partial text. Only asks are answered `stopped`; requests are held instead |
226
- | `failed` | the run did not do the job, and the body says why: either the run ended on an error (such as an API error Pi's retries gave up on), and the body gives the error, then the partial text; or the session was stopped before it read the request or ask, and nothing was done |
301
+ The only environment variable it reads is the standard `XDG_STATE_HOME`, at
302
+ every call, to place the mail root (see [How mail works](#how-mail-works)). It
303
+ sets no environment variable of its own.
227
304
 
228
305
  ## Hooks
229
306
 
230
- `pi-session-mail` provides all three hooks below and consumes none. All are `pi.events`
231
- channels, and all depend on listeners doing all their work
232
- **synchronously**: the emitter reads the results off the payload the moment
233
- `emit` returns, so a listener must finish before its first `await`.
307
+ Other extensions integrate through the `pi.events` hooks below. They never
308
+ import this package or read its files.
309
+
310
+ `pi-session-mail` provides all three hooks and consumes none. All three depend
311
+ on listeners doing all their work **synchronously**: the emitter reads the
312
+ results off the payload the moment `emit` returns, so a listener must finish
313
+ before its first `await`.
234
314
 
235
315
  The `js` blocks below are the contract's worked examples, and
236
316
  `test/hooks.test.ts` extracts and runs them verbatim, one per block, on a real
@@ -244,18 +324,25 @@ runs that block.
244
324
 
245
325
  The consumer emits `{ to, body }`. Consumers send on the user's behalf: a
246
326
  command the user typed, or a tool whose call the user started. A provider
247
- writes a request (`kind: "request"`, `hops: 0`) from its own session's address and sets `envelope` on the same object before `emit`
248
- returns, or sets `error` instead. **If neither is set, no provider is
249
- installed**, and the consumer should refuse rather than pretend the message
250
- was sent.
327
+ writes a request (`kind: "request"`) from its own session's address and sets
328
+ `envelope` on the same object before `emit` returns, or sets `error` instead.
329
+ **If neither is set, no provider is installed**, and the consumer should
330
+ refuse rather than pretend the message was sent.
251
331
 
252
332
  | Field | Set by | Meaning |
253
333
  | --- | --- | --- |
254
334
  | `to` | consumer | the recipient's address: a session id |
255
335
  | `body` | consumer | the message text |
256
- | `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `hops` (`0`), `in_reply_to`, `status`, `ts`, `body` |
336
+ | `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `hops` (the run's hop count during a run, `0` when idle), `in_reply_to`, `status`, `ts`, `body` |
257
337
  | `error` | provider | why nothing was written: no active session, or an invalid `to` or `body` |
258
338
 
339
+ While a run is active, between `agent_start` and `agent_settled`, the request
340
+ carries that run's hop count (see [Hop limit](#hop-limit)), so two models that
341
+ delegate to each other through an extension cannot escape the limit. While the
342
+ session is idle it carries 0, as the example below does. The provider never
343
+ refuses a send for its hop count; the receiving session refuses past the limit
344
+ as a loop.
345
+
259
346
  The request's `id` is what a reply names in its `in_reply_to`, so a consumer
260
347
  that wants its answers back should keep it.
261
348
 
@@ -281,15 +368,16 @@ assert.equal(probe.error, undefined);
281
368
 
282
369
  ### `message:inbound`
283
370
 
284
- `pi-session-mail` emits this for every claimed envelope before injecting it, requests,
285
- messages and replies alike.
371
+ `pi-session-mail` emits this for every claimed envelope before injecting it,
372
+ requests, messages and replies alike.
286
373
 
287
- `pi-session-mail` never emits it from inside a `session_start` handler. Mail waiting
288
- when a session starts is claimed on a later event-loop turn (`setImmediate`),
289
- so a listener that rebuilds its state synchronously in its own `session_start`
290
- sees that mail, whether it loads before or after `pi-session-mail`. The one exception
291
- is an extension loaded between the two whose `session_start` waits on I/O:
292
- the claim can then run before the listener's handler. The watcher and the poll timer deliver later mail as usual.
374
+ `pi-session-mail` never emits it from inside a `session_start` handler. Mail
375
+ waiting when a session starts is claimed on a later event-loop turn
376
+ (`setImmediate`), so a listener that rebuilds its state synchronously in its
377
+ own `session_start` sees that mail, whether it loads before or after
378
+ `pi-session-mail`. The one exception is an extension loaded between the two
379
+ whose `session_start` waits on I/O: the claim can then run before the
380
+ listener's handler. The watcher and the poll timer deliver later mail as usual.
293
381
 
294
382
  | Field | Meaning |
295
383
  | --- | --- |
@@ -301,8 +389,8 @@ the claim can then run before the listener's handler. The watcher and the poll t
301
389
  A listener that sets `handled` owns the display, and chooses whether its own
302
390
  message starts a turn (`triggerTurn`). A request a listener handled still
303
391
  arms this session's reply and counts as read, so a takeover never leaves a
304
- sender without an answer. A reply `pi-session-mail` injects itself quotes each request
305
- the same way, capped at 2 KiB with the copy's path.
392
+ sender without an answer. A reply `pi-session-mail` injects itself quotes each
393
+ request the same way, capped at 2 KiB with the copy's path.
306
394
 
307
395
  ```js message:inbound
308
396
  // Take over replies to requests this extension sent; `pi-session-mail` still shows
@@ -326,8 +414,8 @@ pi.events.on("message:inbound", (payload) => {
326
414
 
327
415
  ### `message:scan`
328
416
 
329
- A consumer emits `{}` to have `pi-session-mail` claim the mail waiting in its inbox
330
- now, instead of at the next watcher event or poll. The listener runs the
417
+ A consumer emits `{}` to have `pi-session-mail` claim the mail waiting in its
418
+ inbox now, instead of at the next watcher event or poll. The listener runs the
331
419
  inbox scan synchronously, so every waiting envelope has been emitted as
332
420
  `message:inbound` (and delivered) by the time `emit` returns, and then sets
333
421
  `scanned` to `true`. **If `scanned` is not set, no provider is installed**,
@@ -337,8 +425,9 @@ and the consumer must not conclude that no reply is waiting.
337
425
  | --- | --- | --- |
338
426
  | `scanned` | provider | `true` once the scan has finished and its `message:inbound` events were emitted |
339
427
 
340
- `delegate` uses this before checking whether a delegate's window is gone, so
341
- a reply written just before the window closed is claimed first.
428
+ The `delegate` tool in [pi-squire](https://github.com/gvanderclay/pi-squire)
429
+ uses this before checking whether a delegate's window is gone, so a reply
430
+ written just before the window closed is claimed first.
342
431
 
343
432
  ```js message:scan
344
433
  // Claim waiting mail now; a waiting reply reaches `message:inbound` listeners
@@ -355,3 +444,37 @@ const probe = {};
355
444
  bareBus.emit("message:scan", probe);
356
445
  assert.equal(probe.scanned, undefined);
357
446
  ```
447
+
448
+ ## Compatibility
449
+
450
+ Sessions running older and newer copies of `pi-session-mail` share one mail
451
+ root, and other extensions depend on its hooks, so these are stable:
452
+
453
+ - The three hooks, `message:send`, `message:inbound` and `message:scan`, and
454
+ their payloads as [Hooks](#hooks) describes them.
455
+ - The `[mailbox] From …` label and the `[mailbox] End of the mail …` line that
456
+ frame injected mail.
457
+ - The mail root path, `$XDG_STATE_HOME/pi-session-mail/`, or
458
+ `~/.local/state/pi-session-mail/` when `XDG_STATE_HOME` is unset or not
459
+ absolute.
460
+ - The envelope fields (`id`, `from`, `to`, `kind`, `hops`, `in_reply_to`,
461
+ `status`, `ts`, `body`), the kinds (`request`, `reply`, `message`, `ask`)
462
+ and a reply's statuses (`done`, `stopped`, `failed`); see
463
+ [Envelopes](#envelopes).
464
+ - The names of the tools (`session_mail_list`, `session_mail_send`,
465
+ `session_mail_ask`, `session_mail_reply`) and of the `/mailbox` command.
466
+ - The keys of `session-mail.json`, today `hopLimit` and `pruneAfterDays`.
467
+
468
+ Changing any of them needs a major version from 1.0 on, and a minor version
469
+ while the package is at 0.x.
470
+
471
+ ## More
472
+
473
+ - [CONTRIBUTING.md](CONTRIBUTING.md) explains how to set up, test and propose a
474
+ change.
475
+ - [CHANGELOG.md](CHANGELOG.md) lists what changed in each release.
476
+ - [SECURITY.md](SECURITY.md) explains how to report a security problem.
477
+
478
+ ## License
479
+
480
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mail",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "A file mailbox between Pi sessions on the same machine",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,14 +24,11 @@
24
24
  "packageManager": "pnpm@10.33.0",
25
25
  "pi": {
26
26
  "extensions": [
27
- "./index.ts"
27
+ "./src/index.ts"
28
28
  ]
29
29
  },
30
30
  "files": [
31
- "index.ts",
32
- "store.ts",
33
- "running.ts",
34
- "tools.ts",
31
+ "src",
35
32
  "README.md",
36
33
  "LICENSE",
37
34
  "CHANGELOG.md"
package/src/config.ts ADDED
@@ -0,0 +1,46 @@
1
+ // The settings in `<agent dir>/session-mail.json`, parsed from its text.
2
+ // Pure: reading the file is the caller's job, so this module imports nothing.
3
+
4
+ /** The hop limit when `session-mail.json` is missing or broken. */
5
+ export const DEFAULT_HOP_LIMIT = 5;
6
+
7
+ /** Days a closed session's mailbox folder is kept before it is pruned. */
8
+ export const DEFAULT_PRUNE_AFTER_DAYS = 30;
9
+
10
+ /** The settings, each valid or its default, and what was wrong with the file, if anything. */
11
+ export type Config = { hopLimit: number; pruneAfterDays: number; problems: string[] };
12
+
13
+ const isPositiveInteger = (value: unknown): value is number =>
14
+ typeof value === "number" && Number.isInteger(value) && value >= 1;
15
+
16
+ /**
17
+ * The settings in the text of `session-mail.json` at `path`; `undefined` text
18
+ * is a missing file. A key that is absent means its default; an invalid one
19
+ * means its default and a problem. A file that is not a JSON object means
20
+ * every default and one problem.
21
+ */
22
+ export function parseConfig(text: string | undefined, path: string): Config {
23
+ const defaults = { hopLimit: DEFAULT_HOP_LIMIT, pruneAfterDays: DEFAULT_PRUNE_AFTER_DAYS };
24
+ if (text === undefined) return { ...defaults, problems: [] };
25
+ const using = `using the hop limit ${DEFAULT_HOP_LIMIT} and pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`;
26
+ let config: unknown;
27
+ try {
28
+ config = JSON.parse(text);
29
+ } catch (err) {
30
+ return { ...defaults, problems: [`${path} is not valid JSON (${(err as Error).message}); ${using}`] };
31
+ }
32
+ if (typeof config !== "object" || config === null || Array.isArray(config))
33
+ return { ...defaults, problems: [`${path} is not a JSON object; ${using}`] };
34
+ const values = config as { hopLimit?: unknown; pruneAfterDays?: unknown };
35
+ const problems: string[] = [];
36
+ const read = (key: "hopLimit" | "pruneAfterDays"): number => {
37
+ const value = values[key];
38
+ if (value === undefined) return defaults[key];
39
+ if (isPositiveInteger(value)) return value;
40
+ problems.push(`${key} in ${path} must be a positive integer, not ${JSON.stringify(value)}; using ${defaults[key]}`);
41
+ return defaults[key];
42
+ };
43
+ const hopLimit = read("hopLimit");
44
+ const pruneAfterDays = read("pruneAfterDays");
45
+ return { hopLimit, pruneAfterDays, problems };
46
+ }