npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pi-session-mail

v0.2.2

Published

A file mailbox between Pi sessions on the same machine

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-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, show its address and name:

/mailbox

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 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:

  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 | /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_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, typing it with /mailbox or 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). 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 in in_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 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. /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: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, $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, 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 /mailbox command.
  • The keys of session-mail.json, today hopLimit and 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