pi-post 0.7.1 → 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 +4 -2
- 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
|
@@ -41,8 +41,9 @@ smuggling state between sessions.
|
|
|
41
41
|
|
|
42
42
|
**An address that outlives the process.** A session address names a
|
|
43
43
|
conversation, not a process: the same session resumed tomorrow answers to
|
|
44
|
-
the same address, and messages queued while it was closed
|
|
45
|
-
|
|
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
|
|
46
47
|
session registered in that directory, live sessions first, ambiguity
|
|
47
48
|
refused.
|
|
48
49
|
|
|
@@ -143,6 +144,7 @@ sessions can read state; only one can consume a message.
|
|
|
143
144
|
| Variable | Default | Meaning |
|
|
144
145
|
| --- | --- | --- |
|
|
145
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 |
|
|
146
148
|
| `PI_POST_DIR` | `~/.pi/agent/post` | Where the registry and mailboxes live |
|
|
147
149
|
| `PI_POST_FROM` | — | Default `--from` label for the CLI |
|
|
148
150
|
| `PI_POST_REPLY_TO` | — | Default `--reply-to` address for the CLI |
|
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;
|