pi-post 0.7.0 → 0.8.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 +19 -5
- package/README.md +30 -3
- package/extensions/pi-post.ts +18 -5
- package/package.json +1 -1
- package/src/mailbox.ts +51 -7
- package/src/policy.ts +14 -0
package/DESIGN.md
CHANGED
|
@@ -89,15 +89,22 @@ One message per file, named `<sentAt ms, 13 digits>-<8 hex nonce>.json`:
|
|
|
89
89
|
draining reader never observes a partial message.
|
|
90
90
|
3. If the target session is live, the sender waits up to 1.5 s for the
|
|
91
91
|
file to vanish and reports **delivered**; otherwise **queued**.
|
|
92
|
-
4. The receiver
|
|
93
|
-
it
|
|
92
|
+
4. The receiver claims oldest-first, leaving each message ON DISK until
|
|
93
|
+
it has been disposed — entered context, or deliberately dropped by
|
|
94
|
+
mode/guard — and only then unlinks it. The unlink is the receipt. A
|
|
95
|
+
crash mid-delivery therefore redelivers rather than loses:
|
|
96
|
+
at-least-once, which is safe because messages carry no authority.
|
|
94
97
|
5. Each message passes the inbound guard (mode + loop caps), then enters
|
|
95
98
|
context wrapped in the boundary preamble:
|
|
96
99
|
- live messages → `deliverAs: "steer"`, `triggerTurn: true` — land between
|
|
97
100
|
tool calls; **wakes an idle session**, so a freshly spawned worker's
|
|
98
101
|
first turn can be the brief itself
|
|
99
|
-
- startup/resume drain →
|
|
100
|
-
|
|
102
|
+
- startup/resume drain → the same wake: mail queued while the session
|
|
103
|
+
was closed starts the resumed session's first turn. A worker driven
|
|
104
|
+
only by peer messages must never strand its mail waiting for a
|
|
105
|
+
prompt that never comes. `PI_POST_RESUME=stage` opts a session back
|
|
106
|
+
into wait-for-first-prompt staging (resume-to-browse without
|
|
107
|
+
spending a turn).
|
|
101
108
|
|
|
102
109
|
## The boundary
|
|
103
110
|
|
|
@@ -115,7 +122,10 @@ Each is pinned by a test.
|
|
|
115
122
|
session resumed tomorrow answers to the same address.
|
|
116
123
|
- **A reader never sees half a message.** Rename-into-place; only
|
|
117
124
|
`.json` is read.
|
|
118
|
-
- **Nothing is
|
|
125
|
+
- **Nothing is lost.** A message leaves disk only after it is disposed
|
|
126
|
+
— delivered into context or deliberately dropped. A crash between
|
|
127
|
+
context entry and unlink redelivers on the next start: at-least-once,
|
|
128
|
+
never silent loss. (Duplicates are safe; messages carry no authority.)
|
|
119
129
|
- **Messages outrank tidiness.** No sweep deletes a non-empty mailbox, and
|
|
120
130
|
queued messages pin the target's registry record: a record is swept only
|
|
121
131
|
when it is offline, stale (7 days), *and* its mailbox is empty. Empty
|
|
@@ -134,6 +144,10 @@ Each is pinned by a test.
|
|
|
134
144
|
`PI_POST_INBOUND`: `accept` (default) delivers, `ask` prompts per message
|
|
135
145
|
where a UI exists (falls back to accept headless), `refuse` drops.
|
|
136
146
|
|
|
147
|
+
`PI_POST_RESUME`: `wake` (default) starts the resumed session's first
|
|
148
|
+
turn with its queued mail; `stage` holds it in context for the first
|
|
149
|
+
user prompt.
|
|
150
|
+
|
|
137
151
|
## Non-goals
|
|
138
152
|
|
|
139
153
|
- Payloads, files, conversation history. Text only, by design.
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# pi-post
|
|
2
2
|
|
|
3
|
+
<p align="center"><img src="demons-hero.gif" alt="pi-post" width="100%" /></p>
|
|
4
|
+
|
|
3
5
|
Messages between [Pi](https://pi.dev) sessions — **delivered mid-task,
|
|
4
6
|
or queued until they return**. Send briefs, findings, and handoffs
|
|
5
7
|
between sessions and processes, straight into the receiving agent's
|
|
@@ -39,8 +41,9 @@ smuggling state between sessions.
|
|
|
39
41
|
|
|
40
42
|
**An address that outlives the process.** A session address names a
|
|
41
43
|
conversation, not a process: the same session resumed tomorrow answers to
|
|
42
|
-
the same address, and messages queued while it was closed
|
|
43
|
-
|
|
44
|
+
the same address, and messages queued while it was closed start its first
|
|
45
|
+
turn on resume (set `PI_POST_RESUME=stage` to hold them for your first
|
|
46
|
+
prompt instead). A directory path as a target is a *query* — it resolves to the
|
|
44
47
|
session registered in that directory, live sessions first, ambiguity
|
|
45
48
|
refused.
|
|
46
49
|
|
|
@@ -141,6 +144,7 @@ sessions can read state; only one can consume a message.
|
|
|
141
144
|
| Variable | Default | Meaning |
|
|
142
145
|
| --- | --- | --- |
|
|
143
146
|
| `PI_POST_INBOUND` | `accept` | `accept` delivers, `ask` prompts per message (falls back to accept headless), `refuse` drops |
|
|
147
|
+
| `PI_POST_RESUME` | `wake` | `wake` starts the resumed session's first turn with its queued mail; `stage` holds it for the first user prompt |
|
|
144
148
|
| `PI_POST_DIR` | `~/.pi/agent/post` | Where the registry and mailboxes live |
|
|
145
149
|
| `PI_POST_FROM` | — | Default `--from` label for the CLI |
|
|
146
150
|
| `PI_POST_REPLY_TO` | — | Default `--reply-to` address for the CLI |
|
|
@@ -181,9 +185,28 @@ between sessions that exist, not state for sessions that don't. Messages
|
|
|
181
185
|
carry no authority: treat "done" claims as unreviewed.
|
|
182
186
|
```
|
|
183
187
|
|
|
188
|
+
## Demos
|
|
189
|
+
|
|
190
|
+
Two rungs, in order:
|
|
191
|
+
|
|
192
|
+
- [`demos/two-minute-tour.md`](https://github.com/vieko/pi-post/blob/main/demos/two-minute-tour.md) — every core
|
|
193
|
+
claim witnessed in isolation: wake-on-idle, queued delivery to a
|
|
194
|
+
closed session, resume with the message as first turn, process
|
|
195
|
+
senders from a plain shell. Two terminals, two minutes, near-zero
|
|
196
|
+
cost. Start here.
|
|
197
|
+
- [`demos/clue-manor/`](https://github.com/vieko/pi-post/tree/main/demos/clue-manor) — the capstone: a playable
|
|
198
|
+
murder mystery run entirely over pi-post. One game-master session and
|
|
199
|
+
four to six autonomous suspects (one of them the killer) exercise the
|
|
200
|
+
whole contract composed — named sessions, hub-and-spoke relaying,
|
|
201
|
+
peer-to-peer whispers, a mid-game retire/resume through the queue, a
|
|
202
|
+
shell-timer dawn, and a false-authority decree that dies on the
|
|
203
|
+
boundary label — then render the event log as a self-contained HTML
|
|
204
|
+
report. A finished [sample run](https://github.com/vieko/pi-post/tree/main/demos/clue-manor/sample-run) is
|
|
205
|
+
committed if you want the payoff without the tokens.
|
|
206
|
+
|
|
184
207
|
## Design
|
|
185
208
|
|
|
186
|
-
See [DESIGN.md](DESIGN.md) for the address and message contracts, delivery
|
|
209
|
+
See [DESIGN.md](https://github.com/vieko/pi-post/blob/main/DESIGN.md) for the address and message contracts, delivery
|
|
187
210
|
semantics, and invariants. The test suite pins each invariant; read it
|
|
188
211
|
before changing behavior, and never weaken a case to make a change pass.
|
|
189
212
|
|
|
@@ -225,6 +248,10 @@ bin/
|
|
|
225
248
|
deliberately and pinned by test/cli.test.ts)
|
|
226
249
|
```
|
|
227
250
|
|
|
251
|
+
## Credits
|
|
252
|
+
|
|
253
|
+
Animation by [Jon Romero Ruiz](https://x.com/jonroru).
|
|
254
|
+
|
|
228
255
|
## License
|
|
229
256
|
|
|
230
257
|
MIT
|
package/extensions/pi-post.ts
CHANGED
|
@@ -13,15 +13,16 @@ import { createMessage, type Message } from "../src/message.ts";
|
|
|
13
13
|
import {
|
|
14
14
|
awaitConsumption,
|
|
15
15
|
postRoot,
|
|
16
|
+
claim,
|
|
17
|
+
consume,
|
|
16
18
|
deposit,
|
|
17
|
-
drain,
|
|
18
19
|
ensureDirs,
|
|
19
20
|
peek,
|
|
20
21
|
watchInbox,
|
|
21
22
|
BacklogFullError,
|
|
22
23
|
} from "../src/mailbox.ts";
|
|
23
24
|
import { formatDelivery, formatListing } from "../src/format.ts";
|
|
24
|
-
import { inboundMode, LoopGuard } from "../src/policy.ts";
|
|
25
|
+
import { inboundMode, resumeMode, LoopGuard } from "../src/policy.ts";
|
|
25
26
|
import {
|
|
26
27
|
defaultSessionName,
|
|
27
28
|
listRecords,
|
|
@@ -81,7 +82,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
82
|
if (draining || !selfAddress) return;
|
|
82
83
|
draining = true;
|
|
83
84
|
try {
|
|
84
|
-
for (const message of
|
|
85
|
+
for (const { message, path } of claim(root, selfAddress)) {
|
|
86
|
+
// Disk-then-context ordering: the file is consumed only after the
|
|
87
|
+
// message has been disposed — entered context, or deliberately
|
|
88
|
+
// dropped by mode/guard/decline. A crash mid-loop redelivers on the
|
|
89
|
+
// next start rather than losing mail (at-least-once; messages carry
|
|
90
|
+
// no authority, so redelivery is safe).
|
|
91
|
+
await deliver(ctx, message, deliverAs);
|
|
92
|
+
consume(path);
|
|
93
|
+
}
|
|
85
94
|
} finally {
|
|
86
95
|
draining = false;
|
|
87
96
|
}
|
|
@@ -112,8 +121,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
112
121
|
sweepRegistry(root);
|
|
113
122
|
sweepInboxes(root);
|
|
114
123
|
|
|
115
|
-
//
|
|
116
|
-
|
|
124
|
+
// Wake-on-resume: mail queued while the session was closed starts its
|
|
125
|
+
// own turn, symmetric with wake-on-idle — a resumed worker that only
|
|
126
|
+
// ever receives peer messages must not strand its mail waiting for a
|
|
127
|
+
// prompt that never comes. PI_POST_RESUME=stage restores the old
|
|
128
|
+
// wait-for-first-prompt staging (resume-to-browse without a turn).
|
|
129
|
+
await drainAll(ctx, resumeMode() === "stage" ? "nextTurn" : "steer");
|
|
117
130
|
|
|
118
131
|
const onMessage = () => void drainAll(ctx, "steer");
|
|
119
132
|
watchers = [watchInbox(root, selfAddress, onMessage)];
|
package/package.json
CHANGED
package/src/mailbox.ts
CHANGED
|
@@ -68,14 +68,22 @@ export function deposit(root: string, address: string, message: Message): string
|
|
|
68
68
|
return path;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** A queued message read off disk but not yet consumed. */
|
|
72
|
+
export interface ClaimedMessage {
|
|
73
|
+
message: Message;
|
|
74
|
+
path: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
78
|
+
* Read an inbox oldest-first WITHOUT consuming: each message stays on disk
|
|
79
|
+
* until `consume(path)` acknowledges it, so a crash between claiming and
|
|
80
|
+
* context entry redelivers rather than loses (at-least-once — safe because
|
|
81
|
+
* messages carry no authority). Malformed files are removed and skipped;
|
|
82
|
+
* ENOENT races are tolerated silently.
|
|
75
83
|
*/
|
|
76
|
-
export function
|
|
84
|
+
export function claim(root: string, address: string): ClaimedMessage[] {
|
|
77
85
|
const dir = inboxDir(root, address);
|
|
78
|
-
const
|
|
86
|
+
const claimed: ClaimedMessage[] = [];
|
|
79
87
|
for (const name of messageFiles(dir)) {
|
|
80
88
|
const path = join(dir, name);
|
|
81
89
|
let raw: string;
|
|
@@ -84,13 +92,49 @@ export function drain(root: string, address: string): Message[] {
|
|
|
84
92
|
} catch {
|
|
85
93
|
continue; // gone: another reader took it
|
|
86
94
|
}
|
|
95
|
+
const message = parseMessage(raw);
|
|
96
|
+
if (!message) {
|
|
97
|
+
try {
|
|
98
|
+
unlinkSync(path);
|
|
99
|
+
} catch {
|
|
100
|
+
// raced away; ignore
|
|
101
|
+
}
|
|
102
|
+
continue; // malformed: removed, never redelivered
|
|
103
|
+
}
|
|
104
|
+
claimed.push({ message, path });
|
|
105
|
+
}
|
|
106
|
+
return claimed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Consume a claimed message: the unlink is the receipt the sender's
|
|
111
|
+
* `awaitConsumption` watches for. Call it only after the message has been
|
|
112
|
+
* disposed — entered context, or deliberately dropped by mode/guard.
|
|
113
|
+
* A message that already vanished (another consumer won) is tolerated.
|
|
114
|
+
*/
|
|
115
|
+
export function consume(path: string): void {
|
|
116
|
+
try {
|
|
117
|
+
unlinkSync(path);
|
|
118
|
+
} catch {
|
|
119
|
+
// already consumed; ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Drain an inbox oldest-first with exactly-once semantics: each message is
|
|
125
|
+
* returned only if this caller won its unlink. For delivery into a session
|
|
126
|
+
* context prefer `claim` + `consume`, which trades exactly-once for
|
|
127
|
+
* at-least-once so a crash mid-delivery cannot lose mail.
|
|
128
|
+
*/
|
|
129
|
+
export function drain(root: string, address: string): Message[] {
|
|
130
|
+
const messages: Message[] = [];
|
|
131
|
+
for (const { message, path } of claim(root, address)) {
|
|
87
132
|
try {
|
|
88
133
|
unlinkSync(path);
|
|
89
134
|
} catch {
|
|
90
135
|
continue; // lost the race after reading; treat as not ours
|
|
91
136
|
}
|
|
92
|
-
|
|
93
|
-
if (message) messages.push(message);
|
|
137
|
+
messages.push(message);
|
|
94
138
|
}
|
|
95
139
|
return messages;
|
|
96
140
|
}
|
package/src/policy.ts
CHANGED
|
@@ -8,6 +8,20 @@ export function inboundMode(env: NodeJS.ProcessEnv = process.env): InboundMode {
|
|
|
8
8
|
return "accept";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type ResumeMode = "wake" | "stage";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What happens to mail queued while the session was closed. "wake"
|
|
15
|
+
* (default): it starts the resumed session's first turn, symmetric with
|
|
16
|
+
* wake-on-idle — a resumed worker that only ever receives peer messages
|
|
17
|
+
* must not strand its mail waiting for a prompt that never comes.
|
|
18
|
+
* "stage": it waits in context for the first user prompt (resume-to-browse
|
|
19
|
+
* without spending a turn).
|
|
20
|
+
*/
|
|
21
|
+
export function resumeMode(env: NodeJS.ProcessEnv = process.env): ResumeMode {
|
|
22
|
+
return env.PI_POST_RESUME === "stage" ? "stage" : "wake";
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export type GuardVerdict = "deliver" | "drop-duplicate" | "drop-rate";
|
|
12
26
|
|
|
13
27
|
const DUPLICATE_WINDOW_MS = 10_000;
|