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 +16 -0
- package/README.md +13 -6
- package/bin/pi-post.mjs +46 -10
- package/extensions/pi-post.ts +10 -7
- package/package.json +1 -1
- package/src/address.ts +9 -0
- package/src/format.ts +18 -2
- package/src/resolve.ts +13 -5
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,
|
|
52
|
-
reports **delivered** (consumed now) or **queued** (waiting
|
|
53
|
-
`list_sessions` shows known sessions, presence,
|
|
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
|
|
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(
|
|
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
|
}
|
package/extensions/pi-post.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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),
|
|
192
|
-
"queued mail counts
|
|
193
|
-
"is
|
|
194
|
-
|
|
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
|
+
"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(
|
|
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(
|
|
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.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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);
|