pi-session-mail 0.5.0 → 0.6.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 CHANGED
@@ -6,6 +6,58 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-10-31
10
+
11
+ ### Added
12
+
13
+ - The `message:reply` hook: an extension that took over a request answers it
14
+ by emitting `{ to, inReplyTo, body, status }`. `pi-session-mail` writes a
15
+ reply from the current session (the caller cannot set `from`) with that
16
+ body and status exactly as given, at once, without waiting for the run to
17
+ settle or using the model's answer, and sets `envelope` or `error` on the
18
+ payload before `emit` returns, as `message:send` does. `status` is
19
+ required and is `done`, `stopped` or `failed`; `inReplyTo` is a non-empty
20
+ list of at most 50 non-empty ids, checked for shape only and not looked up
21
+ in the mailbox; `body` is any string; `to` is checked as `message:send`
22
+ checks it. There is no duplicate guard: every valid call writes a reply.
23
+ With no provider installed, neither field is set. The reply travels like any
24
+ other: it waits in a closed sender's inbox, and the sender gets it through
25
+ `message:inbound` with its request copies. Asks are still answered with
26
+ `session_mail_reply` or at settle. The README documents the hook with a
27
+ runnable request-to-reply example. It is how an extension answers a request
28
+ now that requests get no automatic answer (see the second **Breaking** entry
29
+ under Removed).
30
+
31
+ ### Removed
32
+
33
+ - **Breaking:** the hop limit is gone entirely. There is no per-turn hop
34
+ count, `hops` is no longer written to new envelopes or part of the
35
+ supported envelope, `session_mail_send` and `session_mail_ask` are never
36
+ refused because of hops, and the `hopLimit` setting in `session-mail.json`
37
+ has no effect (no migration; `pruneAfterDays` is unchanged). No other loop
38
+ guard replaces it: sessions can keep waking each other, which is accepted.
39
+ An old stored envelope with a `hops` property is still read and delivered;
40
+ the property is ignored. The test that pinned the old behaviour changed with
41
+ it.
42
+ - **Breaking:** a request (mail another extension sends through
43
+ `message:send`) no longer gets an automatic answer. The receiving session
44
+ sends no reply when the run settles, whether a `message:inbound` listener
45
+ took the request over or not, and whether the run completed, stopped or
46
+ failed or never read the request; a request is no longer held across a stop
47
+ and answered "took over" later. The extension that consumes a request now
48
+ owns its reply. A request is still stored, delivered and woken on as before,
49
+ keeps its `sent/` copy and shows in `/mailbox`. Its label no longer says the
50
+ final answer goes back automatically, and `session_mail_reply` now refuses a
51
+ request saying the extension that consumes it answers it. Asks are
52
+ unchanged. pi-squire's `delegate` relied on the automatic answer, so this
53
+ release and pi-squire must be upgraded together: the matching pi-squire
54
+ release sends its replies with `message:reply`. There is no version check,
55
+ capability negotiation or fallback answering, and mixing this release with
56
+ another pi-squire release is unsupported, with no particular failure
57
+ promised. The tests that pinned automatic request answers
58
+ changed with it: they now pin that no answer is sent, and the reply-delivery
59
+ tests write their replies directly.
60
+
9
61
  ## [0.5.0] - 2026-10-23
10
62
 
11
63
  ### Changed
@@ -188,7 +240,8 @@ author's dotfiles; the changes below are against that copy.
188
240
  - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
189
241
  published file list.
190
242
 
191
- [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.5.0...HEAD
243
+ [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.6.0...HEAD
244
+ [0.6.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.5.0...v0.6.0
192
245
  [0.5.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...v0.5.0
193
246
  [0.4.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...v0.4.0
194
247
  [0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
package/README.md CHANGED
@@ -5,8 +5,10 @@ A file mailbox between Pi sessions on the same machine.
5
5
  Each Pi session gets an address and an inbox. You send a message to another
6
6
  running session with `/mailbox:send`, and the model lists, asks, answers and
7
7
  sends through four `session_mail_*` tools. Mail waits on disk until the
8
- recipient starts or resumes, and the recipient's final answer goes back to the
9
- sender automatically.
8
+ recipient starts or resumes. An ask's answer goes back to its sender
9
+ automatically; a request another extension sends through `message:send` gets
10
+ no automatic answer, because the extension that consumes it owns its reply and
11
+ sends it through `message:reply`.
10
12
 
11
13
  ## Install
12
14
 
@@ -60,9 +62,9 @@ and then typing the text:
60
62
  ```
61
63
 
62
64
  The sender sees `Sent <id> to <address>`. A message wakes the recipient's model
63
- and expects no answer. The model sends requests, asks and answers through the
64
- `session_mail_*` tools, and a request's last answer comes back to the sender as
65
- a reply when that run settles.
65
+ and expects no answer. The model sends asks, messages and answers through the
66
+ `session_mail_*` tools, and an ask's last answer comes back to the sender as a
67
+ reply when that run settles.
66
68
 
67
69
  The footer shows `📬 N unread` while mail has reached this session but not yet
68
70
  entered its conversation, such as mail queued behind a busy run; it is hidden
@@ -95,18 +97,18 @@ quotes each request it answers, capped at 2 KiB each with the `sent/` copy's
95
97
  path, or the request's id alone when this session has no copy.
96
98
 
97
99
  When the recipient's agent settles, its last answer goes back to each sender as
98
- one reply listing the requests it answers. Each of the sender's asks not
99
- already answered with `session_mail_reply` gets a separate reply. The status is
100
- `done` normally, `stopped` when the user stopped the run (with the partial
101
- text), or `failed` when the run ended on an error (with the error and the
102
- partial text).
103
-
104
- A request is never answered `stopped`. When the user stops a run, the requests
105
- it read are held for the next run that completes, which answers them `done` and
106
- says the user took over (see [Delivery](#delivery)). A request the session was
107
- stopped before reading gets a `failed` reply instead. Replies and messages are
108
- never answered. The answer to an ask its sender is still waiting on becomes
109
- that sender's `session_mail_ask` result.
100
+ one reply listing that sender's asks not already answered with
101
+ `session_mail_reply`. The status is `done` normally, `stopped` when the user
102
+ stopped the run (with the partial text), or `failed` when the run ended on an
103
+ error (with the error and the partial text). An ask the session was stopped
104
+ before reading gets a `failed` reply instead. The answer to an ask its sender
105
+ is still waiting on becomes that sender's `session_mail_ask` result.
106
+
107
+ A request gets no automatic answer: not when its run completes, stops or
108
+ fails, not when the session never read it, and not when a `message:inbound`
109
+ listener took it over. The extension that consumes it owns its reply, and
110
+ sends it with the [`message:reply`](#messagereply) hook. Replies
111
+ and messages are never answered.
110
112
 
111
113
  ## Running sessions
112
114
 
@@ -131,9 +133,9 @@ The model gets four tools:
131
133
  | Tool | Parameters | What it does |
132
134
  | --- | --- | --- |
133
135
  | `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. |
134
- | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it 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. |
135
- | `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. |
136
- | `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). |
136
+ | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it 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, and when `to` does not resolve, is ambiguous or is this session. |
137
+ | `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done`, 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 asks are still answered. Refused, sending nothing, for an ask already answered (by this tool or at settle), for a request (the extension that consumes it answers it), a message or a reply, for an id that never reached this session, and for an empty text. |
138
+ | `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, and when the text is empty. |
137
139
 
138
140
  ## Addressing
139
141
 
@@ -155,10 +157,10 @@ A `to` given to a tool resolves as follows:
155
157
 
156
158
  | Kind | Written by | Delivered as | Answered |
157
159
  | --- | --- | --- | --- |
158
- | `request` | `message:send` | `triggerTurn`, `deliverAs: "steer"` | yes, when a run completes; held while the user has taken over |
159
- | `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, when the run settles |
160
+ | `request` | `message:send` | `triggerTurn`, `deliverAs: "steer"` | never automatically; the extension that consumes it replies |
161
+ | `ask` | `session_mail_ask` | `triggerTurn`, `deliverAs: "steer"` | yes, with `session_mail_reply` or when the run settles |
160
162
  | `message` | `session_mail_send`, `/mailbox:send` | `triggerTurn`, `deliverAs: "steer"` | never |
161
- | `reply` | the answering session | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
163
+ | `reply` | the answering session: `session_mail_reply` or settle for an ask, `message:reply` for a request | `deliverAs: "followUp"`, no turn; see below for answers to asks | never |
162
164
 
163
165
  Mail for the model wakes an idle session, and reaches a busy one at its next
164
166
  gap between tool calls rather than after the run. It is never held back while
@@ -172,8 +174,8 @@ ask that is not waiting (a background ask, or one that timed out, that the
172
174
  user stopped, or whose session restarted) is delivered like a message: it
173
175
  starts a turn, and its label says the ask is not waiting for it. An
174
176
  unattended session waits rather than asking in the background because ending
175
- its turn ends its work: a delegate's settle is its result, and print mode
176
- exits.
177
+ its turn ends its work: print mode exits, and an unattended TUI has no person
178
+ to read a late answer.
177
179
 
178
180
  Every injected message opens with a neutral label: `[mailbox] From <name>
179
181
  (<full id>, working in <cwd>), another Pi session on this machine.` The short
@@ -182,7 +184,7 @@ sender is not running. The label carries no distrust wording. Safety stays
182
184
  outside the model: an owner-only mail root on one machine. The rest of the
183
185
  label depends on the kind:
184
186
 
185
- - A request's label says the final answer this turn goes back automatically.
187
+ - A request's label is the sender line alone; it promises no answer.
186
188
  - An ask's label says its sender is waiting, gives the ask id and
187
189
  `session_mail_reply` as the way to answer, and says this run's last message
188
190
  is sent as the answer otherwise.
@@ -207,29 +209,22 @@ user message, and the Anthropic API joins it with the next typed prompt into
207
209
  one turn. A quiet reply starts no turn, so the user's next prompt lands right
208
210
  after it; without the end line, models read that prompt as part of the mail.
209
211
 
210
- Mail counts as read when it enters the conversation (`message_end`). A request
211
- an abort dropped before that is answered `failed`.
212
-
213
- A request the user stops a run on is held, not answered: stopping a run (Esc)
214
- means the user took over, not that the request is finished. Its sender sees no
215
- reply yet. It stays owed through further stopped runs and through runs that end
216
- on an error, and the next run that completes answers it `done`. That reply's
217
- body opens with `(The user stopped an earlier run partway and took over; the
218
- answer that follows is from the run that completed after that.)`, then that
219
- run's answer; the stopped runs' partial text is not included. Asks are never
220
- held, because their sender is blocked waiting: a stopped run answers them
221
- `stopped` at once. A held request lives in memory only, so a session that shuts
222
- down while holding one never answers it.
212
+ Mail counts as read when it enters the conversation (`message_end`). An ask
213
+ an abort dropped before that is answered `failed`. Asks are never held across a
214
+ stop, because their sender is blocked waiting: a stopped run answers them
215
+ `stopped` at once.
223
216
 
224
217
  ## Envelopes
225
218
 
226
- Every envelope is a JSON file with `id`, `from`, `to`, `kind`, `hops`,
219
+ Every envelope is a JSON file with `id`, `from`, `to`, `kind`,
227
220
  `in_reply_to`, `status`, `ts` and `body`. `kind` is one of:
228
221
 
229
- - `request`, answered when the recipient settles.
222
+ - `request`, mail another extension sends through `message:send`; the
223
+ extension that consumes it in the recipient answers it, if anything does.
230
224
  - `reply`, an answer that names what it answers in `in_reply_to`.
231
225
  - `message`, plain mail that expects no answer.
232
- - `ask`, a question whose sender waits for the answer, answered like a request.
226
+ - `ask`, a question whose sender waits for the answer, answered with
227
+ `session_mail_reply` or when the recipient settles.
233
228
 
234
229
  A `kind` this version does not know is read as `message`: the envelope is
235
230
  delivered, wakes the session and expects no answer. The original value is not
@@ -240,42 +235,30 @@ An envelope whose `in_reply_to` names more than 50 requests (50 is allowed) is
240
235
  set aside with a warning, like any other envelope that cannot be read: it stays in `cur/`
241
236
  and is not delivered.
242
237
 
243
- `hops` is a non-negative integer counting how many times a chain of mail has
244
- woken or steered a session with no person typing (see
245
- [Hop limit](#hop-limit)). An envelope written before `kind` and `hops` existed
246
- is read as a reply when `in_reply_to` is non-empty and as a request otherwise,
247
- with hops 0.
238
+ An envelope written before `kind` existed is read as a reply when `in_reply_to`
239
+ is non-empty and as a request otherwise.
240
+
241
+ There is no hop count or other limit on chains of mail: sessions can keep waking
242
+ each other. An old envelope that still carries a `hops` property is read and
243
+ delivered like any other, and the property is ignored.
248
244
 
249
- A reply's `status` says how the run ended, not whether the job succeeded.
250
- Requests, asks and messages carry no status.
245
+ Requests, asks and messages carry no status. A reply's `status` is one of
246
+ `done`, `stopped` and `failed`.
247
+
248
+ The answer to an ask, from `session_mail_reply` (always `done`) or at settle,
249
+ uses the status to say how the run ended, not whether the job succeeded, and
250
+ `pi-session-mail` writes the body:
251
251
 
252
252
  | `status` | Meaning |
253
253
  | --- | --- |
254
- | `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 |
255
- | `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 |
256
- | `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 |
257
-
258
- ## Hop limit
259
-
260
- Each session keeps a hop count for its current turn. It is 0 once the user
261
- starts or steers the turn: any Pi `input` event, whether typed, sent over RPC,
262
- or a prompt a command sent. Otherwise it is the highest `hops` plus one among
263
- the envelopes that started the turn or steered into it, requests and messages
264
- alike, and a reply a `message:inbound` listener took over. A reply shown
265
- quietly starts no turn and does not count. The count starts afresh when the
266
- run settles.
267
-
268
- `session_mail_send` and `session_mail_ask` stamp the count on their mail, and
269
- are refused before anything is written once the count has reached the limit (5
270
- unless `session-mail.json` says otherwise; see
271
- [Configuration](#configuration)). The refusal says that a person typing in
272
- either session starts the count again. Automatic answers carry the count and
273
- are never refused. A `message:send` is never refused at send time: its consumer
274
- acts for the user. Emitted while the session is idle it carries 0; during a run
275
- it carries that run's count, and the session that receives it counts it like
276
- any other mail, so a chain that reaches the limit is refused there as a loop.
277
- The limit is a loop guard, not a security boundary: a hand-written envelope
278
- can claim any `hops`.
254
+ | `done` | the run settled; the body is its answer |
255
+ | `stopped` | the user stopped the run before it settled; the body says so, then the partial text |
256
+ | `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 ask, and nothing was done |
257
+
258
+ A reply to a request is different: its consumer sets the status and the body
259
+ through [`message:reply`](#messagereply), and `pi-session-mail` writes them as
260
+ given. It adds no wording and waits for nothing. What `done`, `stopped` and
261
+ `failed` mean for a request is for the consumer to define.
279
262
 
280
263
  ## Pruning
281
264
 
@@ -304,29 +287,30 @@ reports `Removed <N> mailbox folders of closed sessions`.
304
287
  (`PI_CODING_AGENT_DIR`, or `~/.pi/agent`), so each agent directory sets its own:
305
288
 
306
289
  ```json
307
- { "hopLimit": 5, "pruneAfterDays": 30 }
290
+ { "pruneAfterDays": 30 }
308
291
  ```
309
292
 
310
293
  | Key | Effect |
311
294
  | --- | --- |
312
- | `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. |
313
295
  | `pruneAfterDays` | A positive integer: how many days a closed session's mailbox folder is kept before it is [pruned](#pruning). Default 30. |
314
296
 
315
- The file is read at each send and at session start. A missing file, or one
297
+ The file is read at session start. A missing file, or one
316
298
  without a key, means that key's default. An unreadable or invalid file, or an
317
- invalid value, also means the default (5 hops, 30 days), with one warning per
318
- session.
299
+ invalid value, also means the default (30 days), with one warning per
300
+ session. A key this version does not know, such as the former `hopLimit`, is ignored.
319
301
 
320
- The only environment variable it reads is the standard `XDG_STATE_HOME`, at
321
- every call, to place the mail root (see [How mail works](#how-mail-works)). It
322
- sets no environment variable of its own.
302
+ It reads two environment variables: the standard `XDG_STATE_HOME`, at every
303
+ call, to place the mail root (see [How mail works](#how-mail-works)), and
304
+ `PI_UNATTENDED`, which `session_mail_ask` checks to tell whether a person is
305
+ watching a TUI session (see [the tools](#running-sessions)). It sets no
306
+ environment variable of its own.
323
307
 
324
308
  ## Hooks
325
309
 
326
310
  Other extensions integrate through the `pi.events` hooks below. They never
327
311
  import this package or read its files.
328
312
 
329
- `pi-session-mail` provides all three hooks and consumes none. All three depend
313
+ `pi-session-mail` provides all four hooks and consumes none. All four depend
330
314
  on listeners doing all their work **synchronously**: the emitter reads the
331
315
  results off the payload the moment `emit` returns, so a listener must finish
332
316
  before its first `await`.
@@ -335,7 +319,9 @@ The `js` blocks below are the contract's worked examples, and
335
319
  `test/hooks.test.ts` extracts and runs them verbatim, one per block, on a real
336
320
  `createEventBus`. Their scope is the test harness's: `pi` is the extension
337
321
  API, `peer` is another session's address, `bareBus` is an event bus with no
338
- `message:send` provider, and `assert` is `node:assert/strict`. Write them as
322
+ `message:send` provider, `peerPi` is the extension API of the session at
323
+ `peer` (in the examples that need both ends), and `assert` is
324
+ `node:assert/strict`. Write them as
339
325
  plain JavaScript so they stay runnable; the name after `js` is the test that
340
326
  runs that block.
341
327
 
@@ -352,18 +338,13 @@ refuse rather than pretend the message was sent.
352
338
  | --- | --- | --- |
353
339
  | `to` | consumer | the recipient's address: a session id |
354
340
  | `body` | consumer | the message text |
355
- | `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` |
341
+ | `envelope` | provider | the written request: `id`, `from`, `to`, `kind` (`"request"`), `in_reply_to`, `status`, `ts`, `body` |
356
342
  | `error` | provider | why nothing was written: no active session, or an invalid `to` or `body` |
357
343
 
358
- While a run is active, between `agent_start` and `agent_settled`, the request
359
- carries that run's hop count (see [Hop limit](#hop-limit)), so two models that
360
- delegate to each other through an extension cannot escape the limit. While the
361
- session is idle it carries 0, as the example below does. The provider never
362
- refuses a send for its hop count; the receiving session refuses past the limit
363
- as a loop.
364
-
365
344
  The request's `id` is what a reply names in its `in_reply_to`, so a consumer
366
- that wants its answers back should keep it.
345
+ that wants its answers back should keep it. `pi-session-mail` never answers a
346
+ request by itself: the extension that consumes it in the recipient session
347
+ sends any reply, through [`message:reply`](#messagereply).
367
348
 
368
349
  ```js message:send
369
350
  // Send a request from this session and keep the id a reply will name.
@@ -374,7 +355,7 @@ assert.equal(payload.error, undefined);
374
355
  assert.ok(payload.envelope, "a provider sets `envelope` before `emit` returns");
375
356
  assert.equal(typeof payload.envelope.id, "string");
376
357
  assert.equal(payload.envelope.kind, "request");
377
- assert.equal(payload.envelope.hops, 0);
358
+ assert.equal("hops" in payload.envelope, false);
378
359
  ```
379
360
 
380
361
  ```js no-provider
@@ -385,10 +366,92 @@ assert.equal(probe.envelope, undefined);
385
366
  assert.equal(probe.error, undefined);
386
367
  ```
387
368
 
369
+ ### `message:reply`
370
+
371
+ A consumer that took over a request emits `{ to, inReplyTo, body, status }` to
372
+ answer it, whenever its work is done. A provider writes a reply (`kind:
373
+ "reply"`) from its own session's address and sets `envelope` on the same
374
+ object before `emit` returns, or sets `error` instead and writes nothing.
375
+ **If neither is set, no provider is installed**, as with `message:send`.
376
+
377
+ | Field | Set by | Meaning |
378
+ | --- | --- | --- |
379
+ | `to` | consumer | the request's sender: a session id, checked as `message:send` checks it |
380
+ | `inReplyTo` | consumer | the ids of the requests this answers: a non-empty list of non-empty strings, at most 50 |
381
+ | `body` | consumer | the reply text: any string, empty included |
382
+ | `status` | consumer | required: `"done"`, `"stopped"` or `"failed"`; the consumer defines what each means for its request (see [Envelopes](#envelopes)) |
383
+ | `envelope` | provider | the written reply: `id`, `from`, `to`, `kind` (`"reply"`), `in_reply_to`, `status`, `ts`, `body` |
384
+ | `error` | provider | why nothing was written: no active session, an invalid field, or a failed write |
385
+
386
+ The hook is for answering requests. The reply's `from` is always this
387
+ session; a `from` on the payload is ignored. `body` and `status` are written
388
+ exactly as given: the reply does not wait for the run to settle, does not use
389
+ the model's answer and adds no wording. `inReplyTo` is checked for shape only:
390
+ its ids are not looked up in this session's mailbox, so the hook does not
391
+ check that they name requests this session received. Nothing stops a second
392
+ reply to the same request either: every valid call writes one, and the
393
+ consumer makes sure it answers once. An ask is answered with
394
+ `session_mail_reply` or at settle, never through this hook, which leaves an
395
+ ask's bookkeeping alone.
396
+
397
+ The reply goes through the mailbox like any other: one the sender is not
398
+ running to receive waits in its inbox and is claimed when it starts again.
399
+ The sender then gets it through `message:inbound` with its `requests` copies,
400
+ and a listener there can take it over.
401
+
402
+ ```js message:reply
403
+ // The consumer in the peer session takes over the requests it receives.
404
+ const taken = [];
405
+ peerPi.events.on("message:inbound", (inbound) => {
406
+ if (inbound.handled || inbound.envelope.kind !== "request") return;
407
+ inbound.handled = true;
408
+ taken.push(inbound.envelope);
409
+ });
410
+
411
+ // This session sends a request, and the peer claims it now.
412
+ const request = { to: peer, body: "Please run the test suite." };
413
+ pi.events.emit("message:send", request);
414
+ peerPi.events.emit("message:scan", {});
415
+ assert.equal(taken.length, 1);
416
+
417
+ // When its work is done, the consumer answers from the peer session.
418
+ const reply = { to: taken[0].from, inReplyTo: [taken[0].id], body: "All 42 tests pass.", status: "done" };
419
+ peerPi.events.emit("message:reply", reply);
420
+ assert.equal(reply.error, undefined);
421
+ assert.ok(reply.envelope, "a provider sets `envelope` before `emit` returns");
422
+ assert.equal(reply.envelope.kind, "reply");
423
+ assert.equal(reply.envelope.from, peer);
424
+
425
+ // The reply reaches this session through `message:inbound`, unchanged.
426
+ const replies = [];
427
+ pi.events.on("message:inbound", (inbound) => replies.push(inbound));
428
+ pi.events.emit("message:scan", {});
429
+ assert.equal(replies.length, 1);
430
+ const [{ envelope, requests }] = replies;
431
+ assert.deepEqual(envelope.in_reply_to, [request.envelope.id]);
432
+ assert.equal(envelope.body, "All 42 tests pass.");
433
+ assert.equal(envelope.status, "done");
434
+ assert.equal(requests[0].envelope.id, request.envelope.id);
435
+
436
+ // Invalid input sets `error` and writes nothing.
437
+ const unknown = { to: peer, inReplyTo: [request.envelope.id], body: "?", status: "maybe" };
438
+ pi.events.emit("message:reply", unknown);
439
+ assert.equal(unknown.envelope, undefined);
440
+ assert.match(unknown.error, /status/);
441
+
442
+ // With no provider installed, neither field is set.
443
+ const probe = { to: peer, inReplyTo: [request.envelope.id], body: "hello", status: "done" };
444
+ bareBus.emit("message:reply", probe);
445
+ assert.equal(probe.envelope, undefined);
446
+ assert.equal(probe.error, undefined);
447
+ ```
448
+
388
449
  ### `message:inbound`
389
450
 
390
451
  `pi-session-mail` emits this for every claimed envelope before injecting it,
391
- requests, messages and replies alike.
452
+ requests, asks, messages and replies alike, except the reply to an ask this
453
+ session is waiting on: that goes to the waiting `session_mail_ask` call as its
454
+ result and is neither emitted nor injected.
392
455
 
393
456
  `pi-session-mail` never emits it from inside a `session_start` handler. Mail
394
457
  waiting when a session starts is claimed on a later event-loop turn
@@ -400,15 +463,16 @@ listener's handler. The watcher and the poll timer deliver later mail as usual.
400
463
 
401
464
  | Field | Meaning |
402
465
  | --- | --- |
403
- | `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 |
466
+ | `envelope` | the claimed envelope, with `from`, `kind`, `in_reply_to`, `status`, `body` and the rest; an old envelope without `kind` gets it filled in as described above |
404
467
  | `path` | the envelope's path in this session's `cur/` |
405
468
  | `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 |
406
469
  | `handled` | set to `true` by a listener that shows the message itself; `pi-session-mail` then injects nothing |
407
470
 
408
471
  A listener that sets `handled` owns the display, and chooses whether its own
409
- message starts a turn (`triggerTurn`). A request a listener handled still
410
- arms this session's reply and counts as read, so a takeover never leaves a
411
- sender without an answer. A reply `pi-session-mail` injects itself quotes each
472
+ message starts a turn (`triggerTurn`). Handled mail counts as read, and an
473
+ ask a listener handled is still answered when this session settles. A request
474
+ arms no answer, handled or not: the listener that consumes it owns its reply. A
475
+ reply `pi-session-mail` injects itself quotes each
412
476
  request the same way, capped at 2 KiB with the copy's path.
413
477
 
414
478
  ```js message:inbound
@@ -436,7 +500,9 @@ pi.events.on("message:inbound", (payload) => {
436
500
  A consumer emits `{}` to have `pi-session-mail` claim the mail waiting in its
437
501
  inbox now, instead of at the next watcher event or poll. The listener runs the
438
502
  inbox scan synchronously, so every waiting envelope has been emitted as
439
- `message:inbound` (and delivered) by the time `emit` returns, and then sets
503
+ `message:inbound` (and delivered) by the time `emit` returns, except the
504
+ reply to an ask this session is waiting on, which goes to the waiting
505
+ `session_mail_ask` call instead. The listener then sets
440
506
  `scanned` to `true`. **If `scanned` is not set, no provider is installed**,
441
507
  and the consumer must not conclude that no reply is waiting.
442
508
 
@@ -469,21 +535,21 @@ assert.equal(probe.scanned, undefined);
469
535
  Sessions running older and newer copies of `pi-session-mail` share one mail
470
536
  root, and other extensions depend on its hooks, so these are stable:
471
537
 
472
- - The three hooks, `message:send`, `message:inbound` and `message:scan`, and
473
- their payloads as [Hooks](#hooks) describes them.
538
+ - The four hooks, `message:send`, `message:reply`, `message:inbound` and
539
+ `message:scan`, and their payloads as [Hooks](#hooks) describes them.
474
540
  - The `[mailbox] From …` label and the `[mailbox] End of the mail …` line that
475
541
  frame injected mail.
476
542
  - The mail root path, `$XDG_STATE_HOME/pi-session-mail/`, or
477
543
  `~/.local/state/pi-session-mail/` when `XDG_STATE_HOME` is unset or not
478
544
  absolute.
479
- - The envelope fields (`id`, `from`, `to`, `kind`, `hops`, `in_reply_to`,
545
+ - The envelope fields (`id`, `from`, `to`, `kind`, `in_reply_to`,
480
546
  `status`, `ts`, `body`), the kinds (`request`, `reply`, `message`, `ask`)
481
547
  and a reply's statuses (`done`, `stopped`, `failed`); see
482
548
  [Envelopes](#envelopes).
483
549
  - The names of the tools (`session_mail_list`, `session_mail_send`,
484
550
  `session_mail_ask`, `session_mail_reply`) and of the `/mailbox`,
485
551
  `/mailbox:send` and `/mailbox:prune` commands.
486
- - The keys of `session-mail.json`, today `hopLimit` and `pruneAfterDays`.
552
+ - The keys of `session-mail.json`, today `pruneAfterDays`.
487
553
 
488
554
  Changing any of them needs a major version from 1.0 on, and a minor version
489
555
  while the package is at 0.x.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mail",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A file mailbox between Pi sessions on the same machine",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/config.ts CHANGED
@@ -1,14 +1,11 @@
1
1
  // The settings in `<agent dir>/session-mail.json`, parsed from its text.
2
2
  // Pure: reading the file is the caller's job, so this module imports nothing.
3
3
 
4
- /** The hop limit when `session-mail.json` is missing or broken. */
5
- export const DEFAULT_HOP_LIMIT = 5;
6
-
7
4
  /** Days a closed session's mailbox folder is kept before it is pruned. */
8
5
  export const DEFAULT_PRUNE_AFTER_DAYS = 30;
9
6
 
10
7
  /** 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[] };
8
+ export type Config = { pruneAfterDays: number; problems: string[] };
12
9
 
13
10
  const isPositiveInteger = (value: unknown): value is number =>
14
11
  typeof value === "number" && Number.isInteger(value) && value >= 1;
@@ -20,9 +17,9 @@ const isPositiveInteger = (value: unknown): value is number =>
20
17
  * every default and one problem.
21
18
  */
22
19
  export function parseConfig(text: string | undefined, path: string): Config {
23
- const defaults = { hopLimit: DEFAULT_HOP_LIMIT, pruneAfterDays: DEFAULT_PRUNE_AFTER_DAYS };
20
+ const defaults = { pruneAfterDays: DEFAULT_PRUNE_AFTER_DAYS };
24
21
  if (text === undefined) return { ...defaults, problems: [] };
25
- const using = `using the hop limit ${DEFAULT_HOP_LIMIT} and pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`;
22
+ const using = `using the default, pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`;
26
23
  let config: unknown;
27
24
  try {
28
25
  config = JSON.parse(text);
@@ -31,16 +28,16 @@ export function parseConfig(text: string | undefined, path: string): Config {
31
28
  }
32
29
  if (typeof config !== "object" || config === null || Array.isArray(config))
33
30
  return { ...defaults, problems: [`${path} is not a JSON object; ${using}`] };
34
- const values = config as { hopLimit?: unknown; pruneAfterDays?: unknown };
31
+ const values = config as { pruneAfterDays?: unknown };
35
32
  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 };
33
+ const value = values.pruneAfterDays;
34
+ let pruneAfterDays = defaults.pruneAfterDays;
35
+ if (value !== undefined) {
36
+ if (isPositiveInteger(value)) pruneAfterDays = value;
37
+ else
38
+ problems.push(
39
+ `pruneAfterDays in ${path} must be a positive integer, not ${JSON.stringify(value)}; using ${defaults.pruneAfterDays}`,
40
+ );
41
+ }
42
+ return { pruneAfterDays, problems };
46
43
  }
package/src/index.ts CHANGED
@@ -13,15 +13,16 @@
13
13
  // expects no answer. Mail in the inbox is claimed into `cur/` and injected with
14
14
  // a label naming the sending session. Requests and messages
15
15
  // wake an idle session and steer into a busy one; a reply quotes each request
16
- // it answers from this session's `sent/` copy and starts no turn. When the recipient settles, its
17
- // last answer goes back to each sender as one reply — `done` normally, or
18
- // `stopped` when the user stopped the run, with any partial text, or `failed`
19
- // when the run ended on an error, with the error and any partial text. A
20
- // request is never answered `stopped`: a run the user stopped holds it, owed,
21
- // until a run completes, and that answer says the user took over. Requests
22
- // that never entered the conversation (an abort drops queued follow-ups) get
23
- // a `failed` reply instead. Replies and messages are never answered. Other extensions use
24
- // `pi.events` (below).
16
+ // it answers from this session's `sent/` copy and starts no turn. A request
17
+ // gets no automatic answer: the extension that consumes it owns its reply and
18
+ // sends it through `message:reply`. An
19
+ // ask not answered with `session_mail_reply` is answered when the recipient
20
+ // settles, its last answer going back to each sender as one reply — `done`
21
+ // normally, `stopped` when the user stopped the run, with any partial text, or
22
+ // `failed` when the run ended on an error, with the error and any partial
23
+ // text. Asks that never entered the conversation (an abort drops queued
24
+ // follow-ups) get a `failed` reply instead. Replies and messages are never
25
+ // answered. Other extensions use `pi.events` (below).
25
26
  //
26
27
  // The hook contracts live in this package's README.
27
28
  import { type FSWatcher, statSync, watch } from "node:fs";
@@ -31,7 +32,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
31
32
  import { mailStatus } from "./mail-status.ts";
32
33
  import { type Peer, type PickerFilter, type PickerItem, type PickResult, picker } from "./picker.ts";
33
34
  import { isRunning, label, listRunning, type RunningRecord, removeRecord, type State, writeRecord } from "./running.ts";
34
- import { type SettleInput, settle } from "./settle.ts";
35
+ import { type SettleAsksInput, settleAsks } from "./settle.ts";
35
36
  import {
36
37
  boxPath,
37
38
  claim,
@@ -41,6 +42,7 @@ import {
41
42
  findSent,
42
43
  isAddress,
43
44
  listNew,
45
+ MAX_REPLY_IDS,
44
46
  pruneClosed,
45
47
  readEnvelope,
46
48
  readMailbox,
@@ -142,7 +144,6 @@ function inboundText(
142
144
  const id = plain(envelope.id);
143
145
  const replyIds = envelope.in_reply_to.map(plain);
144
146
  const header = [sender.line];
145
- if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
146
147
  if (envelope.kind === "ask")
147
148
  header.push(
148
149
  `It is waiting for your answer to its ask ${id}. Answer with session_mail_reply (ask ${id}); otherwise this run's last message is sent as the answer.`,
@@ -167,20 +168,35 @@ function inboundText(
167
168
  * synchronous code before `emit` returns; do all work before any `await`.
168
169
  *
169
170
  * - `message:send`: the caller emits `{ to, body }`; `pi-session-mail` writes a
170
- * request, stamped with the run's hop count during a run and 0 when idle,
171
- * and sets `envelope` (or `error`) on the same object. Neither set means no
171
+ * request and sets `envelope` (or `error`) on the same object. Neither set means no
172
172
  * provider is installed.
173
173
  * - `message:inbound`: emitted for every claimed envelope, requests and
174
- * replies alike, before injection; `requests` holds a reply's request
175
- * copies from this session's `sent/`, and a listener sets `handled` to show
176
- * the message itself.
174
+ * replies alike, before injection, except the reply to an ask this session
175
+ * is waiting on, which is the waiting tool call's result; `requests` holds
176
+ * a reply's request copies from this session's `sent/`, and a listener sets
177
+ * `handled` to show the message itself.
178
+ * - `message:reply`: the caller emits `{ to, inReplyTo, body, status }`;
179
+ * `pi-session-mail` writes a reply from this session and sets `envelope`
180
+ * (or `error`), as for `message:send`. It answers requests a consumer took
181
+ * over; the ids are checked for shape only, never looked up.
177
182
  */
178
183
  const SEND = "message:send";
184
+ const REPLY = "message:reply";
185
+ /** The statuses a `message:reply` caller may give, the same as a settled ask's. */
186
+ const REPLY_STATUSES: readonly string[] = ["done", "stopped", "failed"];
179
187
  const INBOUND = "message:inbound";
180
188
  /** A consumer's request to claim waiting mail now; see the README's `message:scan`. */
181
189
  const SCAN = "message:scan";
182
190
 
183
191
  type SendPayload = { to: unknown; body: unknown; envelope?: Envelope; error?: string };
192
+ type ReplyPayload = {
193
+ to: unknown;
194
+ inReplyTo: unknown;
195
+ body: unknown;
196
+ status: unknown;
197
+ envelope?: Envelope;
198
+ error?: string;
199
+ };
184
200
  type InboundPayload = { envelope: Envelope; path: string; requests: SentCopy[]; handled: boolean };
185
201
 
186
202
  type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown };
@@ -222,15 +238,8 @@ export default function mailbox(pi: ExtensionAPI) {
222
238
  let timer: ReturnType<typeof setInterval> | undefined;
223
239
  /** Envelope ids already handed to Pi in this process. */
224
240
  const delivered = new Set<string>();
225
- /** Request ids awaiting this session's reply, keyed by sender. In memory only. */
226
- let owed = new Map<string, string[]>();
227
- /** Owed request ids whose message entered the conversation (or a listener took over). */
241
+ /** Ids of mail whose message entered the conversation (or a listener took over); settle reads it for asks. */
228
242
  let seen = new Set<string>();
229
- /**
230
- * Request ids held across a stop: the user stopped a run while they were
231
- * owed, so they wait, still owed, for the next run that completes.
232
- */
233
- let held = new Set<string>();
234
243
  /** Text of the last assistant message, remembered at `agent_end`. */
235
244
  let lastAnswer: string | undefined;
236
245
  /** Whether the user stopped that run, whatever its last message's `stopReason`. */
@@ -239,12 +248,6 @@ export default function mailbox(pi: ExtensionAPI) {
239
248
  let lastError: string | undefined;
240
249
  /** Ids of injected mail that has not entered the conversation yet, for the status's unread count. */
241
250
  let unread = new Set<string>();
242
- /**
243
- * The hop count of the current turn: 0 once the user starts or steers it,
244
- * otherwise the highest `hops` + 1 among the envelopes that started or
245
- * steered into it. Mail sent from the turn carries it.
246
- */
247
- let hops = 0;
248
251
  /** The problems with `session-mail.json` this session has already warned about. */
249
252
  let configWarned = new Set<string>();
250
253
  /** What this session's running-session record says. */
@@ -395,13 +398,9 @@ export default function mailbox(pi: ExtensionAPI) {
395
398
  // Listeners run synchronously inside emit, so `handled` is final when it returns.
396
399
  const inbound: InboundPayload = { envelope, path, requests, handled: false };
397
400
  pi.events.emit(INBOUND, inbound);
398
- // Only requests arm an answer: replies are never answered, and messages
399
- // expect none. A request arms one even when a listener took over its display.
400
- if (envelope.kind === "request") owed.set(envelope.from, [...(owed.get(envelope.from) ?? []), envelope.id]);
401
+ // Only asks arm an answer: the extension that consumes a request owns its
402
+ // reply, replies are never answered, and messages expect none.
401
403
  tools?.armAsk(envelope);
402
- // Mail that wakes or steers the session raises its count; a reply shown
403
- // quietly starts no turn, but one a listener took over may.
404
- if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
405
404
  // `pi-session-mail` cannot observe a listener's own message, so a takeover counts as seen.
406
405
  if (inbound.handled) {
407
406
  seen.add(envelope.id);
@@ -458,11 +457,32 @@ export default function mailbox(pi: ExtensionAPI) {
458
457
  try {
459
458
  if (address === undefined) throw new Error("no active session has a mailbox address");
460
459
  if (typeof payload.body !== "string") throw new Error("body must be a string");
461
- // During a run the caller is the model acting through an extension, so the
462
- // request continues the run's chain; idle, it acts for the user and starts a
463
- // fresh one. It is never refused here: the receiving side enforces the limit.
464
- const hopsNow = state === "busy" ? hops : 0;
465
- payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: hopsNow });
460
+ payload.envelope = send(address, payload.to as string, payload.body, { kind: "request" });
461
+ } catch (err) {
462
+ payload.error = (err as Error).message;
463
+ }
464
+ });
465
+
466
+ // Synchronous on purpose, like `message:send`. The reply is written as the
467
+ // caller gives it, now: no settle, no assistant text, no added wording, and
468
+ // no lookup or duplicate guard on `inReplyTo`; the consumer owns all that.
469
+ pi.events.on(REPLY, (data) => {
470
+ const payload = data as ReplyPayload;
471
+ if (typeof payload !== "object" || payload === null) return;
472
+ try {
473
+ if (address === undefined) throw new Error("no active session has a mailbox address");
474
+ const ids = payload.inReplyTo;
475
+ if (!Array.isArray(ids) || ids.length === 0 || !ids.every((id) => typeof id === "string" && id !== ""))
476
+ throw new Error("inReplyTo must be a non-empty list of non-empty request ids");
477
+ if (ids.length > MAX_REPLY_IDS) throw new Error(`inReplyTo names more than ${MAX_REPLY_IDS} requests`);
478
+ if (typeof payload.body !== "string") throw new Error("body must be a string");
479
+ if (typeof payload.status !== "string" || !REPLY_STATUSES.includes(payload.status))
480
+ throw new Error(`status must be one of ${REPLY_STATUSES.map((s) => JSON.stringify(s)).join(", ")}`);
481
+ payload.envelope = send(address, payload.to as string, payload.body, {
482
+ kind: "reply",
483
+ inReplyTo: [...ids],
484
+ status: payload.status,
485
+ });
466
486
  } catch (err) {
467
487
  payload.error = (err as Error).message;
468
488
  }
@@ -497,14 +517,11 @@ export default function mailbox(pi: ExtensionAPI) {
497
517
  pi.on("session_start", async (_event, context) => {
498
518
  stop();
499
519
  ctx = context;
500
- owed = new Map();
501
520
  seen = new Set();
502
- held = new Set();
503
521
  lastAnswer = undefined;
504
522
  lastAborted = false;
505
523
  lastError = undefined;
506
524
  unread = new Set();
507
- hops = 0;
508
525
  tools?.abandonAsk();
509
526
  waitingOn = "";
510
527
  configWarned = new Set();
@@ -563,12 +580,6 @@ export default function mailbox(pi: ExtensionAPI) {
563
580
  if (unread.delete(message.details.id)) updateStatus();
564
581
  });
565
582
 
566
- // Typed text, RPC input and a prompt a command sends all come from the
567
- // user, so each starts the chain again.
568
- pi.on("input", async () => {
569
- hops = 0;
570
- });
571
-
572
583
  pi.on("session_info_changed", async (event) => {
573
584
  name = event.name || undefined;
574
585
  announce();
@@ -606,24 +617,12 @@ export default function mailbox(pi: ExtensionAPI) {
606
617
  // queued follow-up after `agent_end` would otherwise get a premature answer.
607
618
  pi.on("agent_settled", async () => {
608
619
  const me = address;
609
- // Answers carry the settled turn's count; the next turn counts afresh.
610
- const answerHops = hops;
611
- hops = 0;
612
- const end: SettleInput["end"] = lastAborted
620
+ const end: SettleAsksInput["end"] = lastAborted
613
621
  ? { kind: "stopped" }
614
622
  : lastError !== undefined
615
623
  ? { kind: "failed", error: lastError }
616
624
  : { kind: "done" };
617
- const decision = settle({
618
- requests: owed,
619
- asks: tools?.takeOwedAsks() ?? new Map(),
620
- seen,
621
- held,
622
- end,
623
- answer: lastAnswer,
624
- });
625
- owed = decision.owed;
626
- held = decision.held;
625
+ const replies = settleAsks({ asks: tools?.takeOwedAsks() ?? new Map(), seen, end, answer: lastAnswer });
627
626
  seen = new Set();
628
627
  lastAnswer = undefined;
629
628
  lastAborted = false;
@@ -632,11 +631,10 @@ export default function mailbox(pi: ExtensionAPI) {
632
631
  state = "idle";
633
632
  announce();
634
633
  if (me === undefined) return;
635
- for (const reply of decision.replies) {
634
+ for (const reply of replies) {
636
635
  const { to } = reply;
637
636
  try {
638
- // Answers are never refused by the hop limit.
639
- send(me, to, reply.body, { kind: "reply", hops: answerHops, inReplyTo: reply.ids, status: reply.status });
637
+ send(me, to, reply.body, { kind: "reply", inReplyTo: reply.ids, status: reply.status });
640
638
  } catch (err) {
641
639
  warn(`could not reply to ${to}: ${(err as Error).message}`);
642
640
  }
@@ -663,8 +661,6 @@ export default function mailbox(pi: ExtensionAPI) {
663
661
  waitingOn = to;
664
662
  announce();
665
663
  },
666
- hops: () => hops,
667
- configProblem,
668
664
  });
669
665
 
670
666
  pi.registerCommand("mailbox", {
@@ -721,7 +717,7 @@ export default function mailbox(pi: ExtensionAPI) {
721
717
  const text = (await context.ui.input("Message", "type a message"))?.trim();
722
718
  if (text === undefined || text === "") return;
723
719
  try {
724
- const envelope = send(me, target.address, text, { kind: "message", hops: 0 });
720
+ const envelope = send(me, target.address, text, { kind: "message" });
725
721
  context.ui.notify(`Sent ${envelope.id} to ${envelope.to}`, "info");
726
722
  } catch (err) {
727
723
  context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
package/src/settle.ts CHANGED
@@ -1,30 +1,28 @@
1
- export type SettleInput = {
2
- requests: ReadonlyMap<string, string[]>;
1
+ /** What a settle decides on: the open asks, the ids seen, how the run ended and the answer text it left. */
2
+ export type SettleAsksInput = {
3
3
  asks: ReadonlyMap<string, string[]>;
4
4
  seen: ReadonlySet<string>;
5
- held: ReadonlySet<string>;
6
5
  end: { kind: "done" } | { kind: "stopped" } | { kind: "failed"; error: string };
7
6
  answer: string | undefined;
8
7
  };
9
8
  export type Reply = { to: string; ids: string[]; body: string; status: "done" | "stopped" | "failed" };
10
- export type SettleOutput = { replies: Reply[]; owed: Map<string, string[]>; held: Set<string> };
11
9
 
12
10
  /** Reply body when the run ended with no assistant text. */
13
11
  export const NO_ANSWER = "(The session settled with no answer text.)";
14
12
  /** Reply body of a run the user stopped, with any partial answer text after it. */
15
13
  export const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
16
- /** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
17
- export const TOOK_OVER =
18
- "(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
19
14
  /** Opening line of the reply body of a run that ended on an error. */
20
15
  export const ERRORED = (error: string): string =>
21
16
  `(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
22
- /** Body of a `failed` reply to requests that never entered the conversation. */
17
+ /** Body of a `failed` reply to asks that never entered the conversation. */
23
18
  export const UNSEEN =
24
19
  "(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
25
20
 
26
21
  /** The body and status of the reply to a run that ended this way: how it ended, then what it had. */
27
- function describeEnd(end: SettleInput["end"], answer: string | undefined): { body: string; status: Reply["status"] } {
22
+ function describeEnd(
23
+ end: SettleAsksInput["end"],
24
+ answer: string | undefined,
25
+ ): { body: string; status: Reply["status"] } {
28
26
  const text = answer ?? NO_ANSWER;
29
27
  const withOpening = (opening: string): string => (answer === undefined ? opening : `${opening}\n\n${answer}`);
30
28
  if (end.kind === "stopped") return { body: withOpening(STOPPED), status: "stopped" };
@@ -32,12 +30,14 @@ function describeEnd(end: SettleInput["end"], answer: string | undefined): { bod
32
30
  return { body: text, status: "done" };
33
31
  }
34
32
 
35
- /** The replies to the asks: read ones get the run's answer, the rest fail as unseen. */
36
- function askReplies(
37
- asks: SettleInput["asks"],
38
- seen: SettleInput["seen"],
39
- run: Pick<Reply, "body" | "status">,
40
- ): Reply[] {
33
+ /**
34
+ * The replies to the open asks when a run settles: read ones get the run's
35
+ * answer, the rest fail as unseen. Asks are never held across a stop, and
36
+ * requests are never answered here; the extension that consumes one owns its reply.
37
+ */
38
+ export function settleAsks(input: SettleAsksInput): Reply[] {
39
+ const { asks, seen, end, answer } = input;
40
+ const run = describeEnd(end, answer);
41
41
  const replies: Reply[] = [];
42
42
  for (const [to, ids] of asks) {
43
43
  const read = ids.filter((id) => seen.has(id));
@@ -47,36 +47,3 @@ function askReplies(
47
47
  }
48
48
  return replies;
49
49
  }
50
-
51
- /**
52
- * Decide the replies when a run settles, and which requests stay owed.
53
- *
54
- * A request the user stopped a run on is held: it stays owed, through stops
55
- * and failed runs alike, until a run completes, and is then answered "took
56
- * over". A request or ask never read is failed as unseen. Asks are never held;
57
- * their asker is blocked waiting.
58
- */
59
- export function settle(input: SettleInput): SettleOutput {
60
- const { end, answer, seen, held: wasHeld } = input;
61
- const stopped = end.kind === "stopped";
62
- const failed = end.kind === "failed";
63
- const { body, status } = describeEnd(end, answer);
64
- const tookOverBody = `${TOOK_OVER}\n\n${answer ?? NO_ANSWER}`;
65
- const replies: Reply[] = [];
66
- const owed = new Map<string, string[]>();
67
- const held = new Set<string>();
68
- for (const [to, ids] of input.requests) {
69
- const unseen = ids.filter((id) => !seen.has(id) && !wasHeld.has(id));
70
- const keep = ids.filter((id) => !unseen.includes(id) && (stopped || (failed && wasHeld.has(id))));
71
- const answered = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
72
- if (keep.length > 0) owed.set(to, keep);
73
- for (const id of keep) held.add(id);
74
- const plain = answered.filter((id) => !wasHeld.has(id));
75
- const tookOver = answered.filter((id) => wasHeld.has(id));
76
- if (plain.length > 0) replies.push({ to, ids: plain, body, status });
77
- if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
78
- if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
79
- }
80
- replies.push(...askReplies(input.asks, seen, { body, status }));
81
- return { replies, owed, held };
82
- }
package/src/store.ts CHANGED
@@ -12,7 +12,7 @@ import { dirname, isAbsolute, join } from "node:path";
12
12
 
13
13
  import { type MailFile, mailStatus } from "./mail-status.ts";
14
14
 
15
- /** What an envelope is: answered at settle, waited on, plain, or an answer. */
15
+ /** What an envelope is: a request its consuming extension answers, an ask answered at settle, a plain message, or an answer. */
16
16
  export type Kind = "request" | "ask" | "message" | "reply";
17
17
  const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
18
18
 
@@ -21,8 +21,6 @@ export type Envelope = {
21
21
  from: string;
22
22
  to: string;
23
23
  kind: Kind;
24
- /** How many times this chain of mail has woken or steered a session with no person typing. */
25
- hops: number;
26
24
  /** Request ids this envelope answers; empty for a request. */
27
25
  in_reply_to: string[];
28
26
  status: string;
@@ -36,7 +34,7 @@ const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
36
34
  const ADDRESS = /^[A-Za-z0-9_-]+$/;
37
35
 
38
36
  /** The most requests one reply may name; an envelope over this is not read. */
39
- const MAX_REPLY_IDS = 50;
37
+ export const MAX_REPLY_IDS = 50;
40
38
 
41
39
  /** An address is a single safe path segment: letters, digits, `-` and `_`. */
42
40
  export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
@@ -80,7 +78,7 @@ function stamp(): number {
80
78
  const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
81
79
 
82
80
  /** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
83
- export type SendOptions = { kind: Kind; hops: number; inReplyTo?: string[]; status?: string };
81
+ export type SendOptions = { kind: Kind; inReplyTo?: string[]; status?: string };
84
82
 
85
83
  /**
86
84
  * Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
@@ -94,7 +92,6 @@ export function send(from: string, to: string, body: string, options: SendOption
94
92
  if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
95
93
  if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
96
94
  if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
97
- if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
98
95
  const inReplyTo = options.inReplyTo ?? [];
99
96
  const ms = stamp();
100
97
  const envelope: Envelope = {
@@ -102,7 +99,6 @@ export function send(from: string, to: string, body: string, options: SendOption
102
99
  from,
103
100
  to,
104
101
  kind: options.kind,
105
- hops: options.hops,
106
102
  in_reply_to: inReplyTo,
107
103
  status: options.kind === "reply" ? (options.status ?? "done") : "",
108
104
  ts: new Date(ms).toISOString(),
@@ -179,8 +175,6 @@ export function claim(address: string, name: string): string | null {
179
175
  /** A request's copy in its sender's `sent/`, with its path. */
180
176
  export type SentCopy = { envelope: Envelope; path: string };
181
177
 
182
- const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
183
-
184
178
  /** The request ids an envelope answers; throws when the list is not a short list of strings. */
185
179
  function replyIds(value: unknown): string[] {
186
180
  if (!Array.isArray(value) || !value.every((id) => typeof id === "string"))
@@ -191,8 +185,8 @@ function replyIds(value: unknown): string[] {
191
185
 
192
186
  /**
193
187
  * Parse an envelope value; throws when it is not a well-formed envelope from a
194
- * valid address. An envelope written before `kind` and `hops` existed is read
195
- * as a reply when it answers something and as a request otherwise, with hops 0.
188
+ * valid address. An envelope written before `kind` existed is read as a reply
189
+ * when it answers something and as a request otherwise.
196
190
  * A `kind` this version does not know is read as `message`, so mail from a
197
191
  * newer copy is delivered rather than stranded; the original value is dropped.
198
192
  */
@@ -206,8 +200,6 @@ function parseEnvelope(value: unknown): Envelope {
206
200
  if (typeof envelope.body !== "string") throw new Error("missing body");
207
201
  if (envelope.kind === undefined) envelope.kind = envelope.in_reply_to.length > 0 ? "reply" : "request";
208
202
  else if (!KINDS.includes(envelope.kind)) envelope.kind = "message";
209
- if (envelope.hops === undefined) envelope.hops = 0;
210
- else if (!isHops(envelope.hops)) throw new Error(`invalid hops ${JSON.stringify(envelope.hops)}`);
211
203
  return envelope as Envelope;
212
204
  }
213
205
 
package/src/tools.ts CHANGED
@@ -8,29 +8,23 @@
8
8
  // `session_mail_ask` leaves an ask. In a session a person is watching it
9
9
  // returns at once and the answer arrives as a message; elsewhere it waits, one
10
10
  // at a time, for the answer, which comes back as its result. An unattended
11
- // session must wait because ending its turn ends its work: a delegate's settle
12
- // is its result, and print mode exits.
11
+ // session must wait because ending its turn ends its work: print mode exits,
12
+ // and an unattended TUI has no person to read a late answer.
13
13
  // `session_mail_reply` answers an ask this session received, mid-run; the asks
14
14
  // this session owes, and the kind of every envelope it has received, are kept
15
- // here for it. Mail a
16
- // tool sends carries the turn's hop count and is refused once the count
17
- // reaches `hopLimit` from `<agent dir>/session-mail.json`.
15
+ // here for it.
18
16
  import { readFileSync } from "node:fs";
19
17
  import { join } from "node:path";
20
18
 
21
19
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
22
20
  import { Type } from "typebox";
23
21
 
24
- import { DEFAULT_HOP_LIMIT, DEFAULT_PRUNE_AFTER_DAYS, parseConfig } from "./config.ts";
22
+ import { DEFAULT_PRUNE_AFTER_DAYS, parseConfig } from "./config.ts";
25
23
  import { label, listRunning, type RunningRecord, resolveTo } from "./running.ts";
26
24
  import { type Envelope, send } from "./store.ts";
27
25
 
28
26
  /** What the tools need from the extension around them. */
29
27
  export type ToolHooks = {
30
- /** The current turn's hop count, which mail sent now carries. */
31
- hops: () => number;
32
- /** Report a broken `session-mail.json`; the extension shows one warning per session. */
33
- configProblem: (message: string) => void;
34
28
  /** The address this session's ask now waits on, or "" when it waits on none; written to its running record. */
35
29
  waitingOn: (address: string) => void;
36
30
  };
@@ -92,7 +86,7 @@ function askText(ask: Envelope, target: string, outcome: AskOutcome): string {
92
86
 
93
87
  /** Why `session_mail_reply` refuses mail that is not an ask, by the kind of that mail. */
94
88
  const NOT_AN_ASK: Record<Exclude<Envelope["kind"], "ask">, string> = {
95
- request: "A request is answered automatically when this run settles.",
89
+ request: "A request is answered by the extension that consumes it, not by this tool.",
96
90
  message: "A message expects no answer; session_mail_send sends one if it is wanted.",
97
91
  reply: "A reply is never answered.",
98
92
  };
@@ -101,7 +95,7 @@ const NOT_AN_ASK: Record<Exclude<Envelope["kind"], "ask">, string> = {
101
95
  * The settings from `<agent dir>/session-mail.json`, read at each call; each
102
96
  * problem goes to `onProblem`, which the extension shows once per session.
103
97
  */
104
- export function readSettings(onProblem: (message: string) => void): { hopLimit: number; pruneAfterDays: number } {
98
+ export function readSettings(onProblem: (message: string) => void): { pruneAfterDays: number } {
105
99
  const path = join(getAgentDir(), "session-mail.json");
106
100
  let text: string | undefined;
107
101
  try {
@@ -109,7 +103,7 @@ export function readSettings(onProblem: (message: string) => void): { hopLimit:
109
103
  } catch (err) {
110
104
  if ((err as NodeJS.ErrnoException).code !== "ENOENT")
111
105
  onProblem(
112
- `could not read ${path} (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT} and pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`,
106
+ `could not read ${path} (${(err as Error).message}); using the default, pruning after ${DEFAULT_PRUNE_AFTER_DAYS} days`,
113
107
  );
114
108
  text = undefined;
115
109
  }
@@ -118,15 +112,6 @@ export function readSettings(onProblem: (message: string) => void): { hopLimit:
118
112
  return settings;
119
113
  }
120
114
 
121
- /** Refuse, before anything is written, once the turn's hop count has reached the limit. */
122
- function checkHops(hops: number, hooks: ToolHooks): void {
123
- const limit = readSettings(hooks.configProblem).hopLimit;
124
- if (hops < limit) return;
125
- throw new Error(
126
- `nothing was sent: this turn is ${hops} hops into a chain of sessions waking each other with nobody typing, and the hop limit of ${limit} is reached. A person typing in either session starts the count again.`,
127
- );
128
- }
129
-
130
115
  /**
131
116
  * The one result every tool returns. `structuredContent` is what codemode scripts
132
117
  * receive; it must match the tool's `outputSchema`, which Pi does not check.
@@ -203,7 +188,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
203
188
  let waiting: WaitingAsk | undefined;
204
189
  /** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
205
190
  const receivedKinds = new Map<string, Envelope["kind"]>();
206
- /** Ask ids awaiting this session's answer, keyed by sender; answered apart from requests. */
191
+ /** Ask ids awaiting this session's answer, keyed by sender. */
207
192
  let owedAsks = new Map<string, string[]>();
208
193
  /** This session's address as the extension last set it; undefined when it has none. */
209
194
  let address: string | undefined;
@@ -219,8 +204,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
219
204
  const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
220
205
  // An answered ask, by this tool or at settle, is no longer owed.
221
206
  if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
222
- // Answers carry the turn's count and are never refused by the hop limit.
223
- const answer = send(me, from, body, { kind: "reply", hops: hooks.hops(), inReplyTo: [id], status: "done" });
207
+ const answer = send(me, from, body, { kind: "reply", inReplyTo: [id], status: "done" });
224
208
  const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
225
209
  if (rest.length > 0) owedAsks.set(from, rest);
226
210
  else owedAsks.delete(from);
@@ -287,9 +271,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
287
271
  const body = typeof params.message === "string" ? params.message.trim() : "";
288
272
  if (body === "") throw new Error("the message is empty; nothing was sent");
289
273
  const to = resolveTo(typeof params.to === "string" ? params.to : "", me);
290
- const hops = hooks.hops();
291
- checkHops(hops, hooks);
292
- const envelope = send(me, to, body, { kind: "message", hops });
274
+ const envelope = send(me, to, body, { kind: "message" });
293
275
  const target = listRunning().find((record) => record.address === to);
294
276
  const text =
295
277
  target === undefined
@@ -340,9 +322,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
340
322
  throw new Error(
341
323
  `nothing was sent: ${target} is waiting on an answer from this session, so asking it back would leave both waiting; answer it first`,
342
324
  );
343
- const hops = hooks.hops();
344
- checkHops(hops, hooks);
345
- const ask = send(me, to, body, { kind: "ask", hops });
325
+ const ask = send(me, to, body, { kind: "ask" });
346
326
  const outcome: AskOutcome = attended(ctx)
347
327
  ? { outcome: "background" }
348
328
  : await new Promise<AskOutcome>((resolve) => {
@@ -374,7 +354,7 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
374
354
  name: "session_mail_reply",
375
355
  label: "Session mail: reply",
376
356
  description:
377
- "Answer an ask another Pi session sent this session, at once, without ending this run. The asker is waiting, and gets the message as the answer to its ask; the answer this run's last message would otherwise send is then skipped for that ask. `ask` is the ask id its label gives. Each ask is answered once; requests are answered automatically when the run settles, and messages expect no answer.",
357
+ "Answer an ask another Pi session sent this session, at once, without ending this run. The asker is waiting, and gets the message as the answer to its ask; the answer this run's last message would otherwise send is then skipped for that ask. `ask` is the ask id its label gives. Each ask is answered once; requests are answered by the extension that consumes them, and messages expect no answer.",
378
358
  parameters: Type.Object({
379
359
  ask: Type.String({ description: "The ask id, as given in the ask's label." }),
380
360
  message: Type.String({ description: "The answer." }),