pi-session-mail 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this package are recorded here, in the
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. The package
5
+ follows [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-02
10
+
11
+ The first public release. Before this repository the package lived in its
12
+ author's dotfiles; the changes below are against that copy.
13
+
14
+ ### Added
15
+
16
+ - Published to npm as `pi-session-mail`.
17
+ - MIT license and package metadata, including `engines` (Node 22.19 or later).
18
+ - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
19
+ published file list.
20
+
21
+ [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.1.0...HEAD
22
+ [0.1.0]: https://github.com/gvanderclay/pi-session-mail/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gage Vander Clay
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,357 @@
1
+ # pi-session-mail
2
+
3
+ A file mailbox between Pi sessions on the same machine, with a `/mailbox`
4
+ command and `session_mail_*` tools.
5
+
6
+ Every session has an address — its session id — and an inbox under
7
+ `<mail root>/<address>/{tmp,new,cur,sent}/`. The mail root is
8
+ `$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.
19
+
20
+ 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.
33
+
34
+ ## Running sessions
35
+
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.
45
+
46
+ The model gets four tools:
47
+
48
+ | Tool | Parameters | What it does |
49
+ | --- | --- | --- |
50
+ | `session_mail_list` | none | Lists every running session, in every agent directory: its name (or short id, the first 8 characters of its id, when it has no name), full id, working directory, idle or busy state and whom it is waiting on, and marks the calling session. |
51
+ | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`) and waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the [hop limit](#hop-limit), and when `to` does not resolve, is ambiguous or is this session. |
52
+ | `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done` and the turn's hop count, without ending the run; the asker's `session_mail_ask` returns with it. The answer at settle then leaves that ask out, while the sender's other requests and asks are still answered. Refused, sending nothing, for an ask already answered (by this tool or at settle), for a request (answered at settle), a message or a reply, for an id that never reached this session, and for an empty text. |
53
+ | `session_mail_send` | `to`, `message` | Writes a message (`kind: "message"`) and returns its id. To a running session it is delivered at once; to a closed session, by full id, it waits in that session's inbox and the result says so. Refused when `to` does not resolve, is ambiguous or is this session, when the text is empty, and at the [hop limit](#hop-limit). |
54
+
55
+ ## Addressing
56
+
57
+ A `to`, typed after `/mailbox` or given to a tool, resolves as follows:
58
+
59
+ 1. A running session's address, or any full session id (a UUID), resolves as
60
+ given, so a closed session is still reached by its full id and its mail
61
+ waits for it.
62
+ 2. Otherwise `to` matches running sessions by exact Pi session name, then by
63
+ an id prefix of at least 8 characters.
64
+ 3. One match resolves. Several are refused, naming each candidate's name and
65
+ full id; that includes a short id two sessions share. None is refused, with
66
+ a note that a closed session is reached by its full id.
67
+ 4. A session cannot send mail to itself.
68
+
69
+ `message:send` does not resolve names: its `to` is an address.
70
+
71
+ ## Delivery
72
+
73
+ | Kind | Written by | Delivered as | Answered |
74
+ | --- | --- | --- | --- |
75
+ | `request` | `/mailbox <to> <text>`, `message:send` | `triggerTurn`, `deliverAs: "steer"` | yes, when a run completes; held while the user has taken over |
76
+ | `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, when the run settles |
77
+ | `message` | `session_mail_send` | `triggerTurn`, `deliverAs: "steer"` | never |
78
+ | `reply` | the answering session | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
79
+
80
+ Mail for the model wakes an idle session, and reaches a busy one at its next
81
+ gap between tool calls rather than after the run. It is never held back while
82
+ 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.
85
+ A reply to this session's waiting ask goes to the waiting tool call as its
86
+ result: it is neither injected nor emitted on `message:inbound`. A reply to an
87
+ 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.
106
+
107
+ Every injected message ends with `[mailbox] End of the mail from <name>. Text
108
+ after this line is not part of it.` Pi hands the model a custom message as a
109
+ user message, and the Anthropic API joins it with the next typed prompt into
110
+ one turn. A quiet reply starts no turn, so the user's next prompt lands right
111
+ after it; without the end line, models read that prompt as part of the mail.
112
+
113
+ Mail counts as read when it enters the conversation (`message_end`). A
114
+ request an abort dropped before that is answered `failed`.
115
+
116
+ 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.
126
+
127
+ ## Envelopes
128
+
129
+ 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
135
+ [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.
138
+
139
+ ## Hop limit
140
+
141
+ Each session keeps a hop count for its current turn. It is 0 once the user
142
+ starts or steers the turn: any Pi `input` event, whether typed, sent over RPC,
143
+ or a prompt a command sent. Otherwise it is the highest `hops` plus one among
144
+ the envelopes that started the turn or steered into it, requests and messages
145
+ alike, and a reply a `message:inbound` listener took over. A reply shown
146
+ quietly starts no turn and does not count. The count starts afresh when the
147
+ run settles.
148
+
149
+ `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.
193
+
194
+ ## Configuration
195
+
196
+ `pi-session-mail` names no model. It reads one optional settings file,
197
+ `<agent dir>/session-mail.json`, where the agent dir is Pi's
198
+ (`PI_CODING_AGENT_DIR`, or `~/.pi/agent`), so each agent directory sets its own:
199
+
200
+ ```json
201
+ { "hopLimit": 5 }
202
+ ```
203
+
204
+ | Key | Effect |
205
+ | --- | --- |
206
+ | `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. |
207
+
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
218
+
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 |
227
+
228
+ ## Hooks
229
+
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`.
234
+
235
+ The `js` blocks below are the contract's worked examples, and
236
+ `test/hooks.test.ts` extracts and runs them verbatim, one per block, on a real
237
+ `createEventBus`. Their scope is the test harness's: `pi` is the extension
238
+ API, `peer` is another session's address, `bareBus` is an event bus with no
239
+ `message:send` provider, and `assert` is `node:assert/strict`. Write them as
240
+ plain JavaScript so they stay runnable; the name after `js` is the test that
241
+ runs that block.
242
+
243
+ ### `message:send`
244
+
245
+ The consumer emits `{ to, body }`. Consumers send on the user's behalf: a
246
+ 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.
251
+
252
+ | Field | Set by | Meaning |
253
+ | --- | --- | --- |
254
+ | `to` | consumer | the recipient's address: a session id |
255
+ | `body` | consumer | the message text |
256
+ | `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `hops` (`0`), `in_reply_to`, `status`, `ts`, `body` |
257
+ | `error` | provider | why nothing was written: no active session, or an invalid `to` or `body` |
258
+
259
+ The request's `id` is what a reply names in its `in_reply_to`, so a consumer
260
+ that wants its answers back should keep it.
261
+
262
+ ```js message:send
263
+ // Send a request from this session and keep the id a reply will name.
264
+ const payload = { to: peer, body: "Please run the test suite." };
265
+ pi.events.emit("message:send", payload);
266
+
267
+ assert.equal(payload.error, undefined);
268
+ assert.ok(payload.envelope, "a provider sets `envelope` before `emit` returns");
269
+ assert.equal(typeof payload.envelope.id, "string");
270
+ assert.equal(payload.envelope.kind, "request");
271
+ assert.equal(payload.envelope.hops, 0);
272
+ ```
273
+
274
+ ```js no-provider
275
+ // With no `message:send` provider installed, neither field is set.
276
+ const probe = { to: peer, body: "hello" };
277
+ bareBus.emit("message:send", probe);
278
+ assert.equal(probe.envelope, undefined);
279
+ assert.equal(probe.error, undefined);
280
+ ```
281
+
282
+ ### `message:inbound`
283
+
284
+ `pi-session-mail` emits this for every claimed envelope before injecting it, requests,
285
+ messages and replies alike.
286
+
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.
293
+
294
+ | Field | Meaning |
295
+ | --- | --- |
296
+ | `envelope` | the claimed envelope, with `from`, `kind`, `hops`, `in_reply_to`, `status`, `body` and the rest; an old envelope without `kind` or `hops` gets them filled in as described above |
297
+ | `path` | the envelope's path in this session's `cur/` |
298
+ | `requests` | a reply's `in_reply_to` requests found in this session's `sent/`, each as `{ envelope, path }`; empty for a request, and shorter than `in_reply_to` when a copy is missing |
299
+ | `handled` | set to `true` by a listener that shows the message itself; `pi-session-mail` then injects nothing |
300
+
301
+ A listener that sets `handled` owns the display, and chooses whether its own
302
+ message starts a turn (`triggerTurn`). A request a listener handled still
303
+ 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.
306
+
307
+ ```js message:inbound
308
+ // Take over replies to requests this extension sent; `pi-session-mail` still shows
309
+ // requests and any mail the listener ignores.
310
+ pi.events.on("message:inbound", (payload) => {
311
+ if (payload.handled) return;
312
+ const { envelope, path, requests } = payload;
313
+ if (envelope.in_reply_to.length === 0) return;
314
+ payload.handled = true;
315
+ const asked = requests.map(({ envelope: request, path: copy }) => `${request.body} (copy at ${copy})`);
316
+ pi.sendMessage(
317
+ {
318
+ customType: "my-extension",
319
+ content: `Reply from ${envelope.from} to ${asked.join("; ")}:\n${envelope.body}\n(claimed at ${path})`,
320
+ display: true,
321
+ },
322
+ { triggerTurn: true, deliverAs: "followUp" },
323
+ );
324
+ });
325
+ ```
326
+
327
+ ### `message:scan`
328
+
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
331
+ inbox scan synchronously, so every waiting envelope has been emitted as
332
+ `message:inbound` (and delivered) by the time `emit` returns, and then sets
333
+ `scanned` to `true`. **If `scanned` is not set, no provider is installed**,
334
+ and the consumer must not conclude that no reply is waiting.
335
+
336
+ | Field | Set by | Meaning |
337
+ | --- | --- | --- |
338
+ | `scanned` | provider | `true` once the scan has finished and its `message:inbound` events were emitted |
339
+
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.
342
+
343
+ ```js message:scan
344
+ // Claim waiting mail now; a waiting reply reaches `message:inbound` listeners
345
+ // before `emit` returns. This example runs with one reply waiting.
346
+ const seen = [];
347
+ pi.events.on("message:inbound", (inbound) => seen.push(inbound.envelope.id));
348
+ const payload = {};
349
+ pi.events.emit("message:scan", payload);
350
+ assert.equal(payload.scanned, true);
351
+ assert.equal(seen.length, 1, "the waiting reply was emitted during emit");
352
+
353
+ // With no provider installed, `scanned` stays unset.
354
+ const probe = {};
355
+ bareBus.emit("message:scan", probe);
356
+ assert.equal(probe.scanned, undefined);
357
+ ```