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