pi-post 0.4.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
@@ -116,7 +116,10 @@ Each is pinned by a test.
116
116
  - **A reader never sees half a message.** Rename-into-place; only
117
117
  `.json` is read.
118
118
  - **Nothing is delivered twice.** Unlink before handling.
119
- - **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.
120
123
  - **Loops terminate structurally.** Identical body from one sender inside
121
124
  10 s is dropped; a sender is throttled past 8 messages in 30 s; a
122
125
  mailbox stops accepting at 50 queued messages. Independent of model
@@ -141,7 +144,9 @@ where a UI exists (falls back to accept headless), `refuse` drops.
141
144
  do not exist yet — is the caller's convention. Successor handoffs
142
145
  belong in project memory (which any number of future sessions can
143
146
  read), not in a consume-once message that exactly one arbitrary
144
- 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.
145
150
  - Cross-machine anything. Two parties can reach each other exactly when
146
151
  they share a filesystem.
147
152
  - Messaging *into* other runtimes (e.g. Claude Code sessions). Inbound
package/README.md CHANGED
@@ -80,13 +80,13 @@ Nothing to enable; every session registers itself on startup.
80
80
 
81
81
  | Surface | Effect |
82
82
  |---|---|
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 |
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` |
85
85
  | `/inbox` | Peek at this session's queued messages without consuming them |
86
86
  | `/peers` | The `list_sessions` listing, without spending a model turn |
87
- | `pi-post send` (CLI) | Send from any process: `--to`, `--body`/stdin, `--from`, `--reply-to` |
87
+ | `pi-post send` (CLI) | Send from any process: `--to` (repeatable), `--body`/stdin, `--from`, `--reply-to` |
88
88
  | `pi-post resolve <handle>` (CLI) | One session's full record: name, address, session id, presence, cwd, resume command |
89
- | `pi-post list` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
89
+ | `pi-post list [--all]` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
90
90
 
91
91
  Ask in words; the model picks the tool.
92
92
 
@@ -105,9 +105,16 @@ pi-post send --to "$PI_POST_REPLY_TO" --from "golem:gtmeng-2573" \
105
105
  --body "gate green, diff unreviewed, log at ~/scratch/logs/2573.log"
106
106
  ```
107
107
 
108
- ### Dispatch pattern
108
+ ### Dispatch patterns
109
109
 
110
- Spawn first, send second the brief starts the worker's first turn:
110
+ Two patterns cover real use; pick by whether the worker exists yet.
111
+
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:
111
118
 
112
119
  ```bash
113
120
  # 1. spawn the worker in its own worktree; it registers and sits idle
@@ -117,6 +124,14 @@ cd ~/dev/repo-worktree && pi
117
124
  # with the brief — wake-on-idle makes it the worker's first turn
118
125
  ```
119
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
+
120
135
  For sessions that don't exist yet — tomorrow's session on this repo —
121
136
  use project memory or your tracker, not messages: any number of future
122
137
  sessions can read state; only one can consume a message.
@@ -152,13 +167,13 @@ words; summoning stays yours.
152
167
  ```markdown
153
168
  ## Cross-session messages (pi-post)
154
169
 
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.
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.
162
177
  ```
163
178
 
164
179
  ## Design
package/bin/pi-post.mjs CHANGED
@@ -7,7 +7,7 @@
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
12
  * pi-post resolve <target>
13
13
  * pi-post peek <target>
@@ -32,6 +32,7 @@ import { basename, isAbsolute, join, resolve } from "node:path";
32
32
 
33
33
  const MAX_BODY_BYTES = 32 * 1024;
34
34
  const BACKLOG_CAP = 50;
35
+ const MAX_TARGETS = 8;
35
36
  const ADDRESS_RE = /^s-[0-9a-f]{12}$/;
36
37
 
37
38
  const root = process.env.PI_POST_DIR || join(homedir(), ".pi", "agent", "post");
@@ -136,11 +137,21 @@ function resolveTarget(target) {
136
137
 
137
138
  function parseArgs(argv) {
138
139
  const args = { _: [] };
140
+ const flags = new Set(["all"]);
139
141
  for (let i = 0; i < argv.length; i++) {
140
142
  const arg = argv[i];
141
143
  if (arg.startsWith("--")) {
142
144
  const key = arg.slice(2);
143
- 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
+ }
144
155
  i++;
145
156
  } else {
146
157
  args._.push(arg);
@@ -170,7 +181,18 @@ async function send(args) {
170
181
  fail(`body is ${bytes} bytes; the cap is ${MAX_BODY_BYTES} (send a summary and a path, not a payload)`);
171
182
  }
172
183
 
173
- 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
+ }
174
196
  const replyToArg = args["reply-to"] ?? defaultReplyTo();
175
197
  const replyTo = replyToArg === "none" ? undefined : replyToArg;
176
198
  const from = {
@@ -179,40 +201,46 @@ async function send(args) {
179
201
  cwd: process.cwd(),
180
202
  };
181
203
 
182
- const sentAt = Date.now();
183
- const message = {
184
- v: 1,
185
- id: `${String(sentAt).padStart(13, "0")}-${randomBytes(4).toString("hex")}`,
186
- from,
187
- ...(replyTo ? { replyTo } : {}),
188
- sentAt,
189
- body,
190
- };
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
+ };
191
215
 
192
- const dir = join(root, "inbox", target.address);
193
- mkdirSync(dir, { recursive: true, mode: 0o700 });
194
- const queued = readdirSync(dir).filter((n) => n.endsWith(".json"));
195
- if (queued.length >= BACKLOG_CAP) {
196
- 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 });
197
226
  }
198
- const path = join(dir, `${message.id}.json`);
199
- writeFileSync(`${path}.tmp`, JSON.stringify(message), { mode: 0o600 });
200
- renameSync(`${path}.tmp`, path);
201
227
 
202
- const live = target.record ? isLive(target.record) : false;
203
- let consumed = false;
204
- if (live) {
205
- const deadline = Date.now() + 1500;
206
- while (Date.now() < deadline) {
207
- if (!existsSync(path)) {
208
- consumed = true;
209
- 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));
210
239
  }
211
- await new Promise((r) => setTimeout(r, 50));
240
+ if (!existsSync(path)) consumed = true;
212
241
  }
213
- if (!existsSync(path)) consumed = true;
242
+ console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
214
243
  }
215
- console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
216
244
  }
217
245
 
218
246
  function queuedCount(address) {
@@ -223,20 +251,48 @@ function queuedCount(address) {
223
251
  }
224
252
  }
225
253
 
226
- function list() {
227
- 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
+ });
228
272
  if (records.length === 0) {
229
273
  console.log("No registered sessions.");
230
274
  return;
231
275
  }
276
+ let hidden = 0;
232
277
  for (const record of records) {
278
+ const live = isLive(record);
233
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)}`;
234
286
  const mail = queued > 0 ? `, ${queued} queued` : "";
235
287
  console.log(
236
- `${record.name} — ${record.address} (${isLive(record) ? "live" : "offline"}${mail}) ${record.cwd} ` +
288
+ `${record.name} — ${record.address} (${state}${mail}) ${record.cwd} ` +
237
289
  `[pi --session ${resumeHandle(record.sessionId)}]`,
238
290
  );
239
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
+ }
240
296
  }
241
297
 
242
298
  /** The directory answer for one session: every handle it has, in both directions. */
@@ -298,7 +354,7 @@ switch (command) {
298
354
  await send(args);
299
355
  break;
300
356
  case "list":
301
- list();
357
+ list(args);
302
358
  break;
303
359
  case "resolve":
304
360
  resolveCmd(args);
@@ -310,7 +366,7 @@ switch (command) {
310
366
  whoami();
311
367
  break;
312
368
  default:
313
- console.log("usage: pi-post send --to <target> [--body <text>] [--from <label>] [--reply-to <addr>|none]");
314
- console.log(" pi-post list | resolve <target> | 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");
315
371
  process.exit(command ? 1 : 0);
316
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,58 +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 " +
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 " +
133
136
  "(s-…), a pi session id (or unique prefix), or a directory path — a path resolves to " +
134
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({
147
+ to: Type.Union([Type.String(), Type.Array(Type.String())], {
146
148
  description:
147
- "Session name, address (s-…), session id (or unique prefix), or directory path (e.g. ~/dev/repo)",
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.",
148
152
  }),
149
153
  body: Type.String({ description: "Plain-text message body (≤ 32 KiB)" }),
150
154
  reply_to: Type.Optional(
151
155
  Type.String({
152
- 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.",
153
159
  }),
154
160
  ),
155
161
  }),
156
162
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
157
- 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
+ );
158
170
  const replyTo =
159
171
  params.reply_to === "none" ? undefined : (params.reply_to ?? selfAddress);
160
- let message: Message;
161
- try {
162
- message = createMessage({ from: senderFrom(ctx), body: params.body, replyTo });
163
- } catch (error) {
164
- 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 });
165
187
  }
166
- let path: string;
167
- try {
168
- path = deposit(root, target.address, message);
169
- } catch (error) {
170
- if (error instanceof BacklogFullError) throw error;
171
- throw error;
172
- }
173
- const live = target.record ? presence(target.record) === "live" : false;
174
- const consumed = live ? await awaitConsumption(path) : false;
175
- 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
+ );
176
200
  return {
177
- content: [
178
- {
179
- type: "text",
180
- text: `${status === "delivered" ? "Delivered to" : "Queued for"} ${target.display} (${target.address}).`,
181
- },
182
- ],
183
- details: { status, address: target.address, messageId: message.id },
201
+ content: [{ type: "text", text: lines.join("\n") }],
202
+ details: { receipts },
184
203
  };
185
204
  },
186
205
  });
@@ -189,23 +208,37 @@ export default function (pi: ExtensionAPI) {
189
208
  name: "list_sessions",
190
209
  label: "List Sessions",
191
210
  description:
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.",
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.",
196
216
  promptSnippet:
197
217
  "List pi sessions reachable by message, with presence, queued mail, and resume handles",
198
- parameters: Type.Object({}),
199
- async execute() {
200
- const text = formatListing(root, listRecords(root), selfAddress);
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
+ });
201
230
  return { content: [{ type: "text", text }], details: {} };
202
231
  },
203
232
  });
204
233
 
205
234
  pi.registerCommand("peers", {
206
- description: "List pi sessions reachable by message, without spending a model turn",
207
- handler: async (_args, ctx) => {
208
- 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
+ );
209
242
  },
210
243
  });
211
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-post",
3
- "version": "0.4.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/format.ts CHANGED
@@ -32,17 +32,59 @@ export function resumeHandle(sessionId: string): string {
32
32
  return sessionId.length > 18 ? sessionId.slice(0, 18) : sessionId;
33
33
  }
34
34
 
35
- export function formatListing(root: string, records: SessionRecord[], selfAddress?: string): string {
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
+ });
36
65
  const lines: string[] = [];
37
- for (const record of [...records].sort((a, b) => b.lastSeen - a.lastSeen)) {
38
- const self = record.address === selfAddress ? " [self]" : "";
66
+ let hidden = 0;
67
+ for (const record of sorted) {
68
+ const live = presence(record) === "live";
39
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)}`;
40
78
  const mail = queued > 0 ? `, ${queued} queued` : "";
41
79
  lines.push(
42
- `${record.name} — ${record.address} (${presence(record)}${mail})${self} ${record.cwd} ` +
80
+ `${record.name} — ${record.address} (${state}${mail})${self ? " [self]" : ""} ${record.cwd} ` +
43
81
  `[pi --session ${resumeHandle(record.sessionId)}]`,
44
82
  );
45
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)`);
87
+ }
46
88
  if (lines.length === 0) lines.push("No registered sessions.");
47
89
  lines.push(
48
90
  "",
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
@@ -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");
@@ -78,3 +88,19 @@ export function resolveTarget(root: string, target: string, cwd?: string): Resol
78
88
  }
79
89
  return pick(trimmed, matches);
80
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
+ }