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 +22 -0
- package/LICENSE +21 -0
- package/README.md +357 -0
- package/index.ts +642 -0
- package/package.json +60 -0
- package/running.ts +156 -0
- package/store.ts +222 -0
- package/tools.ts +307 -0
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
|
+
```
|