pi-session-mail 0.1.0 → 0.2.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 +86 -1
- package/README.md +280 -157
- package/package.json +3 -6
- package/src/config.ts +46 -0
- package/{index.ts → src/index.ts} +139 -134
- package/{running.ts → src/running.ts} +118 -10
- package/src/settle.ts +82 -0
- package/src/store.ts +412 -0
- package/{tools.ts → src/tools.ts} +80 -40
- package/store.ts +0 -222
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
|
|
4
|
-
command and `session_mail_*` tools.
|
|
3
|
+
A file mailbox between Pi sessions on the same machine.
|
|
5
4
|
|
|
6
|
-
|
|
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.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
normally, `stopped` when the user stopped the run
|
|
24
|
-
`failed` when the run ended on an error
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
`
|
|
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
|
|
37
|
-
|
|
38
|
-
directory, process id, `idle` or `busy
|
|
39
|
-
ask waits on, or empty), and
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
Every injected message opens with a neutral label
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
says the final answer this turn goes back automatically.
|
|
99
|
-
its sender is waiting, gives the ask id and
|
|
100
|
-
answer, and says this run's last message
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
[Configuration](#configuration)). The
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
##
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
209
|
-
means
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
`
|
|
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"
|
|
248
|
-
returns, or sets `error` instead.
|
|
249
|
-
|
|
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,
|
|
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
|
|
288
|
-
when a session starts is claimed on a later event-loop turn
|
|
289
|
-
so a listener that rebuilds its state synchronously in its
|
|
290
|
-
sees that mail, whether it loads before or after
|
|
291
|
-
is an extension loaded between the two
|
|
292
|
-
the claim can then run before the
|
|
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
|
|
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
|
|
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`
|
|
341
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
-
"
|
|
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
|
+
}
|