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 +7 -2
- package/README.md +28 -13
- package/bin/pi-post.mjs +93 -37
- package/extensions/pi-post.ts +76 -43
- package/package.json +1 -1
- package/src/format.ts +46 -4
- package/src/registry.ts +54 -12
- package/src/resolve.ts +26 -0
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
|
|
84
|
-
| `list_sessions` (tool) | Known sessions,
|
|
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
|
|
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
|
|
108
|
+
### Dispatch patterns
|
|
109
109
|
|
|
110
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
ends for future sessions go to
|
|
159
|
-
|
|
160
|
-
for sessions that don't. Messages
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
240
|
+
if (!existsSync(path)) consumed = true;
|
|
212
241
|
}
|
|
213
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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} (${
|
|
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
|
}
|
package/extensions/pi-post.ts
CHANGED
|
@@ -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 {
|
|
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() ??
|
|
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
|
|
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,
|
|
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
|
-
"
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
message = createMessage({ from: senderFrom(ctx), body: params.body, replyTo });
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
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
|
|
194
|
-
"listed directory).
|
|
195
|
-
"
|
|
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
|
-
|
|
200
|
-
|
|
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 (
|
|
208
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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} (${
|
|
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
|
|
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
|
-
/**
|
|
83
|
-
|
|
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)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|