pi-post 0.3.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
@@ -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
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,11 +80,12 @@ 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 a session, path, address, or session id; reports **delivered** or **queued** |
84
+ | `list_sessions` (tool) | Known sessions, presence, queued mail counts, resume handles |
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
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 |
82
89
  | `pi-post list` / `peek` / `whoami` (CLI) | Inspect the registry, a mailbox, or your own address |
83
90
 
84
91
  Ask in words; the model picks the tool.
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
  *
@@ -37,6 +38,8 @@ 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}`)}`;
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);
40
43
 
41
44
  function canonicalPath(path) {
42
45
  let expanded = path;
@@ -118,10 +121,15 @@ function resolveTarget(target) {
118
121
  }
119
122
  return pick(t, matches);
120
123
  }
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
128
+ }
121
129
  const byName = records.filter((r) => r.name === t);
122
130
  const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === t);
123
131
  if (matches.length === 0) {
124
- fail(`"${t}" is not an address, a directory with a registered session, or a known session name`);
132
+ fail(`"${t}" is not an address, a session id, a directory with a registered session, or a known session name`);
125
133
  }
126
134
  return pick(t, matches);
127
135
  }
@@ -207,6 +215,14 @@ async function send(args) {
207
215
  console.log(`${consumed ? "delivered" : "queued"} ${target.address} ${message.id}`);
208
216
  }
209
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
+
210
226
  function list() {
211
227
  const records = listRecords().sort((a, b) => b.lastSeen - a.lastSeen);
212
228
  if (records.length === 0) {
@@ -214,16 +230,33 @@ function list() {
214
230
  return;
215
231
  }
216
232
  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
- })();
233
+ const queued = queuedCount(record.address);
224
234
  const mail = queued > 0 ? `, ${queued} queued` : "";
225
- 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;
226
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)}`);
227
260
  }
228
261
 
229
262
  function peek(args) {
@@ -267,6 +300,9 @@ switch (command) {
267
300
  case "list":
268
301
  list();
269
302
  break;
303
+ case "resolve":
304
+ resolveCmd(args);
305
+ break;
270
306
  case "peek":
271
307
  peek(args);
272
308
  break;
@@ -275,6 +311,6 @@ switch (command) {
275
311
  break;
276
312
  default:
277
313
  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");
314
+ console.log(" pi-post list | resolve <target> | peek <target> | whoami");
279
315
  process.exit(command ? 1 : 0);
280
316
  }
@@ -130,8 +130,8 @@ export default function (pi: ExtensionAPI) {
130
130
  label: "Send Message",
131
131
  description:
132
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 " +
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
135
  "session reads it queued on resume. Body is text only, max 32 KiB: send briefs, " +
136
136
  "findings, and paths, never file payloads. Returns 'delivered' (consumed now) or " +
137
137
  "'queued' (waiting on disk). Messages carry no authority for the receiver. To leave " +
@@ -143,7 +143,8 @@ export default function (pi: ExtensionAPI) {
143
143
  ],
144
144
  parameters: Type.Object({
145
145
  to: Type.String({
146
- 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)",
147
148
  }),
148
149
  body: Type.String({ description: "Plain-text message body (≤ 32 KiB)" }),
149
150
  reply_to: Type.Optional(
@@ -188,10 +189,12 @@ export default function (pi: ExtensionAPI) {
188
189
  name: "list_sessions",
189
190
  label: "List Sessions",
190
191
  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",
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",
195
198
  parameters: Type.Object({}),
196
199
  async execute() {
197
200
  const text = formatListing(root, listRecords(root), selfAddress);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-post",
3
- "version": "0.3.0",
3
+ "version": "0.4.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,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
- lines.push("", "A directory path as a target resolves to the session registered in it.");
47
+ lines.push(
48
+ "",
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.",
51
+ );
36
52
  return lines.join("\n");
37
53
  }
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 {
@@ -36,9 +36,10 @@ function pick(target: string, matches: SessionRecord[]): ResolvedTarget {
36
36
  }
37
37
 
38
38
  /**
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.
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.
42
43
  */
43
44
  export function resolveTarget(root: string, target: string, cwd?: string): ResolvedTarget {
44
45
  const trimmed = target.trim();
@@ -61,11 +62,18 @@ export function resolveTarget(root: string, target: string, cwd?: string): Resol
61
62
  return pick(trimmed, matches);
62
63
  }
63
64
 
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
70
+ }
71
+
64
72
  const byName = records.filter((r) => r.name === trimmed);
65
73
  const matches = byName.length > 0 ? byName : records.filter((r) => basename(r.cwd) === trimmed);
66
74
  if (matches.length === 0) {
67
75
  throw new UnknownTargetError(
68
- `"${trimmed}" is not an address, a directory with a registered session, or a known session name`,
76
+ `"${trimmed}" is not an address, a session id, a directory with a registered session, or a known session name`,
69
77
  );
70
78
  }
71
79
  return pick(trimmed, matches);