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 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 drains oldest-first, unlinking each message as it reads
93
- it. Nothing is delivered twice; consumption is the receipt.
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 → `deliverAs: "nextTurn"` — waits in context for
100
- the next prompt; a queued message never starts a turn on its own
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 delivered twice.** Unlink before handling.
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 land in-context on
45
- resume. A directory path as a target is a *query* — it resolves to the
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 |
@@ -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 drain(root, selfAddress)) await deliver(ctx, message, deliverAs);
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
- // Queued messages wait in context for the first prompt; they never start a turn.
116
- await drainAll(ctx, "nextTurn");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-post",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Messages between pi sessions — delivered mid-task or queued until they return.",
5
5
  "keywords": [
6
6
  "pi-package",
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
- * Drain an inbox oldest-first. Each message is unlinked *before* it is
73
- * returned, so nothing is ever delivered twice. Malformed files are removed
74
- * and skipped. ENOENT races (another drain won) are tolerated silently.
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 drain(root: string, address: string): Message[] {
84
+ export function claim(root: string, address: string): ClaimedMessage[] {
77
85
  const dir = inboxDir(root, address);
78
- const messages: Message[] = [];
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
- const message = parseMessage(raw);
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;