pi-post 0.2.0 → 0.4.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/DESIGN.md CHANGED
@@ -1,10 +1,9 @@
1
1
  # Design
2
2
 
3
3
  pi-post is asynchronous message passing where the delivery endpoint is a
4
- model's context window. A maildir for pi sessions: addresses name
5
- conversations including ones that do not exist yet mail queues on disk,
6
- and "delivered" means the text entered the receiving agent's context at a
7
- safe point in its turn.
4
+ model's context window. A maildir for pi sessions: every session has an
5
+ address, messages queue on disk, and "delivered" means the text entered
6
+ the receiving agent's context at a safe point in its turn.
8
7
 
9
8
  Two contracts pin everything else: the **address derivation** and the
10
9
  **message schema**. Change either only with a version bump.
@@ -22,26 +21,42 @@ session start.
22
21
  inbox/
23
22
  s-1ce0cbe5fe96/ a session's mailbox
24
23
  01786137505631-a4c187c6.json
25
- w-e8f14204d058/ a standing mailbox (a *place*, not a process)
26
- 01786137509999-b2d411aa.json
27
24
  ```
28
25
 
29
26
  ## Addresses
30
27
 
31
- Two kinds, both stable, both 12 hex chars of SHA-256:
32
-
33
- - **Session address** `s-<hash of pi session id>` names a conversation.
34
- Survives restarts and `pi -c`; two sessions never share one.
35
- - **Standing address** `w-<hash of canonical directory path>` names a
36
- seat. Derived from `realpath()` of a directory, so it exists before any
37
- session does and after every session dies. Mail to a standing address is
38
- read by whichever session next opens that directory.
39
-
40
- The standing address is the load-bearing feature. A dispatched worker's
41
- worktree *is* its address: create the worktree, mail the brief to that
42
- path, start the session in it the brief lands in-context on turn one
43
- with no name coordination. A handoff to "the next session on this repo"
44
- is mail to the repo's standing address.
28
+ One kind: a **session address**, `s-` + 12 hex chars of SHA-256 of pi's
29
+ session id. It names a conversation, not a process — it survives
30
+ restarts and `pi -c`, and two sessions never share one.
31
+
32
+ Only sessions have addresses. **A directory path as a target is a
33
+ query, not an address**: it resolves to the session registered in that
34
+ directory (live outranks offline; a remaining tie is refused with
35
+ candidates listed). Nothing can be addressed that does not exist.
36
+
37
+ **Every handle resolves; no identifier is a dead end.** The registry is
38
+ a bidirectional directory: a target may be an address, a live session's
39
+ name, a directory path, or pi's own session id (or a unique prefix of
40
+ one hex-looking strings fall through to name matching when no session
41
+ id matches). In the other direction, listings carry each session's
42
+ resume handle (`pi --session …`, three UUID groups: the UUIDv7
43
+ timestamp plus random bits) beside its address, and `pi-post resolve
44
+ <handle>` prints the full record — name, address, session id, presence,
45
+ cwd, resume command. The address stays a hash on purpose: deriving it
46
+ from the session id would couple the wire contract to pi's id format
47
+ and, with UUIDv7, collide on prefixes for sessions started close
48
+ together. Surfacing the mapping the registry already stores gives the
49
+ same ergonomics without touching the contract. Printing a resume handle
50
+ is directory information, not lifecycle management — pi-post still
51
+ never spawns or resumes anything itself.
52
+
53
+ v0.2.0 had a second kind — standing addresses, one per directory, so
54
+ mail could wait for sessions that did not exist yet. Removed in v0.3.0:
55
+ in a busy repository, directory identity is not task identity, so
56
+ standing mail raced among concurrent sessions, delivered to the wrong
57
+ successor, and — because consumption is the receipt — misdelivered
58
+ *silently and destructively*. The lesson is recorded as a non-goal
59
+ below: how sessions come to exist is not the transport's business.
45
60
 
46
61
  ## Message schema (v1)
47
62
 
@@ -58,56 +73,58 @@ One message per file, named `<sentAt ms, 13 digits>-<8 hex nonce>.json`:
58
73
  }
59
74
  ```
60
75
 
61
- - `from.kind` is `"session"` or `"process"`. Process senders (an anvil run
62
- at exit, a Claude Code hook, a script) have no inbox; `from.address` is
63
- absent and the message may carry no `replyTo`.
76
+ - `from.kind` is `"session"` or `"process"`. Process senders (an anvil
77
+ run at exit, a Claude Code hook, a script) have no inbox; `from.address`
78
+ is absent and the message may carry no `replyTo`.
64
79
  - `replyTo` is pinned at dispatch so results route home automatically.
65
80
  - Body is plain text, capped at 32 KiB. A brief fits; a payload does not.
66
81
  Send a summary and a path, never file contents as state transfer.
67
82
 
68
83
  ## A message, end to end
69
84
 
70
- 1. Sender resolves the target: an explicit address, a directory path
71
- (→ standing), or a live session's name (→ session). Ambiguity is an
72
- error listing candidates, never a guess.
85
+ 1. Sender resolves the target: an explicit address, a live session's
86
+ name, or a directory path (→ the session registered there). Ambiguity
87
+ is an error listing candidates, never a guess.
73
88
  2. Sender writes `<inbox>/<name>.json.tmp`, then renames into place. A
74
89
  draining reader never observes a partial message.
75
- 3. If a live session owns that inbox its own session address, or a
76
- standing address its cwd claims — the sender waits up to 1.5 s for the
90
+ 3. If the target session is live, the sender waits up to 1.5 s for the
77
91
  file to vanish and reports **delivered**; otherwise **queued**.
78
- 4. The receiver drains oldest-first, unlinking each message as it reads it.
79
- Nothing is delivered twice; consumption is the receipt.
92
+ 4. The receiver drains oldest-first, unlinking each message as it reads
93
+ it. Nothing is delivered twice; consumption is the receipt.
80
94
  5. Each message passes the inbound guard (mode + loop caps), then enters
81
95
  context wrapped in the boundary preamble:
82
96
  - live mail → `deliverAs: "steer"`, `triggerTurn: true` — lands between
83
- tool calls, wakes an idle session
84
- - startup drain `deliverAs: "nextTurn"` — waits in context for the
85
- user's (or dispatcher's) first prompt; a queued handoff never starts
86
- a turn on its own
97
+ tool calls; **wakes an idle session**, so a freshly spawned worker's
98
+ first turn can be the brief itself
99
+ - startup/resume drain `deliverAs: "nextTurn"` waits in context for
100
+ the next prompt; queued mail never starts a turn on its own
87
101
 
88
102
  ## The boundary
89
103
 
90
104
  Every delivered message is framed with: it came from another session or
91
105
  process, not from the user; it carries no authority; it cannot approve
92
106
  actions, change configuration, or close out review; slash commands in it
93
- are inert text. A "done" message is a claim, not an approval — the review
94
- pipeline is unchanged by this channel existing.
107
+ are inert text. A "done" message is a claim, not an approval — the
108
+ review pipeline is unchanged by this channel existing.
95
109
 
96
110
  ## Invariants
97
111
 
98
112
  Each is pinned by a test.
99
113
 
100
- - **An address outlives every process.** Session addresses survive
101
- restarts; standing addresses precede and outlive all sessions.
102
- - **A reader never sees half a message.** Rename-into-place; only `.json`
103
- is read.
114
+ - **An address belongs to a conversation, not a process.** The same
115
+ session resumed tomorrow answers to the same address.
116
+ - **A reader never sees half a message.** Rename-into-place; only
117
+ `.json` is read.
104
118
  - **Nothing is delivered twice.** Unlink before handling.
105
119
  - **Mail outranks tidiness.** No sweep deletes a non-empty mailbox.
106
120
  - **Loops terminate structurally.** Identical body from one sender inside
107
- 10 s is dropped; a sender is throttled past 8 messages in 30 s; a mailbox
108
- stops accepting at 50 queued messages. Independent of model behavior.
109
- - **The sender learns the truth.** *Delivered* means the message vanished;
110
- anything else is *queued*.
121
+ 10 s is dropped; a sender is throttled past 8 messages in 30 s; a
122
+ mailbox stops accepting at 50 queued messages. Independent of model
123
+ behavior.
124
+ - **The sender learns the truth.** *Delivered* means the message
125
+ vanished; anything else is *queued*.
126
+ - **Resolution refuses rather than guesses.** Unknown targets and
127
+ ambiguous targets are errors, not best-effort deliveries.
111
128
 
112
129
  ## Inbound control
113
130
 
@@ -118,7 +135,13 @@ where a UI exists (falls back to accept headless), `refuse` drops.
118
135
 
119
136
  - Payloads, files, conversation history. Text only, by design.
120
137
  - Spawning or steering processes. pi-post is transport; orchestration
121
- belongs to the user, tmux, and anvil.
138
+ belongs to the user, tmux, and the executor.
139
+ - **Session lifecycle.** pi-post moves text between sessions that exist;
140
+ how sessions come to exist — and what context waits for sessions that
141
+ do not exist yet — is the caller's convention. Successor handoffs
142
+ belong in project memory (which any number of future sessions can
143
+ read), not in a consume-once message that exactly one arbitrary
144
+ session would destroy on reading.
122
145
  - Cross-machine anything. Two parties can reach each other exactly when
123
146
  they share a filesystem.
124
147
  - Messaging *into* other runtimes (e.g. Claude Code sessions). Inbound
@@ -128,6 +151,7 @@ where a UI exists (falls back to accept headless), `refuse` drops.
128
151
 
129
152
  The mailbox mechanics converge with [pi-peer](https://github.com/shift-labs-ai/pi-peer)
130
153
  (MIT), whose ARCHITECTURE.md and test-suite-as-specification informed this
131
- design, and with Claude Code's cross-session messaging boundary model.
132
- pi-post differs in its addressing (standing addresses for sessions that do
133
- not exist yet), first-class reply-to routing, and process senders.
154
+ design, and the boundary model follows Claude Code's cross-session
155
+ messaging. pi-post differs in pinned reply-to routing, process senders
156
+ via a standalone CLI, and wake-on-idle delivery that lets a message
157
+ start a freshly spawned session's first turn.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # pi-post
2
2
 
3
- Messages between [Pi](https://pi.dev) sessions — **including ones that
4
- don't exist yet**. Send briefs, findings, and handoffs between live
5
- sessions, future sessions, and processes, delivered straight into the
6
- receiving agent's context.
3
+ Messages between [Pi](https://pi.dev) sessions — **delivered mid-task,
4
+ or queued until they return**. Send briefs, findings, and handoffs
5
+ between sessions and processes, straight into the receiving agent's
6
+ context.
7
7
 
8
8
  ```
9
- ✓ send_message Queued for ~/dev/gtm (w-e8f14204d058).
9
+ ✓ send_message Delivered to cache-fix (~/dev/gtm-cache-fix).
10
10
  ```
11
11
 
12
12
  The receiving session gets the text at a safe point in its turn, marked as
@@ -26,7 +26,7 @@ carries no authority…
26
26
 
27
27
  Running several sessions means one of them regularly produces something
28
28
  another needs: a dispatch brief, a finding, a "gate green" from a finished
29
- autonomous run, a loose end for whoever opens the repo tomorrow. Without a
29
+ autonomous run, an answer another session is blocked on. Without a
30
30
  channel, that travels as scratch files plus you pointing sessions at them
31
31
  — storage was never the problem; *making the recipient look, exactly once,
32
32
  at the right moment* is.
@@ -37,16 +37,27 @@ smuggling state between sessions.
37
37
 
38
38
  ## What you get
39
39
 
40
- **Two addresses per session.** A *session address* names a conversation
41
- and survives restarts. A *standing address* names a directory it exists
42
- before any session does, so you can mail a worktree you just created or
43
- "the next session on this repo". Startup drains both; a queued handoff
44
- lands in-context on the first turn.
40
+ **An address that outlives the process.** A session address names a
41
+ conversation, not a process: the same session resumed tomorrow answers to
42
+ the same address, and mail queued while it was closed lands in-context on
43
+ resume. A directory path as a target is a *query* — it resolves to the
44
+ session registered in that directory, live sessions first, ambiguity
45
+ refused.
45
46
 
46
- **Two tools.** `send_message` sends text to a session, path, or address and
47
- reports **delivered** (consumed now) or **queued** (waiting on disk).
48
- `list_sessions` shows known sessions, presence, and queued mail. `/inbox`
49
- peeks without consuming.
47
+ **Every handle resolves.** Target a session by name, address, directory
48
+ path, or pi's own session id (or a unique prefix). In the other
49
+ direction, every listing carries the session's resume handle
50
+ `[pi --session …]`, run from its directory — so nothing pi-post shows
51
+ you is a dead end: anything you can see, you can message *and* reopen.
52
+
53
+ **Wake-on-idle delivery.** A message to an idle session starts its turn.
54
+ Spawn a worker in its worktree, send the brief — the brief *is* the
55
+ worker's first turn. No "check your mail" incantations.
56
+
57
+ **Two tools.** `send_message` sends text to a session, path, address, or
58
+ session id and reports **delivered** (consumed now) or **queued** (waiting
59
+ on disk). `list_sessions` shows known sessions, presence, queued mail, and
60
+ resume handles. `/inbox` peeks without consuming.
50
61
 
51
62
  **A CLI for everything that isn't a pi session.** `pi-post send` lets an
52
63
  autonomous run's exit hook, a Claude Code hook, or any script mail a
@@ -69,22 +80,22 @@ Nothing to enable; every session registers itself on startup.
69
80
 
70
81
  | Surface | Effect |
71
82
  |---|---|
72
- | `send_message` (tool) | Send text to a session, path, or address; reports **delivered** or **queued** |
73
- | `list_sessions` (tool) | Known sessions, presence, queued mail counts |
83
+ | `send_message` (tool) | Send text to a session, path, address, or session id; reports **delivered** or **queued** |
84
+ | `list_sessions` (tool) | Known sessions, presence, queued mail counts, resume handles |
74
85
  | `/inbox` | Peek at this session's queued messages without consuming them |
75
86
  | `/peers` | The `list_sessions` listing, without spending a model turn |
76
87
  | `pi-post send` (CLI) | Send from any process: `--to`, `--body`/stdin, `--from`, `--reply-to` |
88
+ | `pi-post resolve <handle>` (CLI) | One session's full record: name, address, session id, presence, cwd, resume command |
77
89
  | `pi-post list` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
78
90
 
79
91
  Ask in words; the model picks the tool.
80
92
 
81
93
  ```text
82
- Mail the brief to the new worktree at ~/dev/gtm-cache-fix, then I'll start
83
- a session there.
94
+ Send the brief to the session in ~/dev/gtm-cache-fix and let it start.
84
95
 
85
96
  Tell the session working on the dashboard that main moved.
86
97
 
87
- Leave a note for the next session on this repo about the flaky migration job.
98
+ Ask the session in the other terminal whether the migration finished.
88
99
  ```
89
100
 
90
101
  From a script or an autonomous run's exit hook:
@@ -96,15 +107,20 @@ pi-post send --to "$PI_POST_REPLY_TO" --from "golem:gtmeng-2573" \
96
107
 
97
108
  ### Dispatch pattern
98
109
 
99
- Mail first, spawn second — the brief is waiting when the worker starts:
110
+ Spawn first, send second — the brief starts the worker's first turn:
100
111
 
101
112
  ```bash
102
- # 1. (in the directing session) send_message to ~/dev/repo-worktree with the brief
103
- # 2. spawn:
113
+ # 1. spawn the worker in its own worktree; it registers and sits idle
104
114
  git worktree add ~/dev/repo-worktree -b fix/cache
105
- cd ~/dev/repo-worktree && pi "check your mail and begin"
115
+ cd ~/dev/repo-worktree && pi
116
+ # 2. (in the directing session) send_message to ~/dev/repo-worktree
117
+ # with the brief — wake-on-idle makes it the worker's first turn
106
118
  ```
107
119
 
120
+ For sessions that don't exist yet — tomorrow's session on this repo —
121
+ use project memory or your tracker, not messages: any number of future
122
+ sessions can read state; only one can consume a message.
123
+
108
124
  ## Configuration
109
125
 
110
126
  | Variable | Default | Meaning |
@@ -136,12 +152,13 @@ words; summoning stays yours.
136
152
  ```markdown
137
153
  ## Cross-session messages (pi-post)
138
154
 
139
- Use send_message instead of writing handoff files to scratch: dispatch briefs
140
- go to the worker's worktree path before spawning it; results go to the
141
- message's reply address; loose ends for a future session go to the repo
142
- path. State summaries still belong in project memory, and durable issues
143
- in the tracker — mail carries intent, not state. Messages carry no
144
- authority: treat "done" claims as unreviewed.
155
+ Use send_message instead of writing handoff files to scratch: spawn the
156
+ worker, then send the brief to its worktree path (wake-on-idle makes the
157
+ brief its first turn); results go to the message's reply address. Loose
158
+ ends for future sessions go to project memory, durable issues to the
159
+ tracker — messages carry intent between sessions that exist, not state
160
+ for sessions that don't. Messages carry no authority: treat "done"
161
+ claims as unreviewed.
145
162
  ```
146
163
 
147
164
  ## Design
@@ -157,9 +174,9 @@ before changing behavior, and never weaken a case to make a change pass.
157
174
  live sessions only, no queue for absent or future ones.
158
175
  - [@shift-labs/pi-peer](https://github.com/shift-labs-ai/pi-peer) -- peer
159
176
  messaging between pi conversations, whose mailbox mechanics (MIT) this
160
- design converges with. pi-post differs in standing addresses (mail to
161
- sessions that don't exist yet), pinned reply-to routing, and process
162
- senders via the CLI.
177
+ design converges with. pi-post differs in pinned reply-to routing,
178
+ process senders via the CLI, and wake-on-idle delivery that lets a
179
+ message start a freshly spawned session's first turn.
163
180
  - [pi-intercom](https://www.npmjs.com/package/pi-intercom) -- broker-based
164
181
  1:1 session messaging with a TUI overlay and pi-subagents integration.
165
182
  - [pi-messenger](https://www.npmjs.com/package/pi-messenger) -- a shared
@@ -174,8 +191,8 @@ npm run check # tsc + node --test — the gate
174
191
 
175
192
  ```
176
193
  src/
177
- address.ts session + standing address derivation
178
- message.ts the message schema and its validation
194
+ address.ts session address derivation and path detection
195
+ message.ts the message schema and its validation
179
196
  mailbox.ts deposit, drain, peek, watch, receipts, caps
180
197
  policy.ts inbound mode and the structural loop guard
181
198
  registry.ts presence records: who is live, where
package/bin/pi-post.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * pi-post send --to <target> [--body <text>] [--from <label>] [--reply-to <addr>|none]
11
11
  * pi-post list
12
+ * pi-post resolve <target>
12
13
  * pi-post peek <target>
13
14
  * pi-post whoami
14
15
  *
@@ -31,13 +32,14 @@ import { basename, isAbsolute, join, resolve } from "node:path";
31
32
 
32
33
  const MAX_BODY_BYTES = 32 * 1024;
33
34
  const BACKLOG_CAP = 50;
34
- const ADDRESS_RE = /^[sw]-[0-9a-f]{12}$/;
35
+ const ADDRESS_RE = /^s-[0-9a-f]{12}$/;
35
36
 
36
37
  const root = process.env.PI_POST_DIR || join(homedir(), ".pi", "agent", "post");
37
38
 
38
39
  const h12 = (input) => createHash("sha256").update(input).digest("hex").slice(0, 12);
39
40
  const sessionAddress = (sessionId) => `s-${h12(`session\0${sessionId}`)}`;
40
- const standingAddress = (dir) => `w-${h12(`standing\0${dir}`)}`;
41
+ const looksLikeSessionId = (t) => /^[0-9a-f]{8}[0-9a-f-]{0,28}$/i.test(t);
42
+ const resumeHandle = (sessionId) => (sessionId.length > 18 ? sessionId.slice(0, 18) : sessionId);
41
43
 
42
44
  function canonicalPath(path) {
43
45
  let expanded = path;
@@ -86,36 +88,50 @@ function pidAlive(pid) {
86
88
 
87
89
  const isLive = (record) => record.pid !== undefined && pidAlive(record.pid);
88
90
 
91
+
92
+
93
+ function fail(message) {
94
+ console.error(`pi-post: ${message}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ /** Live sessions outrank offline ones; a remaining tie is refused, never guessed. */
99
+ function pick(target, matches) {
100
+ const live = matches.filter(isLive);
101
+ const pool = live.length > 0 ? live : matches;
102
+ if (pool.length === 1) {
103
+ const record = pool[0];
104
+ return { address: record.address, display: `${record.name} (${record.cwd})`, record };
105
+ }
106
+ fail(`"${target}" matches more than one session; use an address:\n` +
107
+ pool.map((r) => ` ${r.name} (${r.address}) — ${r.cwd}`).join("\n"));
108
+ }
109
+
89
110
  function resolveTarget(target) {
90
111
  const t = target.trim();
91
112
  if (ADDRESS_RE.test(t)) {
92
113
  return { address: t, display: t, record: listRecords().find((r) => r.address === t) };
93
114
  }
115
+ const records = listRecords();
94
116
  if (looksLikePath(t)) {
95
117
  const canonical = canonicalPath(t);
96
- return { address: standingAddress(canonical), display: canonical };
97
- }
98
- const records = listRecords();
99
- const byName = records.filter((r) => r.name === t);
100
- let matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === t);
101
- if (matches.length > 1) {
102
- const live = matches.filter(isLive);
103
- if (live.length === 1) matches = live;
118
+ const matches = records.filter((r) => r.cwd === canonical);
119
+ if (matches.length === 0) {
120
+ fail(`no session is registered in ${canonical} — a directory names the session running in it`);
121
+ }
122
+ return pick(t, matches);
104
123
  }
105
- if (matches.length === 1) {
106
- const record = matches[0];
107
- return { address: record.address, display: `${record.name} (${record.cwd})`, record };
124
+ if (looksLikeSessionId(t)) {
125
+ const bySessionId = records.filter((r) => r.sessionId.toLowerCase().startsWith(t.toLowerCase()));
126
+ if (bySessionId.length > 0) return pick(t, bySessionId);
127
+ // fall through: a hex-looking string may still be a session name
108
128
  }
109
- if (matches.length > 1) {
110
- fail(`"${t}" matches more than one session; use an address:\n` +
111
- matches.map((r) => ` ${r.name} (${r.address}) — ${r.cwd}`).join("\n"));
129
+ const byName = records.filter((r) => r.name === t);
130
+ const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === t);
131
+ if (matches.length === 0) {
132
+ fail(`"${t}" is not an address, a session id, a directory with a registered session, or a known session name`);
112
133
  }
113
- fail(`"${t}" is not an address, a directory path, or a known session name`);
114
- }
115
-
116
- function fail(message) {
117
- console.error(`pi-post: ${message}`);
118
- process.exit(1);
134
+ return pick(t, matches);
119
135
  }
120
136
 
121
137
  function parseArgs(argv) {
@@ -183,10 +199,7 @@ async function send(args) {
183
199
  writeFileSync(`${path}.tmp`, JSON.stringify(message), { mode: 0o600 });
184
200
  renameSync(`${path}.tmp`, path);
185
201
 
186
- const live = target.record
187
- ? isLive(target.record)
188
- : target.address.startsWith("w-") &&
189
- listRecords().some((r) => r.standing === target.address && isLive(r));
202
+ const live = target.record ? isLive(target.record) : false;
190
203
  let consumed = false;
191
204
  if (live) {
192
205
  const deadline = Date.now() + 1500;
@@ -202,6 +215,14 @@ async function send(args) {
202
215
  console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
203
216
  }
204
217
 
218
+ function queuedCount(address) {
219
+ try {
220
+ return readdirSync(join(root, "inbox", address)).filter((n) => n.endsWith(".json")).length;
221
+ } catch {
222
+ return 0;
223
+ }
224
+ }
225
+
205
226
  function list() {
206
227
  const records = listRecords().sort((a, b) => b.lastSeen - a.lastSeen);
207
228
  if (records.length === 0) {
@@ -209,16 +230,33 @@ function list() {
209
230
  return;
210
231
  }
211
232
  for (const record of records) {
212
- const queued = (() => {
213
- try {
214
- return readdirSync(join(root, "inbox", record.address)).filter((n) => n.endsWith(".json")).length;
215
- } catch {
216
- return 0;
217
- }
218
- })();
233
+ const queued = queuedCount(record.address);
219
234
  const mail = queued > 0 ? `, ${queued} queued` : "";
220
- console.log(`${record.name} — ${record.address} (${isLive(record) ? "live" : "offline"}${mail}) ${record.cwd}`);
235
+ console.log(
236
+ `${record.name} — ${record.address} (${isLive(record) ? "live" : "offline"}${mail}) ${record.cwd} ` +
237
+ `[pi --session ${resumeHandle(record.sessionId)}]`,
238
+ );
239
+ }
240
+ }
241
+
242
+ /** The directory answer for one session: every handle it has, in both directions. */
243
+ function resolveCmd(args) {
244
+ const targetArg = args._[1];
245
+ if (!targetArg) fail("resolve requires a target (address, session id, path, or session name)");
246
+ const target = resolveTarget(targetArg);
247
+ const record = target.record;
248
+ if (!record) {
249
+ console.log(`address: ${target.address}`);
250
+ console.log("no registry record — the session never registered here, or its record was swept");
251
+ return;
221
252
  }
253
+ const queued = queuedCount(record.address);
254
+ console.log(`name: ${record.name}`);
255
+ console.log(`address: ${record.address}`);
256
+ console.log(`session: ${record.sessionId}`);
257
+ console.log(`presence: ${isLive(record) ? "live" : "offline"}${queued > 0 ? `, ${queued} queued` : ""}`);
258
+ console.log(`cwd: ${record.cwd}`);
259
+ console.log(`resume: cd ${record.cwd} && pi --session ${resumeHandle(record.sessionId)}`);
222
260
  }
223
261
 
224
262
  function peek(args) {
@@ -262,6 +300,9 @@ switch (command) {
262
300
  case "list":
263
301
  list();
264
302
  break;
303
+ case "resolve":
304
+ resolveCmd(args);
305
+ break;
265
306
  case "peek":
266
307
  peek(args);
267
308
  break;
@@ -270,6 +311,6 @@ switch (command) {
270
311
  break;
271
312
  default:
272
313
  console.log("usage: pi-post send --to <target> [--body <text>] [--from <label>] [--reply-to <addr>|none]");
273
- console.log(" pi-post list | peek <target> | whoami");
314
+ console.log(" pi-post list | resolve <target> | peek <target> | whoami");
274
315
  process.exit(command ? 1 : 0);
275
316
  }
@@ -7,7 +7,7 @@ import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
8
  import { basename } from "node:path";
9
9
  import type { FSWatcher } from "node:fs";
10
- import { addressKind, canonicalPath, sessionAddress, standingAddress } from "../src/address.ts";
10
+ import { canonicalPath, sessionAddress } from "../src/address.ts";
11
11
  import { createMessage, type Message } from "../src/message.ts";
12
12
  import {
13
13
  awaitConsumption,
@@ -25,7 +25,6 @@ import {
25
25
  listRecords,
26
26
  markOffline,
27
27
  presence,
28
- standingClaimedLive,
29
28
  sweepRegistry,
30
29
  touchRecord,
31
30
  writeRecord,
@@ -39,7 +38,6 @@ export default function (pi: ExtensionAPI) {
39
38
  const guard = new LoopGuard();
40
39
 
41
40
  let selfAddress: string | undefined;
42
- let selfStanding: string | undefined;
43
41
  let selfName = "pi";
44
42
  let watchers: FSWatcher[] = [];
45
43
  let heartbeat: ReturnType<typeof setInterval> | undefined;
@@ -75,13 +73,10 @@ export default function (pi: ExtensionAPI) {
75
73
  }
76
74
 
77
75
  async function drainAll(ctx: ExtensionContext, deliverAs: "steer" | "nextTurn") {
78
- if (draining || !selfAddress || !selfStanding) return;
76
+ if (draining || !selfAddress) return;
79
77
  draining = true;
80
78
  try {
81
- const messages = [...drain(root, selfAddress), ...drain(root, selfStanding)].sort(
82
- (a, b) => a.sentAt - b.sentAt,
83
- );
84
- for (const message of messages) await deliver(ctx, message, deliverAs);
79
+ for (const message of drain(root, selfAddress)) await deliver(ctx, message, deliverAs);
85
80
  } finally {
86
81
  draining = false;
87
82
  }
@@ -91,7 +86,6 @@ export default function (pi: ExtensionAPI) {
91
86
  const sessionId = ctx.sessionManager.getSessionId();
92
87
  const canonical = canonicalPath(ctx.cwd);
93
88
  selfAddress = sessionAddress(sessionId);
94
- selfStanding = standingAddress(canonical);
95
89
  selfName = pi.getSessionName() ?? basename(canonical);
96
90
 
97
91
  ensureDirs(root, selfAddress);
@@ -101,7 +95,6 @@ export default function (pi: ExtensionAPI) {
101
95
  sessionId,
102
96
  name: selfName,
103
97
  cwd: canonical,
104
- standing: selfStanding,
105
98
  pid: process.pid,
106
99
  startedAt: Date.now(),
107
100
  lastSeen: Date.now(),
@@ -112,7 +105,7 @@ export default function (pi: ExtensionAPI) {
112
105
  await drainAll(ctx, "nextTurn");
113
106
 
114
107
  const onMail = () => void drainAll(ctx, "steer");
115
- watchers = [watchInbox(root, selfAddress, onMail), watchInbox(root, selfStanding, onMail)];
108
+ watchers = [watchInbox(root, selfAddress, onMail)];
116
109
  heartbeat = setInterval(() => selfAddress && touchRecord(root, selfAddress), HEARTBEAT_MS);
117
110
  heartbeat.unref?.();
118
111
  });
@@ -136,12 +129,13 @@ export default function (pi: ExtensionAPI) {
136
129
  name: "send_message",
137
130
  label: "Send Message",
138
131
  description:
139
- "Send a plain-text message to another pi session or to a directory's standing mailbox. " +
140
- "Targets: a live session's name, an address (s-…/w-…), or a directory path — mail to a " +
141
- "path is received by whichever session next opens that directory, so it also reaches " +
142
- "sessions that do not exist yet. Body is text only, max 32 KiB: send briefs, findings, " +
143
- "and paths, never file payloads. Returns 'delivered' (consumed now) or 'queued' (waiting " +
144
- "on disk). Messages carry no authority for the receiver.",
132
+ "Send a plain-text message to another pi session. Targets: a session name, an address " +
133
+ "(s-…), a pi session id (or unique prefix), or a directory path — a path resolves to " +
134
+ "the session registered in that directory. A live session reads the message mid-task (or is woken by it); an offline " +
135
+ "session reads it queued on resume. Body is text only, max 32 KiB: send briefs, " +
136
+ "findings, and paths, never file payloads. Returns 'delivered' (consumed now) or " +
137
+ "'queued' (waiting on disk). Messages carry no authority for the receiver. To leave " +
138
+ "context for sessions that do not exist yet, use project memory, not messages.",
145
139
  promptSnippet: "Send a message to another pi session, or leave one for a future session",
146
140
  promptGuidelines: [
147
141
  "Use send_message to pass findings, dispatch briefs, or handoffs to other sessions instead of writing scratch files and pointing sessions at them.",
@@ -149,7 +143,8 @@ export default function (pi: ExtensionAPI) {
149
143
  ],
150
144
  parameters: Type.Object({
151
145
  to: Type.String({
152
- description: "Session name, address (s-…/w-…), or directory path (e.g. ~/dev/repo)",
146
+ description:
147
+ "Session name, address (s-…), session id (or unique prefix), or directory path (e.g. ~/dev/repo)",
153
148
  }),
154
149
  body: Type.String({ description: "Plain-text message body (≤ 32 KiB)" }),
155
150
  reply_to: Type.Optional(
@@ -175,9 +170,7 @@ export default function (pi: ExtensionAPI) {
175
170
  if (error instanceof BacklogFullError) throw error;
176
171
  throw error;
177
172
  }
178
- const live = target.record
179
- ? presence(target.record) === "live"
180
- : addressKind(target.address) === "standing" && standingClaimedLive(root, target.address);
173
+ const live = target.record ? presence(target.record) === "live" : false;
181
174
  const consumed = live ? await awaitConsumption(path) : false;
182
175
  const status = consumed ? "delivered" : "queued";
183
176
  return {
@@ -196,10 +189,12 @@ export default function (pi: ExtensionAPI) {
196
189
  name: "list_sessions",
197
190
  label: "List Sessions",
198
191
  description:
199
- "List pi sessions known to pi-post: their names, addresses, presence (live/offline), and " +
200
- "queued mail counts. Any directory path is also a valid send_mail target even if nothing " +
201
- "is listed for it.",
202
- promptSnippet: "List pi sessions reachable by message, with presence and queued mail",
192
+ "List pi sessions known to pi-post: their names, addresses, presence (live/offline), " +
193
+ "queued mail counts, and each session's resume handle ([pi --session …], run from the " +
194
+ "listed directory). Any directory path is also a valid send_message target even if " +
195
+ "nothing is listed for it.",
196
+ promptSnippet:
197
+ "List pi sessions reachable by message, with presence, queued mail, and resume handles",
203
198
  parameters: Type.Object({}),
204
199
  async execute() {
205
200
  const text = formatListing(root, listRecords(root), selfAddress);
@@ -217,10 +212,8 @@ export default function (pi: ExtensionAPI) {
217
212
  pi.registerCommand("inbox", {
218
213
  description: "Peek at this session's queued pi-post messages without consuming them",
219
214
  handler: async (_args, ctx) => {
220
- if (!selfAddress || !selfStanding) return;
221
- const messages = [...peek(root, selfAddress), ...peek(root, selfStanding)].sort(
222
- (a, b) => a.sentAt - b.sentAt,
223
- );
215
+ if (!selfAddress) return;
216
+ const messages = peek(root, selfAddress);
224
217
  if (messages.length === 0) {
225
218
  ctx.ui.notify("Inbox empty.", "info");
226
219
  return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-post",
3
- "version": "0.2.0",
4
- "description": "Messages between pi sessions — including ones that don't exist yet. Briefs, findings, and handoffs delivered straight into the receiving agent's context.",
3
+ "version": "0.4.0",
4
+ "description": "Messages between pi sessions — delivered mid-task or queued until they return. Briefs, findings, and handoffs straight into the receiving agent's context.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
package/src/address.ts CHANGED
@@ -3,10 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
 
6
- /** A session address names a conversation; a standing address names a place. */
7
- export type AddressKind = "session" | "standing";
8
-
9
- const ADDRESS_RE = /^[sw]-[0-9a-f]{12}$/;
6
+ const ADDRESS_RE = /^s-[0-9a-f]{12}$/;
10
7
 
11
8
  function h12(input: string): string {
12
9
  return createHash("sha256").update(input).digest("hex").slice(0, 12);
@@ -17,11 +14,6 @@ export function sessionAddress(sessionId: string): string {
17
14
  return `s-${h12(`session\0${sessionId}`)}`;
18
15
  }
19
16
 
20
- /** Stable address for a directory. Exists before and after any session. */
21
- export function standingAddress(canonicalDir: string): string {
22
- return `w-${h12(`standing\0${canonicalDir}`)}`;
23
- }
24
-
25
17
  /**
26
18
  * Canonicalize a directory path: expand `~`, resolve against `cwd`, and
27
19
  * follow symlinks when the path exists so aliases share one address.
@@ -43,11 +35,16 @@ export function isAddress(value: string): boolean {
43
35
  return ADDRESS_RE.test(value);
44
36
  }
45
37
 
46
- export function addressKind(address: string): AddressKind {
47
- return address.startsWith("s-") ? "session" : "standing";
38
+ /**
39
+ * Heuristic: does this target look like a pi session id, or a prefix of one?
40
+ * At least 8 leading hex chars keeps short hex-looking names out; resolution
41
+ * still falls through to name matching when no session id matches.
42
+ */
43
+ export function looksLikeSessionId(value: string): boolean {
44
+ return /^[0-9a-f]{8}[0-9a-f-]{0,28}$/i.test(value);
48
45
  }
49
46
 
50
- /** Heuristic: does this target string denote a path rather than a name? */
47
+ /** Heuristic: does this target string denote a path (a query for the session running there)? */
51
48
  export function looksLikePath(target: string): boolean {
52
49
  return (
53
50
  target === "~" ||
package/src/format.ts CHANGED
@@ -23,18 +23,31 @@ export function formatDelivery(message: Message): string {
23
23
  ].join("\n");
24
24
  }
25
25
 
26
+ /**
27
+ * Short resume handle for a pi session id. Three UUID groups: the UUIDv7
28
+ * millisecond timestamp plus 12 random bits — unique in practice even for
29
+ * sessions spawned in the same second, short enough to read and copy.
30
+ */
31
+ export function resumeHandle(sessionId: string): string {
32
+ return sessionId.length > 18 ? sessionId.slice(0, 18) : sessionId;
33
+ }
34
+
26
35
  export function formatListing(root: string, records: SessionRecord[], selfAddress?: string): string {
27
36
  const lines: string[] = [];
28
37
  for (const record of [...records].sort((a, b) => b.lastSeen - a.lastSeen)) {
29
38
  const self = record.address === selfAddress ? " [self]" : "";
30
39
  const queued = queuedCount(root, record.address);
31
40
  const mail = queued > 0 ? `, ${queued} queued` : "";
32
- lines.push(`${record.name} — ${record.address} (${presence(record)}${mail})${self} ${record.cwd}`);
41
+ lines.push(
42
+ `${record.name} — ${record.address} (${presence(record)}${mail})${self} ${record.cwd} ` +
43
+ `[pi --session ${resumeHandle(record.sessionId)}]`,
44
+ );
33
45
  }
34
46
  if (lines.length === 0) lines.push("No registered sessions.");
35
47
  lines.push(
36
48
  "",
37
- "Any directory is also addressable: send to a path and whichever session next opens it receives the message.",
49
+ "A directory path as a target resolves to the session registered in it.",
50
+ "Reopen a session with its bracketed pi --session command, run from its directory.",
38
51
  );
39
52
  return lines.join("\n");
40
53
  }
package/src/registry.ts CHANGED
@@ -9,8 +9,6 @@ export interface SessionRecord {
9
9
  /** Display name: pi session name when set, else the cwd's basename. */
10
10
  name: string;
11
11
  cwd: string;
12
- /** Standing address of the session's canonical cwd. */
13
- standing: string;
14
12
  pid?: number;
15
13
  startedAt: number;
16
14
  lastSeen: number;
@@ -80,10 +78,6 @@ export function presence(record: SessionRecord): Presence {
80
78
  return record.pid !== undefined && pidAlive(record.pid) ? "live" : "offline";
81
79
  }
82
80
 
83
- /** True when a live session's cwd claims this standing address. */
84
- export function standingClaimedLive(root: string, standingAddress: string): boolean {
85
- return listRecords(root).some((r) => r.standing === standingAddress && presence(r) === "live");
86
- }
87
81
 
88
82
  /** Remove registry records for sessions that are offline and stale. Mail is never touched. */
89
83
  export function sweepRegistry(root: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): void {
package/src/resolve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path";
2
- import { canonicalPath, isAddress, looksLikePath, standingAddress } from "./address.ts";
2
+ import { canonicalPath, isAddress, looksLikePath, looksLikeSessionId } from "./address.ts";
3
3
  import { listRecords, presence, type SessionRecord } from "./registry.ts";
4
4
 
5
5
  export interface ResolvedTarget {
@@ -18,18 +18,28 @@ export class AmbiguousTargetError extends Error {
18
18
  }
19
19
 
20
20
  export class UnknownTargetError extends Error {
21
- constructor(target: string) {
22
- super(`"${target}" is not an address, a directory path, or a known session name`);
21
+ constructor(message: string) {
22
+ super(message);
23
23
  this.name = "UnknownTargetError";
24
24
  }
25
25
  }
26
26
 
27
+ /** Live sessions outrank offline ones; a remaining tie is refused, never guessed. */
28
+ function pick(target: string, matches: SessionRecord[]): ResolvedTarget {
29
+ const live = matches.filter((r) => presence(r) === "live");
30
+ const pool = live.length > 0 ? live : matches;
31
+ if (pool.length === 1) {
32
+ const record = pool[0]!;
33
+ return { address: record.address, display: `${record.name} (${record.cwd})`, record };
34
+ }
35
+ throw new AmbiguousTargetError(target, pool);
36
+ }
37
+
27
38
  /**
28
- * Resolve a target string to an address. Refuses rather than guesses:
29
- * - an explicit address is taken as-is
30
- * - anything path-shaped becomes the directory's standing address
31
- * - otherwise it must match exactly one registered session by name
32
- * (live sessions outrank offline ones before ambiguity is declared)
39
+ * Resolve a target string to a session address. Every handle a session has
40
+ * resolves: an address, a directory path (a *query* for the session
41
+ * registered there), pi's own session id (or a unique prefix), or a name.
42
+ * Refuses rather than guesses.
33
43
  */
34
44
  export function resolveTarget(root: string, target: string, cwd?: string): ResolvedTarget {
35
45
  const trimmed = target.trim();
@@ -38,23 +48,33 @@ export function resolveTarget(root: string, target: string, cwd?: string): Resol
38
48
  return { address: trimmed, display: record ? `${record.name} (${record.cwd})` : trimmed, record };
39
49
  }
40
50
 
51
+ const records = listRecords(root);
52
+
41
53
  if (looksLikePath(trimmed)) {
42
54
  const canonical = canonicalPath(trimmed, cwd);
43
- return { address: standingAddress(canonical), display: canonical };
55
+ const matches = records.filter((r) => r.cwd === canonical);
56
+ if (matches.length === 0) {
57
+ throw new UnknownTargetError(
58
+ `no session is registered in ${canonical} — a directory names the session running in it. ` +
59
+ "Spawn the session first, or leave context for future sessions in project memory instead.",
60
+ );
61
+ }
62
+ return pick(trimmed, matches);
44
63
  }
45
64
 
46
- const records = listRecords(root);
47
- const byName = records.filter((r) => r.name === trimmed);
48
- const byBase = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === trimmed);
49
- let matches = byBase;
50
- if (matches.length > 1) {
51
- const live = matches.filter((r) => presence(r) === "live");
52
- if (live.length === 1) matches = live;
65
+ if (looksLikeSessionId(trimmed)) {
66
+ const lower = trimmed.toLowerCase();
67
+ const bySessionId = records.filter((r) => r.sessionId.toLowerCase().startsWith(lower));
68
+ if (bySessionId.length > 0) return pick(trimmed, bySessionId);
69
+ // fall through: a hex-looking string may still be a session name
53
70
  }
54
- if (matches.length === 1) {
55
- const record = matches[0]!;
56
- return { address: record.address, display: `${record.name} (${record.cwd})`, record };
71
+
72
+ const byName = records.filter((r) => r.name === trimmed);
73
+ const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === trimmed);
74
+ if (matches.length === 0) {
75
+ throw new UnknownTargetError(
76
+ `"${trimmed}" is not an address, a session id, a directory with a registered session, or a known session name`,
77
+ );
57
78
  }
58
- if (matches.length > 1) throw new AmbiguousTargetError(trimmed, matches);
59
- throw new UnknownTargetError(trimmed);
79
+ return pick(trimmed, matches);
60
80
  }