pi-session-mail

A file mailbox between Pi sessions on the same machine

Packages

Package details

extension

Install pi-session-mail from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:pi-session-mail
Package
pi-session-mail
Version
0.7.1
Published
Nov 3, 2026
Downloads
1,019/mo · 632/wk
Author
gvanderclay
License
MIT
Types
extension
Size
143.4 KB
Dependencies
0 dependencies · 3 peers
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

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 message to another running session with /mailbox:send, and the model lists, asks, answers and sends through four session_mail_* tools. Mail waits on disk until the recipient starts or resumes. An ask's answer goes back to its sender automatically; a request another extension sends through message:send gets no automatic answer, because the extension that consumes it owns its reply and sends it through message:reply.

Install

Requires Pi 1.5.0 or later; tested with 1.5.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-mail

To follow the latest commit instead, install it from GitHub:

pi install git:github.com/gvanderclay/pi-session-mail

First use

In a Pi session, open a picker of this session's mail:

/mailbox

Its title names the session, its short address and the filter. The picker opens on unread when there is unread mail, and on all otherwise. Tab cycles the filter through unread, awaiting, read, sent and all, and Shift+Tab goes back. Each row shows the first line of the mail, the other session (from for mail it received, to for mail it sent), the kind and how long ago it was sent; the all and sent views also show its status, and an awaiting row says whether its target is still running or has closed. Enter opens the whole mail: the other session's name and full address, the kind, the id, the absolute time, what it answers and the full text. Esc goes back to the list, and Esc there closes the picker. d deletes the selected read or sent item, or drops a request still awaiting a reply, after a confirmation showing its first line; deleting a read reply also deletes the sent/ copies of the requests it answered. The picker reopens on the same filter with the row that took the deleted one's place selected. d is left out on unread mail. Showing mail never marks it read, so the footer's count stays until the model sees it.

The 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 message by picking a running session from a list and then typing the text:

/mailbox:send

The sender sees Sent <id> to <address>. A message wakes the recipient's model and expects no answer. The model sends asks, messages and answers through the session_mail_* tools, and an ask's last answer comes back to the sender as a reply when that run settles.

The footer shows 📬 N unread while mail has reached this session but not yet entered its conversation, such as mail queued behind a busy run; it is hidden at zero.

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. An absolute PI_SESSION_MAIL_ROOT replaces that path for the mail alone, so a test or verification run can isolate its mail without moving every other tool's state with XDG_STATE_HOME. 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 that sender's asks not already answered with session_mail_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). An ask the session was stopped before reading gets a failed reply instead. The answer to an ask its sender is still waiting on becomes that sender's session_mail_ask result.

A request gets no automatic answer: not when its run completes, stops or fails, not when the session never read it, and not when a message:inbound listener took it over. The extension that consumes it owns its reply, and sends it with the message:reply hook. Replies and messages are never answered.

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"). 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.
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.
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.

Addressing

A to given to a tool resolves as follows:

  1. 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.
  2. Otherwise to matches running sessions by exact Pi session name, then by an id prefix of at least 8 characters.
  3. 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.
  4. 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 message:send triggerTurn, deliverAs: "steer" never automatically; the extension that consumes it replies
ask session_mail_ask triggerTurn, deliverAs: "steer" yes, with session_mail_reply or when the run settles
message session_mail_send, /mailbox:send triggerTurn, deliverAs: "steer" never
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

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 not waiting (a background ask, or one that timed out, that the user stopped, or whose session restarted) is delivered like a message: it starts a turn, and its label says the ask is not waiting for it. An unattended session waits rather than asking in the background because ending its turn ends its work: print mode exits, and an unattended TUI has no person to read a late answer.

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 is the sender line alone; it promises no answer.
  • An ask's label says its sender is waiting, gives the ask id and session_mail_reply as 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_send to 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 through an extension such as delegate, 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). An ask an abort dropped before that is answered failed. Asks are never held across a stop, because their sender is blocked waiting: a stopped run answers them stopped at once.

Envelopes

Every envelope is a JSON file with id, from, to, kind, in_reply_to, status, ts and body. kind is one of:

  • request, mail another extension sends through message:send; the extension that consumes it in the recipient answers it, if anything does.
  • reply, an answer that names what it answers in in_reply_to.
  • message, plain mail that expects no answer.
  • ask, a question whose sender waits for the answer, answered with session_mail_reply or when the recipient settles.

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.

An envelope written before kind existed is read as a reply when in_reply_to is non-empty and as a request otherwise.

There is no hop count or other limit on chains of mail: sessions can keep waking each other. An old envelope that still carries a hops property is read and delivered like any other, and the property is ignored.

Requests, asks and messages carry no status. A reply's status is one of done, stopped and failed.

The answer to an ask, from session_mail_reply (always done) or at settle, uses the status to say how the run ended, not whether the job succeeded, and pi-session-mail writes the body:

status Meaning
done the run settled; the body is its answer
stopped the user stopped the run before it settled; the body says so, then the partial text
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

A reply to a request is different: its consumer sets the status and the body through message:reply, and pi-session-mail writes them as given. It adds no wording and waits for nothing. What done, stopped and failed mean for a request is for the consumer to define.

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 pruneAfterDays days.

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.

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:

{ "pruneAfterDays": 30 }
Key Effect
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 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 (30 days), with one warning per session. A key this version does not know, such as the former hopLimit, is ignored.

It reads three environment variables: PI_SESSION_MAIL_ROOT and the standard XDG_STATE_HOME, at every call, to place the mail root (see How mail works), and PI_UNATTENDED, which session_mail_ask checks to tell whether a person is watching a TUI session (see the tools). 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 four hooks and consumes none. All four 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, peerPi is the extension API of the session at peer (in the examples that need both ends), 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"), in_reply_to, status, ts, body
error provider why nothing was written: no active session, or an invalid to or body

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. pi-session-mail never answers a request by itself: the extension that consumes it in the recipient session sends any reply, through message:reply.

// 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("hops" in payload.envelope, false);
// 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:reply

A consumer that took over a request emits { to, inReplyTo, body, status } to answer it, whenever its work is done. A provider writes a reply (kind: "reply") from its own session's address and sets envelope on the same object before emit returns, or sets error instead and writes nothing. If neither is set, no provider is installed, as with message:send.

Field Set by Meaning
to consumer the request's sender: a session id, checked as message:send checks it
inReplyTo consumer the ids of the requests this answers: a non-empty list of non-empty strings, at most 50
body consumer the reply text: any string, empty included
status consumer required: "done", "stopped" or "failed"; the consumer defines what each means for its request (see Envelopes)
envelope provider the written reply: id, from, to, kind ("reply"), in_reply_to, status, ts, body
error provider why nothing was written: no active session, an invalid field, or a failed write

The hook is for answering requests. The reply's from is always this session; a from on the payload is ignored. body and status are written exactly as given: the reply does not wait for the run to settle, does not use the model's answer and adds no wording. inReplyTo is checked for shape only: its ids are not looked up in this session's mailbox, so the hook does not check that they name requests this session received. Nothing stops a second reply to the same request either: every valid call writes one, and the consumer makes sure it answers once. An ask is answered with session_mail_reply or at settle, never through this hook, which leaves an ask's bookkeeping alone.

The reply goes through the mailbox like any other: one the sender is not running to receive waits in its inbox and is claimed when it starts again. The sender then gets it through message:inbound with its requests copies, and a listener there can take it over.

// The consumer in the peer session takes over the requests it receives.
const taken = [];
peerPi.events.on("message:inbound", (inbound) => {
  if (inbound.handled || inbound.envelope.kind !== "request") return;
  inbound.handled = true;
  taken.push(inbound.envelope);
});

// This session sends a request, and the peer claims it now.
const request = { to: peer, body: "Please run the test suite." };
pi.events.emit("message:send", request);
peerPi.events.emit("message:scan", {});
assert.equal(taken.length, 1);

// When its work is done, the consumer answers from the peer session.
const reply = { to: taken[0].from, inReplyTo: [taken[0].id], body: "All 42 tests pass.", status: "done" };
peerPi.events.emit("message:reply", reply);
assert.equal(reply.error, undefined);
assert.ok(reply.envelope, "a provider sets `envelope` before `emit` returns");
assert.equal(reply.envelope.kind, "reply");
assert.equal(reply.envelope.from, peer);

// The reply reaches this session through `message:inbound`, unchanged.
const replies = [];
pi.events.on("message:inbound", (inbound) => replies.push(inbound));
pi.events.emit("message:scan", {});
assert.equal(replies.length, 1);
const [{ envelope, requests }] = replies;
assert.deepEqual(envelope.in_reply_to, [request.envelope.id]);
assert.equal(envelope.body, "All 42 tests pass.");
assert.equal(envelope.status, "done");
assert.equal(requests[0].envelope.id, request.envelope.id);

// Invalid input sets `error` and writes nothing.
const unknown = { to: peer, inReplyTo: [request.envelope.id], body: "?", status: "maybe" };
pi.events.emit("message:reply", unknown);
assert.equal(unknown.envelope, undefined);
assert.match(unknown.error, /status/);

// With no provider installed, neither field is set.
const probe = { to: peer, inReplyTo: [request.envelope.id], body: "hello", status: "done" };
bareBus.emit("message:reply", 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, asks, messages and replies alike, except the reply to an ask this session is waiting on: that goes to the waiting session_mail_ask call as its result and is neither emitted nor injected.

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, in_reply_to, status, body and the rest; an old envelope without kind gets it 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). Handled mail counts as read, and an ask a listener handled is still answered when this session settles. A request arms no answer, handled or not: the listener that consumes it owns its reply. 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, except the reply to an ask this session is waiting on, which goes to the waiting session_mail_ask call instead. The listener 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 four hooks, message:send, message:reply, message:inbound and message: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, $PI_SESSION_MAIL_ROOT when absolute, else $XDG_STATE_HOME/pi-session-mail/, or ~/.local/state/pi-session-mail/ when XDG_STATE_HOME is unset or not absolute.
  • The envelope fields (id, from, to, kind, 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 /mailbox, /mailbox:send and /mailbox:prune commands.
  • The keys of session-mail.json, today pruneAfterDays.

Changing any of them needs a major version from 1.0 on, and a minor version while the package is at 0.x.

More

License

MIT