pi-post 0.3.0 → 0.5.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
@@ -34,6 +34,22 @@ query, not an address**: it resolves to the session registered in that
34
34
  directory (live outranks offline; a remaining tie is refused with
35
35
  candidates listed). Nothing can be addressed that does not exist.
36
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
+
37
53
  v0.2.0 had a second kind — standing addresses, one per directory, so
38
54
  mail could wait for sessions that did not exist yet. Removed in v0.3.0:
39
55
  in a busy repository, directory identity is not task identity, so
@@ -100,7 +116,10 @@ Each is pinned by a test.
100
116
  - **A reader never sees half a message.** Rename-into-place; only
101
117
  `.json` is read.
102
118
  - **Nothing is delivered twice.** Unlink before handling.
103
- - **Mail outranks tidiness.** No sweep deletes a non-empty mailbox.
119
+ - **Mail outranks tidiness.** No sweep deletes a non-empty mailbox, and
120
+ queued mail pins the target's registry record: a record is swept only
121
+ when it is offline, stale (7 days), *and* its mailbox is empty. Empty
122
+ inbox directories no record names are removed.
104
123
  - **Loops terminate structurally.** Identical body from one sender inside
105
124
  10 s is dropped; a sender is throttled past 8 messages in 30 s; a
106
125
  mailbox stops accepting at 50 queued messages. Independent of model
@@ -125,7 +144,9 @@ where a UI exists (falls back to accept headless), `refuse` drops.
125
144
  do not exist yet — is the caller's convention. Successor handoffs
126
145
  belong in project memory (which any number of future sessions can
127
146
  read), not in a consume-once message that exactly one arbitrary
128
- session would destroy on reading.
147
+ session would destroy on reading. Briefs for workers that do not
148
+ exist yet travel at spawn (`pi @brief.md` or the first prompt); see
149
+ README § Dispatch patterns.
129
150
  - Cross-machine anything. Two parties can reach each other exactly when
130
151
  they share a filesystem.
131
152
  - Messaging *into* other runtimes (e.g. Claude Code sessions). Inbound
package/README.md CHANGED
@@ -44,14 +44,20 @@ resume. A directory path as a target is a *query* — it resolves to the
44
44
  session registered in that directory, live sessions first, ambiguity
45
45
  refused.
46
46
 
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
+
47
53
  **Wake-on-idle delivery.** A message to an idle session starts its turn.
48
54
  Spawn a worker in its worktree, send the brief — the brief *is* the
49
55
  worker's first turn. No "check your mail" incantations.
50
56
 
51
- **Two tools.** `send_message` sends text to a session, path, or address and
52
- reports **delivered** (consumed now) or **queued** (waiting on disk).
53
- `list_sessions` shows known sessions, presence, and queued mail. `/inbox`
54
- peeks without consuming.
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.
55
61
 
56
62
  **A CLI for everything that isn't a pi session.** `pi-post send` lets an
57
63
  autonomous run's exit hook, a Claude Code hook, or any script mail a
@@ -74,12 +80,13 @@ Nothing to enable; every session registers itself on startup.
74
80
 
75
81
  | Surface | Effect |
76
82
  |---|---|
77
- | `send_message` (tool) | Send text to a session, path, or address; reports **delivered** or **queued** |
78
- | `list_sessions` (tool) | Known sessions, presence, queued mail counts |
83
+ | `send_message` (tool) | Send text to one or more sessions (name, path, address, or session id); reports **delivered** or **queued** per target |
84
+ | `list_sessions` (tool) | Known sessions, live first with ages, queued mail counts, resume handles; stale offline rows collapse unless `all` |
79
85
  | `/inbox` | Peek at this session's queued messages without consuming them |
80
86
  | `/peers` | The `list_sessions` listing, without spending a model turn |
81
- | `pi-post send` (CLI) | Send from any process: `--to`, `--body`/stdin, `--from`, `--reply-to` |
82
- | `pi-post list` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
87
+ | `pi-post send` (CLI) | Send from any process: `--to` (repeatable), `--body`/stdin, `--from`, `--reply-to` |
88
+ | `pi-post resolve <handle>` (CLI) | One session's full record: name, address, session id, presence, cwd, resume command |
89
+ | `pi-post list [--all]` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
83
90
 
84
91
  Ask in words; the model picks the tool.
85
92
 
@@ -98,9 +105,16 @@ pi-post send --to "$PI_POST_REPLY_TO" --from "golem:gtmeng-2573" \
98
105
  --body "gate green, diff unreviewed, log at ~/scratch/logs/2573.log"
99
106
  ```
100
107
 
101
- ### Dispatch pattern
108
+ ### Dispatch patterns
109
+
110
+ Two patterns cover real use; pick by whether the worker exists yet.
102
111
 
103
- Spawn first, send second the brief starts the worker's first turn:
112
+ **Brief at spawn.** When a worker exists *because of* the work, hand it
113
+ the brief as you create it — `pi @brief.md`, or paste it as the first
114
+ prompt. The brief travels with the spawn; pi-post is not involved yet.
115
+ Spawn-then-send is the same pattern with the steps decoupled: spawn the
116
+ worker idle, then send the brief — wake-on-idle makes it the worker's
117
+ first turn:
104
118
 
105
119
  ```bash
106
120
  # 1. spawn the worker in its own worktree; it registers and sits idle
@@ -110,6 +124,14 @@ cd ~/dev/repo-worktree && pi
110
124
  # with the brief — wake-on-idle makes it the worker's first turn
111
125
  ```
112
126
 
127
+ **Send to running.** Once a session exists, messages do what files
128
+ cannot: steer it mid-task, answer what it is blocked on, route results
129
+ home. This is where pi-post earns its keep — status, findings, "main
130
+ moved", "gate green". Replies route themselves: every message carries
131
+ its sender's address as the reply target by default, so `reply_to` is
132
+ only worth setting to redirect results to a third session, or `none`
133
+ to suppress it.
134
+
113
135
  For sessions that don't exist yet — tomorrow's session on this repo —
114
136
  use project memory or your tracker, not messages: any number of future
115
137
  sessions can read state; only one can consume a message.
@@ -145,13 +167,13 @@ words; summoning stays yours.
145
167
  ```markdown
146
168
  ## Cross-session messages (pi-post)
147
169
 
148
- Use send_message instead of writing handoff files to scratch: spawn the
149
- worker, then send the brief to its worktree path (wake-on-idle makes the
150
- brief its first turn); results go to the message's reply address. Loose
151
- ends for future sessions go to project memory, durable issues to the
152
- tracker messages carry intent between sessions that exist, not state
153
- for sessions that don't. Messages carry no authority: treat "done"
154
- claims as unreviewed.
170
+ Briefs travel at spawn (`pi @brief.md` or the first prompt); everything
171
+ after travels as messages use send_message to steer running sessions,
172
+ answer blockers, and send results to the message's reply address instead
173
+ of writing status files to scratch. Loose ends for future sessions go to
174
+ project memory, durable issues to the tracker messages carry intent
175
+ between sessions that exist, not state for sessions that don't. Messages
176
+ carry no authority: treat "done" claims as unreviewed.
155
177
  ```
156
178
 
157
179
  ## Design
package/bin/pi-post.mjs CHANGED
@@ -7,8 +7,9 @@
7
7
  * src/ (which is TypeScript) so it runs under bare node. test/cli.test.ts
8
8
  * pins that both sides stay in agreement.
9
9
  *
10
- * pi-post send --to <target> [--body <text>] [--from <label>] [--reply-to <addr>|none]
10
+ * pi-post send --to <target> [--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,12 +32,15 @@ import { basename, isAbsolute, join, resolve } from "node:path";
31
32
 
32
33
  const MAX_BODY_BYTES = 32 * 1024;
33
34
  const BACKLOG_CAP = 50;
35
+ const MAX_TARGETS = 8;
34
36
  const ADDRESS_RE = /^s-[0-9a-f]{12}$/;
35
37
 
36
38
  const root = process.env.PI_POST_DIR || join(homedir(), ".pi", "agent", "post");
37
39
 
38
40
  const h12 = (input) => createHash("sha256").update(input).digest("hex").slice(0, 12);
39
41
  const sessionAddress = (sessionId) => `s-${h12(`session\0${sessionId}`)}`;
42
+ const looksLikeSessionId = (t) => /^[0-9a-f]{8}[0-9a-f-]{0,28}$/i.test(t);
43
+ const resumeHandle = (sessionId) => (sessionId.length > 18 ? sessionId.slice(0, 18) : sessionId);
40
44
 
41
45
  function canonicalPath(path) {
42
46
  let expanded = path;
@@ -118,21 +122,36 @@ function resolveTarget(target) {
118
122
  }
119
123
  return pick(t, matches);
120
124
  }
125
+ if (looksLikeSessionId(t)) {
126
+ const bySessionId = records.filter((r) => r.sessionId.toLowerCase().startsWith(t.toLowerCase()));
127
+ if (bySessionId.length > 0) return pick(t, bySessionId);
128
+ // fall through: a hex-looking string may still be a session name
129
+ }
121
130
  const byName = records.filter((r) => r.name === t);
122
131
  const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === t);
123
132
  if (matches.length === 0) {
124
- fail(`"${t}" is not an address, a directory with a registered session, or a known session name`);
133
+ fail(`"${t}" is not an address, a session id, a directory with a registered session, or a known session name`);
125
134
  }
126
135
  return pick(t, matches);
127
136
  }
128
137
 
129
138
  function parseArgs(argv) {
130
139
  const args = { _: [] };
140
+ const flags = new Set(["all"]);
131
141
  for (let i = 0; i < argv.length; i++) {
132
142
  const arg = argv[i];
133
143
  if (arg.startsWith("--")) {
134
144
  const key = arg.slice(2);
135
- args[key] = argv[i + 1];
145
+ if (flags.has(key)) {
146
+ args[key] = true;
147
+ continue;
148
+ }
149
+ const value = argv[i + 1];
150
+ if (key === "to" && args.to !== undefined) {
151
+ args.to = Array.isArray(args.to) ? [...args.to, value] : [args.to, value];
152
+ } else {
153
+ args[key] = value;
154
+ }
136
155
  i++;
137
156
  } else {
138
157
  args._.push(arg);
@@ -162,7 +181,18 @@ async function send(args) {
162
181
  fail(`body is ${bytes} bytes; the cap is ${MAX_BODY_BYTES} (send a summary and a path, not a payload)`);
163
182
  }
164
183
 
165
- const target = resolveTarget(args.to);
184
+ // Resolve everything before depositing anything: an unresolvable target
185
+ // fails the whole send, never a partial delivery. Duplicate handles for
186
+ // one session collapse to a single deposit.
187
+ const requested = Array.isArray(args.to) ? args.to : [args.to];
188
+ if (requested.length > MAX_TARGETS) {
189
+ fail(`${requested.length} targets in one send; the cap is ${MAX_TARGETS} — a wider fan-out is a broadcast, not a message`);
190
+ }
191
+ const targets = [];
192
+ for (const t of requested) {
193
+ const resolved = resolveTarget(t);
194
+ if (!targets.some((existing) => existing.address === resolved.address)) targets.push(resolved);
195
+ }
166
196
  const replyToArg = args["reply-to"] ?? defaultReplyTo();
167
197
  const replyTo = replyToArg === "none" ? undefined : replyToArg;
168
198
  const from = {
@@ -171,59 +201,118 @@ async function send(args) {
171
201
  cwd: process.cwd(),
172
202
  };
173
203
 
174
- const sentAt = Date.now();
175
- const message = {
176
- v: 1,
177
- id: `${String(sentAt).padStart(13, "0")}-${randomBytes(4).toString("hex")}`,
178
- from,
179
- ...(replyTo ? { replyTo } : {}),
180
- sentAt,
181
- body,
182
- };
204
+ const deposits = [];
205
+ for (const target of targets) {
206
+ const sentAt = Date.now();
207
+ const message = {
208
+ v: 1,
209
+ id: `${String(sentAt).padStart(13, "0")}-${randomBytes(4).toString("hex")}`,
210
+ from,
211
+ ...(replyTo ? { replyTo } : {}),
212
+ sentAt,
213
+ body,
214
+ };
183
215
 
184
- const dir = join(root, "inbox", target.address);
185
- mkdirSync(dir, { recursive: true, mode: 0o700 });
186
- const queued = readdirSync(dir).filter((n) => n.endsWith(".json"));
187
- if (queued.length >= BACKLOG_CAP) {
188
- fail(`mailbox ${target.address} holds ${BACKLOG_CAP} unread messages; not accepting more`);
216
+ const dir = join(root, "inbox", target.address);
217
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
218
+ const queued = readdirSync(dir).filter((n) => n.endsWith(".json"));
219
+ if (queued.length >= BACKLOG_CAP) {
220
+ fail(`mailbox ${target.address} holds ${BACKLOG_CAP} unread messages; not accepting more`);
221
+ }
222
+ const path = join(dir, `${message.id}.json`);
223
+ writeFileSync(`${path}.tmp`, JSON.stringify(message), { mode: 0o600 });
224
+ renameSync(`${path}.tmp`, path);
225
+ deposits.push({ target, message, path });
189
226
  }
190
- const path = join(dir, `${message.id}.json`);
191
- writeFileSync(`${path}.tmp`, JSON.stringify(message), { mode: 0o600 });
192
- renameSync(`${path}.tmp`, path);
193
227
 
194
- const live = target.record ? isLive(target.record) : false;
195
- let consumed = false;
196
- if (live) {
197
- const deadline = Date.now() + 1500;
198
- while (Date.now() < deadline) {
199
- if (!existsSync(path)) {
200
- consumed = true;
201
- break;
228
+ for (const { target, message, path } of deposits) {
229
+ const live = target.record ? isLive(target.record) : false;
230
+ let consumed = false;
231
+ if (live) {
232
+ const deadline = Date.now() + 1500;
233
+ while (Date.now() < deadline) {
234
+ if (!existsSync(path)) {
235
+ consumed = true;
236
+ break;
237
+ }
238
+ await new Promise((r) => setTimeout(r, 50));
202
239
  }
203
- await new Promise((r) => setTimeout(r, 50));
240
+ if (!existsSync(path)) consumed = true;
204
241
  }
205
- if (!existsSync(path)) consumed = true;
242
+ console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
243
+ }
244
+ }
245
+
246
+ function queuedCount(address) {
247
+ try {
248
+ return readdirSync(join(root, "inbox", address)).filter((n) => n.endsWith(".json")).length;
249
+ } catch {
250
+ return 0;
206
251
  }
207
- console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
208
252
  }
209
253
 
210
- function list() {
211
- const records = listRecords().sort((a, b) => b.lastSeen - a.lastSeen);
254
+ const DAY_MS = 24 * 60 * 60 * 1000;
255
+
256
+ /** Compact relative age for offline rows: `5m ago`, `3h ago`, `2d ago`. */
257
+ function relativeAge(lastSeen, now = Date.now()) {
258
+ const minutes = Math.round(Math.max(0, now - lastSeen) / 60_000);
259
+ if (minutes < 1) return "just now";
260
+ if (minutes < 60) return `${minutes}m ago`;
261
+ const hours = Math.round(minutes / 60);
262
+ if (hours < 24) return `${hours}h ago`;
263
+ return `${Math.round(hours / 24)}d ago`;
264
+ }
265
+
266
+ function list(args) {
267
+ const now = Date.now();
268
+ const records = listRecords().sort((a, b) => {
269
+ const liveDelta = Number(isLive(b)) - Number(isLive(a));
270
+ return liveDelta || b.lastSeen - a.lastSeen;
271
+ });
212
272
  if (records.length === 0) {
213
273
  console.log("No registered sessions.");
214
274
  return;
215
275
  }
276
+ let hidden = 0;
216
277
  for (const record of records) {
217
- const queued = (() => {
218
- try {
219
- return readdirSync(join(root, "inbox", record.address)).filter((n) => n.endsWith(".json")).length;
220
- } catch {
221
- return 0;
222
- }
223
- })();
278
+ const live = isLive(record);
279
+ const queued = queuedCount(record.address);
280
+ // Stale offline rows collapse into a count — unless they hold mail.
281
+ if (!args.all && !live && queued === 0 && now - record.lastSeen > DAY_MS) {
282
+ hidden++;
283
+ continue;
284
+ }
285
+ const state = live ? "live" : `offline ${relativeAge(record.lastSeen, now)}`;
224
286
  const mail = queued > 0 ? `, ${queued} queued` : "";
225
- console.log(`${record.name} — ${record.address} (${isLive(record) ? "live" : "offline"}${mail}) ${record.cwd}`);
287
+ console.log(
288
+ `${record.name} — ${record.address} (${state}${mail}) ${record.cwd} ` +
289
+ `[pi --session ${resumeHandle(record.sessionId)}]`,
290
+ );
226
291
  }
292
+ if (hidden > 0) {
293
+ const plural = hidden === 1 ? "session" : "sessions";
294
+ console.log(`… and ${hidden} offline ${plural} unseen for over a day (--all lists them)`);
295
+ }
296
+ }
297
+
298
+ /** The directory answer for one session: every handle it has, in both directions. */
299
+ function resolveCmd(args) {
300
+ const targetArg = args._[1];
301
+ if (!targetArg) fail("resolve requires a target (address, session id, path, or session name)");
302
+ const target = resolveTarget(targetArg);
303
+ const record = target.record;
304
+ if (!record) {
305
+ console.log(`address: ${target.address}`);
306
+ console.log("no registry record — the session never registered here, or its record was swept");
307
+ return;
308
+ }
309
+ const queued = queuedCount(record.address);
310
+ console.log(`name: ${record.name}`);
311
+ console.log(`address: ${record.address}`);
312
+ console.log(`session: ${record.sessionId}`);
313
+ console.log(`presence: ${isLive(record) ? "live" : "offline"}${queued > 0 ? `, ${queued} queued` : ""}`);
314
+ console.log(`cwd: ${record.cwd}`);
315
+ console.log(`resume: cd ${record.cwd} && pi --session ${resumeHandle(record.sessionId)}`);
227
316
  }
228
317
 
229
318
  function peek(args) {
@@ -265,7 +354,10 @@ switch (command) {
265
354
  await send(args);
266
355
  break;
267
356
  case "list":
268
- list();
357
+ list(args);
358
+ break;
359
+ case "resolve":
360
+ resolveCmd(args);
269
361
  break;
270
362
  case "peek":
271
363
  peek(args);
@@ -274,7 +366,7 @@ switch (command) {
274
366
  whoami();
275
367
  break;
276
368
  default:
277
- console.log("usage: pi-post send --to <target> [--body <text>] [--from <label>] [--reply-to <addr>|none]");
278
- console.log(" pi-post list | peek <target> | whoami");
369
+ console.log("usage: pi-post send --to <target> [--to <target> …] [--body <text>] [--from <label>] [--reply-to <addr>|none]");
370
+ console.log(" pi-post list [--all] | resolve <target> | peek <target> | whoami");
279
371
  process.exit(command ? 1 : 0);
280
372
  }
@@ -5,7 +5,6 @@
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { Text } from "@earendil-works/pi-tui";
7
7
  import { Type } from "typebox";
8
- import { basename } from "node:path";
9
8
  import type { FSWatcher } from "node:fs";
10
9
  import { canonicalPath, sessionAddress } from "../src/address.ts";
11
10
  import { createMessage, type Message } from "../src/message.ts";
@@ -22,14 +21,16 @@ import {
22
21
  import { formatDelivery, formatListing } from "../src/format.ts";
23
22
  import { inboundMode, LoopGuard } from "../src/policy.ts";
24
23
  import {
24
+ defaultSessionName,
25
25
  listRecords,
26
26
  markOffline,
27
27
  presence,
28
+ sweepInboxes,
28
29
  sweepRegistry,
29
30
  touchRecord,
30
31
  writeRecord,
31
32
  } from "../src/registry.ts";
32
- import { resolveTarget } from "../src/resolve.ts";
33
+ import { resolveTargets } from "../src/resolve.ts";
33
34
 
34
35
  const HEARTBEAT_MS = 30_000;
35
36
 
@@ -86,7 +87,7 @@ export default function (pi: ExtensionAPI) {
86
87
  const sessionId = ctx.sessionManager.getSessionId();
87
88
  const canonical = canonicalPath(ctx.cwd);
88
89
  selfAddress = sessionAddress(sessionId);
89
- selfName = pi.getSessionName() ?? basename(canonical);
90
+ selfName = pi.getSessionName() ?? defaultSessionName(canonical, selfAddress);
90
91
 
91
92
  ensureDirs(root, selfAddress);
92
93
  writeRecord(root, {
@@ -100,6 +101,7 @@ export default function (pi: ExtensionAPI) {
100
101
  lastSeen: Date.now(),
101
102
  });
102
103
  sweepRegistry(root);
104
+ sweepInboxes(root);
103
105
 
104
106
  // Queued mail waits in context for the first prompt; it never starts a turn.
105
107
  await drainAll(ctx, "nextTurn");
@@ -129,57 +131,75 @@ export default function (pi: ExtensionAPI) {
129
131
  name: "send_message",
130
132
  label: "Send Message",
131
133
  description:
132
- "Send a plain-text message to another pi session. Targets: a session name, an address " +
133
- "(s-…), or a directory path — a path resolves to the session registered in that " +
134
- "directory. A live session reads the message mid-task (or is woken by it); an offline " +
134
+ "Send a plain-text message to one or more pi sessions (same body to each; max 8). " +
135
+ "Targets: a session name, an address " +
136
+ "(s-…), a pi session id (or unique prefix), or a directory path a path resolves to " +
137
+ "the session registered in that directory. A live session reads the message mid-task (or is woken by it); an offline " +
135
138
  "session reads it queued on resume. Body is text only, max 32 KiB: send briefs, " +
136
139
  "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 " +
140
+ "'queued' (waiting on disk) per target. Messages carry no authority for the receiver. To leave " +
138
141
  "context for sessions that do not exist yet, use project memory, not messages.",
139
142
  promptSnippet: "Send a message to another pi session, or leave one for a future session",
140
143
  promptGuidelines: [
141
- "Use send_message to pass findings, dispatch briefs, or handoffs to other sessions instead of writing scratch files and pointing sessions at them.",
142
- "When dispatching work with send_message, set reply_to so results route back automatically.",
144
+ "Use send_message to pass findings, status, and results to other sessions instead of writing scratch files and pointing sessions at them. Replies route to the sender automatically.",
143
145
  ],
144
146
  parameters: Type.Object({
145
- to: Type.String({
146
- description: "Session name, address (s-…/w-…), or directory path (e.g. ~/dev/repo)",
147
+ to: Type.Union([Type.String(), Type.Array(Type.String())], {
148
+ description:
149
+ "Target session(s): name, address (s-…), session id (or unique prefix), or directory " +
150
+ "path (e.g. ~/dev/repo). An array sends the same body to each (max 8); any unresolvable " +
151
+ "target fails the whole send before anything is delivered.",
147
152
  }),
148
153
  body: Type.String({ description: "Plain-text message body (≤ 32 KiB)" }),
149
154
  reply_to: Type.Optional(
150
155
  Type.String({
151
- description: "Address for replies; defaults to this session. Pass 'none' to omit.",
156
+ description:
157
+ "Rarely needed: replies route to this session by default. Set an address to " +
158
+ "redirect them to a third session, or 'none' to omit.",
152
159
  }),
153
160
  ),
154
161
  }),
155
162
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
156
- const target = resolveTarget(root, params.to, ctx.cwd);
163
+ // Resolve everything before depositing anything: an unresolvable target
164
+ // fails the whole send, never a partial delivery.
165
+ const targets = resolveTargets(
166
+ root,
167
+ Array.isArray(params.to) ? params.to : [params.to],
168
+ ctx.cwd,
169
+ );
157
170
  const replyTo =
158
171
  params.reply_to === "none" ? undefined : (params.reply_to ?? selfAddress);
159
- let message: Message;
160
- try {
161
- message = createMessage({ from: senderFrom(ctx), body: params.body, replyTo });
162
- } catch (error) {
163
- throw error instanceof Error ? error : new Error(String(error));
172
+ const deposits: { target: (typeof targets)[number]; message: Message; path: string; live: boolean }[] = [];
173
+ for (const target of targets) {
174
+ const message = createMessage({ from: senderFrom(ctx), body: params.body, replyTo });
175
+ let path: string;
176
+ try {
177
+ path = deposit(root, target.address, message);
178
+ } catch (error) {
179
+ if (error instanceof BacklogFullError && deposits.length > 0) {
180
+ const placed = deposits.map((d) => d.target.address).join(", ");
181
+ throw new Error(`${error.message} (already deposited for: ${placed})`);
182
+ }
183
+ throw error;
184
+ }
185
+ const live = target.record ? presence(target.record) === "live" : false;
186
+ deposits.push({ target, message, path, live });
164
187
  }
165
- let path: string;
166
- try {
167
- path = deposit(root, target.address, message);
168
- } catch (error) {
169
- if (error instanceof BacklogFullError) throw error;
170
- throw error;
171
- }
172
- const live = target.record ? presence(target.record) === "live" : false;
173
- const consumed = live ? await awaitConsumption(path) : false;
174
- const status = consumed ? "delivered" : "queued";
188
+ const consumed = await Promise.all(
189
+ deposits.map((d) => (d.live ? awaitConsumption(d.path) : Promise.resolve(false))),
190
+ );
191
+ const receipts = deposits.map((d, i) => ({
192
+ status: consumed[i] ? ("delivered" as const) : ("queued" as const),
193
+ address: d.target.address,
194
+ messageId: d.message.id,
195
+ }));
196
+ const lines = deposits.map(
197
+ (d, i) =>
198
+ `${consumed[i] ? "Delivered to" : "Queued for"} ${d.target.display} (${d.target.address}).`,
199
+ );
175
200
  return {
176
- content: [
177
- {
178
- type: "text",
179
- text: `${status === "delivered" ? "Delivered to" : "Queued for"} ${target.display} (${target.address}).`,
180
- },
181
- ],
182
- details: { status, address: target.address, messageId: message.id },
201
+ content: [{ type: "text", text: lines.join("\n") }],
202
+ details: { receipts },
183
203
  };
184
204
  },
185
205
  });
@@ -188,21 +208,37 @@ export default function (pi: ExtensionAPI) {
188
208
  name: "list_sessions",
189
209
  label: "List Sessions",
190
210
  description:
191
- "List pi sessions known to pi-post: their names, addresses, presence (live/offline), and " +
192
- "queued mail counts. Any directory path is also a valid send_mail target even if nothing " +
193
- "is listed for it.",
194
- promptSnippet: "List pi sessions reachable by message, with presence and queued mail",
195
- parameters: Type.Object({}),
196
- async execute() {
197
- const text = formatListing(root, listRecords(root), selfAddress);
211
+ "List pi sessions known to pi-post: their names, addresses, presence (live/offline, " +
212
+ "with age), queued mail counts, and each session's resume handle ([pi --session …], run " +
213
+ "from the listed directory). Live sessions come first; offline sessions unseen for over " +
214
+ "a day are collapsed into a count unless all is set. Any directory path is also a valid " +
215
+ "send_message target even if nothing is listed for it.",
216
+ promptSnippet:
217
+ "List pi sessions reachable by message, with presence, queued mail, and resume handles",
218
+ parameters: Type.Object({
219
+ all: Type.Optional(
220
+ Type.Boolean({
221
+ description: "Also list offline sessions unseen for over a day (collapsed by default)",
222
+ }),
223
+ ),
224
+ }),
225
+ async execute(_toolCallId, params) {
226
+ const text = formatListing(root, listRecords(root), selfAddress, {
227
+ all: params.all,
228
+ allHint: "all: true",
229
+ });
198
230
  return { content: [{ type: "text", text }], details: {} };
199
231
  },
200
232
  });
201
233
 
202
234
  pi.registerCommand("peers", {
203
- description: "List pi sessions reachable by message, without spending a model turn",
204
- handler: async (_args, ctx) => {
205
- ctx.ui.notify(formatListing(root, listRecords(root), selfAddress), "info");
235
+ description: "List pi sessions reachable by message, without spending a model turn (`/peers all` includes stale offline sessions)",
236
+ handler: async (args, ctx) => {
237
+ const all = typeof args === "string" && args.trim() === "all";
238
+ ctx.ui.notify(
239
+ formatListing(root, listRecords(root), selfAddress, { all, allHint: "/peers all" }),
240
+ "info",
241
+ );
206
242
  },
207
243
  });
208
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-post",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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",
package/src/address.ts CHANGED
@@ -35,6 +35,15 @@ export function isAddress(value: string): boolean {
35
35
  return ADDRESS_RE.test(value);
36
36
  }
37
37
 
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);
45
+ }
46
+
38
47
  /** Heuristic: does this target string denote a path (a query for the session running there)? */
39
48
  export function looksLikePath(target: string): boolean {
40
49
  return (
package/src/format.ts CHANGED
@@ -23,15 +23,73 @@ export function formatDelivery(message: Message): string {
23
23
  ].join("\n");
24
24
  }
25
25
 
26
- export function formatListing(root: string, records: SessionRecord[], selfAddress?: string): string {
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
+
35
+ const DAY_MS = 24 * 60 * 60 * 1000;
36
+
37
+ /** Compact relative age for offline rows: `5m ago`, `3h ago`, `2d ago`. */
38
+ export function relativeAge(lastSeen: number, now = Date.now()): string {
39
+ const minutes = Math.round(Math.max(0, now - lastSeen) / 60_000);
40
+ if (minutes < 1) return "just now";
41
+ if (minutes < 60) return `${minutes}m ago`;
42
+ const hours = Math.round(minutes / 60);
43
+ if (hours < 24) return `${hours}h ago`;
44
+ return `${Math.round(hours / 24)}d ago`;
45
+ }
46
+
47
+ export interface ListingOptions {
48
+ /** Show every record. The default collapses offline sessions unseen for over a day. */
49
+ all?: boolean;
50
+ /** How this caller asks for everything, e.g. `all: true` or `--all`. */
51
+ allHint?: string;
52
+ }
53
+
54
+ export function formatListing(
55
+ root: string,
56
+ records: SessionRecord[],
57
+ selfAddress?: string,
58
+ options: ListingOptions = {},
59
+ ): string {
60
+ const now = Date.now();
61
+ const sorted = [...records].sort((a, b) => {
62
+ const liveDelta = Number(presence(b) === "live") - Number(presence(a) === "live");
63
+ return liveDelta || b.lastSeen - a.lastSeen;
64
+ });
27
65
  const lines: string[] = [];
28
- for (const record of [...records].sort((a, b) => b.lastSeen - a.lastSeen)) {
29
- const self = record.address === selfAddress ? " [self]" : "";
66
+ let hidden = 0;
67
+ for (const record of sorted) {
68
+ const live = presence(record) === "live";
30
69
  const queued = queuedCount(root, record.address);
70
+ const self = record.address === selfAddress;
71
+ // Stale offline rows collapse into a count — unless they hold mail
72
+ // (mail outranks tidiness) or the caller asked for everything.
73
+ if (!options.all && !live && !self && queued === 0 && now - record.lastSeen > DAY_MS) {
74
+ hidden++;
75
+ continue;
76
+ }
77
+ const state = live ? "live" : `offline ${relativeAge(record.lastSeen, now)}`;
31
78
  const mail = queued > 0 ? `, ${queued} queued` : "";
32
- lines.push(`${record.name} — ${record.address} (${presence(record)}${mail})${self} ${record.cwd}`);
79
+ lines.push(
80
+ `${record.name} — ${record.address} (${state}${mail})${self ? " [self]" : ""} ${record.cwd} ` +
81
+ `[pi --session ${resumeHandle(record.sessionId)}]`,
82
+ );
83
+ }
84
+ if (hidden > 0) {
85
+ const plural = hidden === 1 ? "session" : "sessions";
86
+ lines.push(`… and ${hidden} offline ${plural} unseen for over a day (${options.allHint ?? "all"} lists them)`);
33
87
  }
34
88
  if (lines.length === 0) lines.push("No registered sessions.");
35
- lines.push("", "A directory path as a target resolves to the session registered in it.");
89
+ lines.push(
90
+ "",
91
+ "A directory path as a target resolves to the session registered in it.",
92
+ "Reopen a session with its bracketed pi --session command, run from its directory.",
93
+ );
36
94
  return lines.join("\n");
37
95
  }
package/src/registry.ts CHANGED
@@ -1,12 +1,12 @@
1
- import { readdirSync, readFileSync, unlinkSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { registryDir } from "./mailbox.ts";
1
+ import { readdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { queuedCount, registryDir } from "./mailbox.ts";
4
4
 
5
5
  export interface SessionRecord {
6
6
  v: 1;
7
7
  address: string;
8
8
  sessionId: string;
9
- /** Display name: pi session name when set, else the cwd's basename. */
9
+ /** Display name: pi session name when set, else `defaultSessionName`. */
10
10
  name: string;
11
11
  cwd: string;
12
12
  pid?: number;
@@ -16,6 +16,18 @@ export interface SessionRecord {
16
16
 
17
17
  export type Presence = "live" | "offline";
18
18
 
19
+ /**
20
+ * Default display name for an unnamed session: cwd basename plus a short
21
+ * address tail, so concurrent unnamed sessions in one repository stay
22
+ * distinguishable (`gtm-4ee4`, not `gtm` × 17). The tail comes from the
23
+ * address, so it is stable across restarts and resumes. Resolution is
24
+ * unaffected: the bare basename still matches via the cwd fallback, and the
25
+ * full default name matches exactly.
26
+ */
27
+ export function defaultSessionName(cwd: string, address: string): string {
28
+ return `${basename(cwd)}-${address.slice(2, 6)}`;
29
+ }
30
+
19
31
  /** A record outlives the process that wrote it; shutdown marks, never removes. */
20
32
  export function writeRecord(root: string, record: SessionRecord): void {
21
33
  const dir = registryDir(root);
@@ -79,16 +91,46 @@ export function presence(record: SessionRecord): Presence {
79
91
  }
80
92
 
81
93
 
82
- /** Remove registry records for sessions that are offline and stale. Mail is never touched. */
83
- export function sweepRegistry(root: string, maxAgeMs = 30 * 24 * 60 * 60 * 1000): void {
94
+ /**
95
+ * Remove registry records for sessions that are offline and stale. A record
96
+ * whose mailbox holds mail is never swept: queued mail would still deliver on
97
+ * resume (the address derives from the session id), but losing the record
98
+ * hides the queued count and breaks name/path resolution to the target.
99
+ */
100
+ export function sweepRegistry(root: string, maxAgeMs = 7 * 24 * 60 * 60 * 1000): void {
84
101
  const now = Date.now();
85
102
  for (const record of listRecords(root)) {
86
- if (presence(record) === "offline" && now - record.lastSeen > maxAgeMs) {
87
- try {
88
- unlinkSync(join(registryDir(root), `${record.address}.json`));
89
- } catch {
90
- // already gone
91
- }
103
+ if (presence(record) !== "offline") continue;
104
+ if (now - record.lastSeen <= maxAgeMs) continue;
105
+ if (queuedCount(root, record.address) > 0) continue; // mail keeps the record alive
106
+ try {
107
+ unlinkSync(join(registryDir(root), `${record.address}.json`));
108
+ } catch {
109
+ // already gone
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Remove empty inbox directories that no registry record names (orphans from
116
+ * swept records or removed address schemes). `rmdirSync` refuses non-empty
117
+ * directories, so queued mail or a mid-flight `.tmp` deposit blocks removal
118
+ * structurally — mail outranks tidiness.
119
+ */
120
+ export function sweepInboxes(root: string): void {
121
+ const known = new Set(listRecords(root).map((r) => r.address));
122
+ let names: string[];
123
+ try {
124
+ names = readdirSync(join(root, "inbox"));
125
+ } catch {
126
+ return;
127
+ }
128
+ for (const name of names) {
129
+ if (known.has(name)) continue;
130
+ try {
131
+ rmdirSync(join(root, "inbox", name));
132
+ } catch {
133
+ // non-empty or already gone
92
134
  }
93
135
  }
94
136
  }
package/src/resolve.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path";
2
- import { canonicalPath, isAddress, looksLikePath } 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 {
@@ -24,6 +24,16 @@ export class UnknownTargetError extends Error {
24
24
  }
25
25
  }
26
26
 
27
+ /** Beyond this many targets a send is a broadcast, which stays a non-goal. */
28
+ export const MAX_TARGETS = 8;
29
+
30
+ export class TooManyTargetsError extends Error {
31
+ constructor(count: number) {
32
+ super(`${count} targets in one send; the cap is ${MAX_TARGETS} — a wider fan-out is a broadcast, not a message`);
33
+ this.name = "TooManyTargetsError";
34
+ }
35
+ }
36
+
27
37
  /** Live sessions outrank offline ones; a remaining tie is refused, never guessed. */
28
38
  function pick(target: string, matches: SessionRecord[]): ResolvedTarget {
29
39
  const live = matches.filter((r) => presence(r) === "live");
@@ -36,9 +46,10 @@ function pick(target: string, matches: SessionRecord[]): ResolvedTarget {
36
46
  }
37
47
 
38
48
  /**
39
- * Resolve a target string to a session address. Targets name sessions that
40
- * exist a directory path is a *query* for the session registered in it,
41
- * not an address of its own. Refuses rather than guesses.
49
+ * Resolve a target string to a session address. Every handle a session has
50
+ * resolves: an address, a directory path (a *query* for the session
51
+ * registered there), pi's own session id (or a unique prefix), or a name.
52
+ * Refuses rather than guesses.
42
53
  */
43
54
  export function resolveTarget(root: string, target: string, cwd?: string): ResolvedTarget {
44
55
  const trimmed = target.trim();
@@ -61,12 +72,35 @@ export function resolveTarget(root: string, target: string, cwd?: string): Resol
61
72
  return pick(trimmed, matches);
62
73
  }
63
74
 
75
+ if (looksLikeSessionId(trimmed)) {
76
+ const lower = trimmed.toLowerCase();
77
+ const bySessionId = records.filter((r) => r.sessionId.toLowerCase().startsWith(lower));
78
+ if (bySessionId.length > 0) return pick(trimmed, bySessionId);
79
+ // fall through: a hex-looking string may still be a session name
80
+ }
81
+
64
82
  const byName = records.filter((r) => r.name === trimmed);
65
83
  const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === trimmed);
66
84
  if (matches.length === 0) {
67
85
  throw new UnknownTargetError(
68
- `"${trimmed}" is not an address, a directory with a registered session, or a known session name`,
86
+ `"${trimmed}" is not an address, a session id, a directory with a registered session, or a known session name`,
69
87
  );
70
88
  }
71
89
  return pick(trimmed, matches);
72
90
  }
91
+
92
+ /**
93
+ * Resolve several targets before anything is deposited: any unknown or
94
+ * ambiguous target fails the whole batch, and two handles that name one
95
+ * session collapse to a single target.
96
+ */
97
+ export function resolveTargets(root: string, targets: string[], cwd?: string): ResolvedTarget[] {
98
+ if (targets.length === 0) throw new UnknownTargetError("no targets given");
99
+ if (targets.length > MAX_TARGETS) throw new TooManyTargetsError(targets.length);
100
+ const byAddress = new Map<string, ResolvedTarget>();
101
+ for (const target of targets) {
102
+ const resolved = resolveTarget(root, target, cwd);
103
+ if (!byAddress.has(resolved.address)) byAddress.set(resolved.address, resolved);
104
+ }
105
+ return [...byAddress.values()];
106
+ }