pi-session-mail
v0.2.2
Published
A file mailbox between Pi sessions on the same machine
Maintainers
Readme
pi-session-mail
A file mailbox between Pi sessions on the same machine.
Each Pi session gets an address and an inbox. You send a request to another
running session with /mailbox, and the model lists, asks, answers and sends
through four session_mail_* tools. Mail waits on disk until the recipient
starts or resumes, and the recipient's final answer goes back to the sender
automatically.
Install
Requires Pi 0.80.4 or later; tested with 1.0.0. You also need a writable state directory. The package has no runtime dependencies and no build step.
Install the package from npm:
pi install npm:pi-session-mailTo follow the latest commit instead, install it from GitHub:
pi install git:github.com/gvanderclay/pi-session-mailFirst use
In a Pi session, show its address and name:
/mailboxThe session's id is its address. Other sessions see its Pi session name (set it
with /name), or the first 8 characters of its id when it has none.
In another session, send a request to that name or to an id prefix of at least 8 characters:
/mailbox <to> <text>The sender sees Sent <id> to <address>. The recipient's model receives the
text and works on it, and its last answer comes back to the sender as a reply
when that run settles. Addressing lists how <to> resolves.
The footer shows ✉ N pending · N read · N awaiting, non-zero counts only.
awaiting counts requests and asks with no answer yet, never messages.
How mail works
Every session has an address, its session id, and an inbox under
<mail root>/<address>/{tmp,new,cur,sent}/. The mail root is
$XDG_STATE_HOME/pi-session-mail/, or ~/.local/state/pi-session-mail/ when
XDG_STATE_HOME is unset or not an absolute path. Mail files are mode 0600
and folders 0700, and a session sets the root to 0700 even if it already
existed, so other users cannot read or write mail, while any process running
as you can. A session warns at start if its own address folder is open to
other users; it does not change the folder, so run the chmod 700 it names.
The root is shared by every Pi agent directory on the machine, so sessions in
different agent directories reach each other. It is safe to delete while no
session is running.
Nothing calls fsync, so mail that was written just before a power failure or
an operating-system crash can be lost. The mail root must be on a local
filesystem, not NFS: an NFS rename that is retried after a lost reply can
report that the file is missing although it was moved, and watching a folder
for new mail is unreliable on a network filesystem.
Mail waits on disk until a session with that address starts or resumes. A
running session claims it into cur/ and injects it once, with the body cut at
32 KiB plus the envelope's path (see Delivery). A reply also
quotes each request it answers, capped at 2 KiB each with the sent/ copy's
path, or the request's id alone when this session has no copy.
When the recipient's agent settles, its last answer goes back to each sender as
one reply listing the requests it answers. Each of the sender's asks not
already answered with session_mail_reply gets a separate reply. The status is
done normally, stopped when the user stopped the run (with the partial
text), or failed when the run ended on an error (with the error and the
partial text).
A request is never answered stopped. When the user stops a run, the requests
it read are held for the next run that completes, which answers them done and
says the user took over (see Delivery). A request the session was
stopped before reading gets a failed reply instead. Replies and messages are
never answered. The answer to an ask its sender is still waiting on becomes
that sender's session_mail_ask result.
Running sessions
At session start each session writes an owner-only record to
<mail root>/running/<address>.json. It holds the session's address, Pi
session name (when one is set), working directory, process id, idle or busy
state, waitingOn (the address its waiting ask waits on, or empty), and
updated, when the record last changed.
The record is rewritten when the session is renamed (session_info_changed),
when a run starts (busy) and when it settles (idle), and when an ask starts
or stops waiting. It is removed at session_shutdown. A record whose process
no longer exists counts as not running, and whoever reads it deletes it, so a
session left behind by a crash is cleaned up too. The record also holds
started, an opaque token for the process's start time (from /proc on Linux,
ps on macOS), so a record whose process id the operating system has reused
for another process is treated as gone too. A record without started, or a
platform where the start time cannot be read, is judged by the process id alone.
The model gets four tools:
| Tool | Parameters | What it does |
| --- | --- | --- |
| 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. |
| session_mail_ask | to, message | Writes an ask (kind: "ask") and waits for the answer, which is the result: its status and body, labelled as not an answer when the status is stopped or failed. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; waitingOn is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the hop limit, and when to does not resolve, is ambiguous or is this session. |
| 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. |
| 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. |
Addressing
A to, typed after /mailbox or given to a tool, resolves as follows:
- A running session's address, or any full session id (a UUID), resolves as given, so a closed session is still reached by its full id and its mail waits for it.
- Otherwise
tomatches running sessions by exact Pi session name, then by an id prefix of at least 8 characters. - One match resolves. Several are refused, naming each candidate's name and full id; that includes a short id two sessions share. None is refused, with a note that a closed session is reached by its full id.
- A session cannot send mail to itself.
message:send does not resolve names: its to is an address.
Delivery
| Kind | Written by | Delivered as | Answered |
| --- | --- | --- | --- |
| request | /mailbox <to> <text>, message:send | triggerTurn, deliverAs: "steer" | yes, when a run completes; held while the user has taken over |
| ask | session_mail_ask | triggerTurn, deliverAs: "steer" | yes, when the run settles |
| message | session_mail_send | triggerTurn, deliverAs: "steer" | never |
| reply | the answering session | deliverAs: "followUp", no turn; see below for answers to asks | never |
Mail for the model wakes an idle session, and reaches a busy one at its next
gap between tool calls rather than after the run. It is never held back while
the user has input queued. A reply starts no turn: it is shown in an idle
session and the model sees it with the next message, unless a message:inbound
listener takes it over, as pi-squire's delegate does for its tasks.
A reply to this session's waiting ask goes to the waiting tool call as its
result: it is neither injected nor emitted on message:inbound. A reply to an
ask that is no longer waiting (it timed out, the user stopped it, or the
session restarted) is delivered like a message: it starts a turn, and its label
says the ask stopped waiting.
Every injected message opens with a neutral label: [mailbox] From <name>
(<full id>, working in <cwd>), another Pi session on this machine. The short
id stands in for a missing name, and the working directory is left out when the
sender is not running. The label carries no distrust wording. Safety stays
outside the model: an owner-only mail root on one machine. The rest of the
label depends on the kind:
- A request's label says the final answer this turn goes back automatically.
- An ask's label says its sender is waiting, gives the ask id and
session_mail_replyas the way to answer, and says this run's last message is sent as the answer otherwise. - A message's label says it expects no answer and that, if one is wanted,
session_mail_sendto the sender's full id sends it. - A reply's label names the requests it answers and, when its status is not
done, says it is not an answer. A reply to a request also says the user made that request, typing it with/mailboxor through an extension such asdelegate, since only the user makes requests.
The label is built from fields any process running as you can write: the
envelope's id, status and the ids in in_reply_to, and the sender's name
and working directory in its running record. Each control character in them,
and in the file paths the label names (C0 controls, tab included, DEL and C1
controls, and the line and paragraph separators U+2028 and U+2029), is
replaced by a space, so a forged value cannot start a line of its own that
passes for a [mailbox] line.
Every injected message ends with [mailbox] End of the mail from <name>. Text
after this line is not part of it. Pi hands the model a custom message as a
user message, and the Anthropic API joins it with the next typed prompt into
one turn. A quiet reply starts no turn, so the user's next prompt lands right
after it; without the end line, models read that prompt as part of the mail.
Mail counts as read when it enters the conversation (message_end). A request
an abort dropped before that is answered failed.
A request the user stops a run on is held, not answered: stopping a run (Esc)
means the user took over, not that the request is finished. Its sender sees no
reply yet. It stays owed through further stopped runs and through runs that end
on an error, and the next run that completes answers it done. That reply's
body opens with (The user stopped an earlier run partway and took over; the
answer that follows is from the run that completed after that.), then that
run's answer; the stopped runs' partial text is not included. Asks are never
held, because their sender is blocked waiting: a stopped run answers them
stopped at once. A held request lives in memory only, so a session that shuts
down while holding one never answers it.
Envelopes
Every envelope is a JSON file with id, from, to, kind, hops,
in_reply_to, status, ts and body. kind is one of:
request, answered when the recipient settles.reply, an answer that names what it answers inin_reply_to.message, plain mail that expects no answer.ask, a question whose sender waits for the answer, answered like a request.
A kind this version does not know is read as message: the envelope is
delivered, wakes the session and expects no answer. The original value is not
kept. This lets a copy of the package that is older than the sender's still
deliver its mail.
An envelope whose in_reply_to names more than 50 requests (50 is allowed) is
set aside with a warning, like any other envelope that cannot be read: it stays in cur/
and is not delivered.
hops is a non-negative integer counting how many times a chain of mail has
woken or steered a session with no person typing (see
Hop limit). An envelope written before kind and hops existed
is read as a reply when in_reply_to is non-empty and as a request otherwise,
with hops 0.
A reply's status says how the run ended, not whether the job succeeded.
Requests, asks and messages carry no status.
| status | Meaning |
| --- | --- |
| 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 |
| 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 |
| 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 |
Hop limit
Each session keeps a hop count for its current turn. It is 0 once the user
starts or steers the turn: any Pi input event, whether typed, sent over RPC,
or a prompt a command sent. Otherwise it is the highest hops plus one among
the envelopes that started the turn or steered into it, requests and messages
alike, and a reply a message:inbound listener took over. A reply shown
quietly starts no turn and does not count. The count starts afresh when the
run settles.
session_mail_send and session_mail_ask stamp the count on their mail, and
are refused before anything is written once the count has reached the limit (5
unless session-mail.json says otherwise; see
Configuration). The refusal says that a person typing in
either session starts the count again. Automatic answers carry the count and
are never refused. Requests from /mailbox carry 0 and are never refused:
their sender acts for the user. A message:send emitted while the session is
idle carries 0 too. One emitted during a run carries that run's count and is
not refused at send time; the session that receives it counts it like any
other mail, so a chain that reaches the limit is refused there as a loop.
The limit is a loop guard, not a security boundary: a hand-written envelope
can claim any hops.
Pruning
Folders of closed sessions would otherwise pile up in the mail root. At session start, a session removes an address folder when all of these hold:
- it is not this session's, and no running session has that address;
- nothing is waiting in its
new/, so unread mail is never deleted; - its last activity, the newest change to the folder or its four boxes, is
older than
pruneAfterDaysdays.
Removing a folder removes everything in it: the read mail in cur/ and the
copies of sent requests in sent/ go too, not only the empty new/. Only real
folders named like an address are considered; running/, symlinks and other
names are left alone. The work runs after session_start returns.
To clean up at once, type /mailbox prune. It applies the same rules without
the age limit, so it removes the folder (with its cur/ and sent/) of every
closed session with no unread mail, including one that closed a minute ago. It
reports Removed <N> mailbox folders of closed sessions.
/mailbox prune <text> is still a message to a session named prune.
Configuration
pi-session-mail names no model. It reads one optional settings file,
<agent dir>/session-mail.json, where the agent dir is Pi's
(PI_CODING_AGENT_DIR, or ~/.pi/agent), so each agent directory sets its own:
{ "hopLimit": 5, "pruneAfterDays": 30 }| Key | Effect |
| --- | --- |
| 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. |
| pruneAfterDays | A positive integer: how many days a closed session's mailbox folder is kept before it is pruned. Default 30. |
The file is read at each send and at session start. A missing file, or one without a key, means that key's default. An unreadable or invalid file, or an invalid value, also means the default (5 hops, 30 days), with one warning per session.
The only environment variable it reads is the standard XDG_STATE_HOME, at
every call, to place the mail root (see How mail works). It
sets no environment variable of its own.
Hooks
Other extensions integrate through the pi.events hooks below. They never
import this package or read its files.
pi-session-mail provides all three hooks and consumes none. All three depend
on listeners doing all their work synchronously: the emitter reads the
results off the payload the moment emit returns, so a listener must finish
before its first await.
The js blocks below are the contract's worked examples, and
test/hooks.test.ts extracts and runs them verbatim, one per block, on a real
createEventBus. Their scope is the test harness's: pi is the extension
API, peer is another session's address, bareBus is an event bus with no
message:send provider, and assert is node:assert/strict. Write them as
plain JavaScript so they stay runnable; the name after js is the test that
runs that block.
message:send
The consumer emits { to, body }. Consumers send on the user's behalf: a
command the user typed, or a tool whose call the user started. A provider
writes a request (kind: "request") from its own session's address and sets
envelope on the same object before emit returns, or sets error instead.
If neither is set, no provider is installed, and the consumer should
refuse rather than pretend the message was sent.
| Field | Set by | Meaning |
| --- | --- | --- |
| to | consumer | the recipient's address: a session id |
| body | consumer | the message text |
| 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 |
| error | provider | why nothing was written: no active session, or an invalid to or body |
While a run is active, between agent_start and agent_settled, the request
carries that run's hop count (see Hop limit), so two models that
delegate to each other through an extension cannot escape the limit. While the
session is idle it carries 0, as the example below does. The provider never
refuses a send for its hop count; the receiving session refuses past the limit
as a loop.
The request's id is what a reply names in its in_reply_to, so a consumer
that wants its answers back should keep it.
// Send a request from this session and keep the id a reply will name.
const payload = { to: peer, body: "Please run the test suite." };
pi.events.emit("message:send", payload);
assert.equal(payload.error, undefined);
assert.ok(payload.envelope, "a provider sets `envelope` before `emit` returns");
assert.equal(typeof payload.envelope.id, "string");
assert.equal(payload.envelope.kind, "request");
assert.equal(payload.envelope.hops, 0);// With no `message:send` provider installed, neither field is set.
const probe = { to: peer, body: "hello" };
bareBus.emit("message:send", probe);
assert.equal(probe.envelope, undefined);
assert.equal(probe.error, undefined);message:inbound
pi-session-mail emits this for every claimed envelope before injecting it,
requests, messages and replies alike.
pi-session-mail never emits it from inside a session_start handler. Mail
waiting when a session starts is claimed on a later event-loop turn
(setImmediate), so a listener that rebuilds its state synchronously in its
own session_start sees that mail, whether it loads before or after
pi-session-mail. The one exception is an extension loaded between the two
whose session_start waits on I/O: the claim can then run before the
listener's handler. The watcher and the poll timer deliver later mail as usual.
| Field | Meaning |
| --- | --- |
| 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 |
| path | the envelope's path in this session's cur/ |
| 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 |
| handled | set to true by a listener that shows the message itself; pi-session-mail then injects nothing |
A listener that sets handled owns the display, and chooses whether its own
message starts a turn (triggerTurn). A request a listener handled still
arms this session's reply and counts as read, so a takeover never leaves a
sender without an answer. A reply pi-session-mail injects itself quotes each
request the same way, capped at 2 KiB with the copy's path.
// Take over replies to requests this extension sent; `pi-session-mail` still shows
// requests and any mail the listener ignores.
pi.events.on("message:inbound", (payload) => {
if (payload.handled) return;
const { envelope, path, requests } = payload;
if (envelope.in_reply_to.length === 0) return;
payload.handled = true;
const asked = requests.map(({ envelope: request, path: copy }) => `${request.body} (copy at ${copy})`);
pi.sendMessage(
{
customType: "my-extension",
content: `Reply from ${envelope.from} to ${asked.join("; ")}:\n${envelope.body}\n(claimed at ${path})`,
display: true,
},
{ triggerTurn: true, deliverAs: "followUp" },
);
});message:scan
A consumer emits {} to have pi-session-mail claim the mail waiting in its
inbox now, instead of at the next watcher event or poll. The listener runs the
inbox scan synchronously, so every waiting envelope has been emitted as
message:inbound (and delivered) by the time emit returns, and then sets
scanned to true. If scanned is not set, no provider is installed,
and the consumer must not conclude that no reply is waiting.
| Field | Set by | Meaning |
| --- | --- | --- |
| scanned | provider | true once the scan has finished and its message:inbound events were emitted |
The delegate tool in pi-squire
uses this before checking whether a delegate's window is gone, so a reply
written just before the window closed is claimed first.
// Claim waiting mail now; a waiting reply reaches `message:inbound` listeners
// before `emit` returns. This example runs with one reply waiting.
const seen = [];
pi.events.on("message:inbound", (inbound) => seen.push(inbound.envelope.id));
const payload = {};
pi.events.emit("message:scan", payload);
assert.equal(payload.scanned, true);
assert.equal(seen.length, 1, "the waiting reply was emitted during emit");
// With no provider installed, `scanned` stays unset.
const probe = {};
bareBus.emit("message:scan", probe);
assert.equal(probe.scanned, undefined);Compatibility
Sessions running older and newer copies of pi-session-mail share one mail
root, and other extensions depend on its hooks, so these are stable:
- The three hooks,
message:send,message:inboundandmessage:scan, and their payloads as Hooks describes them. - The
[mailbox] From …label and the[mailbox] End of the mail …line that frame injected mail. - The mail root path,
$XDG_STATE_HOME/pi-session-mail/, or~/.local/state/pi-session-mail/whenXDG_STATE_HOMEis unset or not absolute. - The envelope fields (
id,from,to,kind,hops,in_reply_to,status,ts,body), the kinds (request,reply,message,ask) and a reply's statuses (done,stopped,failed); see Envelopes. - The names of the tools (
session_mail_list,session_mail_send,session_mail_ask,session_mail_reply) and of the/mailboxcommand. - The keys of
session-mail.json, todayhopLimitandpruneAfterDays.
Changing any of them needs a major version from 1.0 on, and a minor version while the package is at 0.x.
More
- CONTRIBUTING.md explains how to set up, test and propose a change.
- CHANGELOG.md lists what changed in each release.
- SECURITY.md explains how to report a security problem.
