pi-session-mail 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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-23
10
+
11
+ ### Changed
12
+
13
+ - `session_mail_ask` no longer holds the turn of a session a person is
14
+ watching (a TUI without `PI_UNATTENDED=1`): it returns at once with outcome
15
+ `background`, and the answer arrives as a message that starts a turn, so the
16
+ user can keep talking to the asker. Unattended sessions, such as delegates,
17
+ still wait for the answer. The label on an answer to an ask that is not
18
+ waiting now reads "is not waiting for it" instead of "has stopped waiting",
19
+ and the test pinning it changed with it.
20
+
9
21
  ## [0.4.0] - 2026-10-20
10
22
 
11
23
  ### Added
@@ -176,7 +188,8 @@ author's dotfiles; the changes below are against that copy.
176
188
  - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
177
189
  published file list.
178
190
 
179
- [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...HEAD
191
+ [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.5.0...HEAD
192
+ [0.5.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.4.0...v0.5.0
180
193
  [0.4.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...v0.4.0
181
194
  [0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
182
195
  [0.2.2]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.1...v0.2.2
package/README.md CHANGED
@@ -131,7 +131,7 @@ The model gets four tools:
131
131
  | Tool | Parameters | What it does |
132
132
  | --- | --- | --- |
133
133
  | `session_mail_list` | none | Lists every running session, in every agent directory: its name (or short id, the first 8 characters of its id, when it has no name), full id, working directory, idle or busy state and whom it is waiting on, and marks the calling session. |
134
- | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`) and waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the [hop limit](#hop-limit), and when `to` does not resolve, is ambiguous or is this session. |
134
+ | `session_mail_ask` | `to`, `message` | Writes an ask (`kind: "ask"`). In a TUI session without `PI_UNATTENDED=1` it returns at once with outcome `background`, sets no `waitingOn`, and the answer arrives as a message. Everywhere else (print, JSON and RPC modes, and a TUI marked unattended, such as a pi-squire delegate) it waits for the answer, which is the result: its status and body, labelled as not an answer when the status is `stopped` or `failed`. The wait ends when the answer arrives, after 10 minutes ("no answer yet"), when the target's running record disappears ("stopped running"), or when the user stops the run; `waitingOn` is set while it waits and cleared in every case. Refused at once, writing nothing, when another ask of this session is waiting, when the target is not running, when the target is waiting on this session, at the [hop limit](#hop-limit), and when `to` does not resolve, is ambiguous or is this session. |
135
135
  | `session_mail_reply` | `ask`, `message` | Answers one open ask this session received, at once, with status `done` and the turn's hop count, without ending the run; the asker's `session_mail_ask` returns with it. The answer at settle then leaves that ask out, while the sender's other requests and asks are still answered. Refused, sending nothing, for an ask already answered (by this tool or at settle), for a request (answered at settle), a message or a reply, for an id that never reached this session, and for an empty text. |
136
136
  | `session_mail_send` | `to`, `message` | Writes a message (`kind: "message"`) and returns its id. To a running session it is delivered at once; to a closed session, by full id, it waits in that session's inbox and the result says so. Refused when `to` does not resolve, is ambiguous or is this session, when the text is empty, and at the [hop limit](#hop-limit). |
137
137
 
@@ -168,9 +168,12 @@ listener takes it over, as pi-squire's `delegate` does for its tasks.
168
168
 
169
169
  A reply to this session's waiting ask goes to the waiting tool call as its
170
170
  result: it is neither injected nor emitted on `message:inbound`. A reply to an
171
- ask that is no longer waiting (it timed out, the user stopped it, or the
172
- session restarted) is delivered like a message: it starts a turn, and its label
173
- says the ask stopped waiting.
171
+ ask that is not waiting (a background ask, or one that timed out, that the
172
+ user stopped, or whose session restarted) is delivered like a message: it
173
+ starts a turn, and its label says the ask is not waiting for it. An
174
+ unattended session waits rather than asking in the background because ending
175
+ its turn ends its work: a delegate's settle is its result, and print mode
176
+ exits.
174
177
 
175
178
  Every injected message opens with a neutral label: `[mailbox] From <name>
176
179
  (<full id>, working in <cwd>), another Pi session on this machine.` The short
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mail",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "A file mailbox between Pi sessions on the same machine",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -147,7 +147,7 @@ function inboundText(
147
147
  header.push(
148
148
  `It is waiting for your answer to its ask ${id}. Answer with session_mail_reply (ask ${id}); otherwise this run's last message is sent as the answer.`,
149
149
  );
150
- if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
150
+ if (lateAnswer) header.push("It answers an ask of yours that is not waiting for it, so it arrives as a message.");
151
151
  if (envelope.kind === "message")
152
152
  header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
153
153
  if (envelope.in_reply_to.length > 0)
@@ -388,7 +388,7 @@ export default function mailbox(pi: ExtensionAPI) {
388
388
  // The answer to a waiting ask is that tool call's result, and nothing else.
389
389
  if (tools?.takeAnswer(envelope)) return;
390
390
  const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
391
- // An answer to an ask that stopped waiting is delivered like a message.
391
+ // An answer to an ask that is not waiting (a background ask, or one that stopped) is delivered like a message.
392
392
  const lateAnswer = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "ask");
393
393
  const wakes = envelope.kind !== "reply" || lateAnswer;
394
394
  const byUser = envelope.kind === "reply" && requests.some((copy) => copy.envelope.kind === "request");
package/src/tools.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  // `session_mail_list` reports every running Pi session on this machine, in
6
6
  // every agent directory, and marks the calling one. `session_mail_send` leaves a plain
7
7
  // message, which wakes or steers its recipient and expects no answer.
8
- // `session_mail_ask` leaves an ask and waits, one at a time, for the answer,
9
- // which comes back as its result rather than as a message.
8
+ // `session_mail_ask` leaves an ask. In a session a person is watching it
9
+ // returns at once and the answer arrives as a message; elsewhere it waits, one
10
+ // at a time, for the answer, which comes back as its result. An unattended
11
+ // session must wait because ending its turn ends its work: a delegate's settle
12
+ // is its result, and print mode exits.
10
13
  // `session_mail_reply` answers an ask this session received, mid-run; the asks
11
14
  // this session owes, and the kind of every envelope it has received, are kept
12
15
  // here for it. Mail a
@@ -53,8 +56,14 @@ const ASK_TIMEOUT_MS = 10 * 60 * 1000;
53
56
  /** How often a waiting ask checks that its target still runs. */
54
57
  const TARGET_CHECK_MS = 1000;
55
58
 
56
- /** How a waiting ask ended. */
59
+ /** Whether a person is watching this session, so an ask need not hold its turn; `PI_UNATTENDED=1` marks a TUI nobody watches. */
60
+ function attended(ctx: ExtensionContext): boolean {
61
+ return ctx.mode === "tui" && process.env.PI_UNATTENDED !== "1";
62
+ }
63
+
64
+ /** How an ask's tool call ended: at once in the background, or how its wait ended. */
57
65
  type AskOutcome =
66
+ | { outcome: "background" }
58
67
  | { outcome: "answered"; status: string; body: string }
59
68
  | { outcome: "timed-out" }
60
69
  | { outcome: "stopped-running" }
@@ -66,6 +75,8 @@ type WaitingAsk = { id: string; to: string; finish: (outcome: AskOutcome) => voi
66
75
  function askText(ask: Envelope, target: string, outcome: AskOutcome): string {
67
76
  const later = "A later answer arrives as a message.";
68
77
  switch (outcome.outcome) {
78
+ case "background":
79
+ return `Sent ask ${ask.id} to ${target}. This session is not waiting: the answer arrives as a message and starts a turn on its own, so go on with other work or end your turn.`;
69
80
  case "answered":
70
81
  return outcome.status === "done"
71
82
  ? `${target} answered your ask ${ask.id}:\n\n${outcome.body}`
@@ -166,6 +177,7 @@ const AskResult = Type.Union([
166
177
  Type.Object({ ...asked, outcome: Type.Literal("timed-out") }),
167
178
  Type.Object({ ...asked, outcome: Type.Literal("stopped-running") }),
168
179
  Type.Object({ ...asked, outcome: Type.Literal("stopped-by-user") }),
180
+ Type.Object({ ...asked, outcome: Type.Literal("background") }),
169
181
  ]);
170
182
 
171
183
  const ReplyResult = Type.Object({
@@ -292,14 +304,13 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
292
304
  name: "session_mail_ask",
293
305
  label: "Session mail: ask",
294
306
  description:
295
- "Ask another running Pi session on this machine a question and wait for its answer, which comes back as this tool's result. The answer is the other session's final message when its run settles, or an earlier reply it sends. The wait ends after 10 minutes, when the other session stops running, or when the user stops it; a later answer then arrives as a message. Only one ask waits at a time, and a session that is waiting on this one cannot be asked. `to` is a running session's name, an id prefix of at least 8 characters, or a full session id; session_mail_list shows who is running. For a note that needs no answer, use session_mail_send.",
307
+ "Ask another running Pi session on this machine a question. The answer is the other session's final message when its run settles, or an earlier reply it sends. In a session a person is watching, the ask returns at once and the answer arrives later as a message that starts a turn. In an unattended session, such as a delegate, the ask waits up to 10 minutes and the answer comes back as this tool's result. `to` is a running session's name, an id prefix of at least 8 characters, or a full session id; session_mail_list shows who is running. For a note that needs no answer, use session_mail_send.",
296
308
  parameters: Type.Object({
297
309
  to: Type.String({
298
310
  description: "The running session to ask: its name, an id prefix of 8+ characters, or its full session id.",
299
311
  }),
300
312
  message: Type.String({
301
- description:
302
- "The question. The recipient sees it labelled as coming from this session, with this session waiting.",
313
+ description: "The question. The recipient sees it labelled as coming from this session.",
303
314
  }),
304
315
  }),
305
316
  outputSchema: AskResult,
@@ -332,26 +343,28 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
332
343
  const hops = hooks.hops();
333
344
  checkHops(hops, hooks);
334
345
  const ask = send(me, to, body, { kind: "ask", hops });
335
- const outcome = await new Promise<AskOutcome>((resolve) => {
336
- const finish = (result: AskOutcome) => {
337
- if (waiting?.id !== ask.id) return;
338
- waiting = undefined;
339
- clearTimeout(timer);
340
- clearInterval(check);
341
- signal?.removeEventListener("abort", onAbort);
342
- hooks.waitingOn("");
343
- resolve(result);
344
- };
345
- const onAbort = () => finish({ outcome: "stopped-by-user" });
346
- const timer = setTimeout(() => finish({ outcome: "timed-out" }), ASK_TIMEOUT_MS);
347
- const check = setInterval(() => {
348
- if (!listRunning().some((r) => r.address === to)) finish({ outcome: "stopped-running" });
349
- }, TARGET_CHECK_MS);
350
- waiting = { id: ask.id, to, finish };
351
- hooks.waitingOn(to);
352
- if (signal?.aborted) onAbort();
353
- else signal?.addEventListener("abort", onAbort, { once: true });
354
- });
346
+ const outcome: AskOutcome = attended(ctx)
347
+ ? { outcome: "background" }
348
+ : await new Promise<AskOutcome>((resolve) => {
349
+ const finish = (result: AskOutcome) => {
350
+ if (waiting?.id !== ask.id) return;
351
+ waiting = undefined;
352
+ clearTimeout(timer);
353
+ clearInterval(check);
354
+ signal?.removeEventListener("abort", onAbort);
355
+ hooks.waitingOn("");
356
+ resolve(result);
357
+ };
358
+ const onAbort = () => finish({ outcome: "stopped-by-user" });
359
+ const timer = setTimeout(() => finish({ outcome: "timed-out" }), ASK_TIMEOUT_MS);
360
+ const check = setInterval(() => {
361
+ if (!listRunning().some((r) => r.address === to)) finish({ outcome: "stopped-running" });
362
+ }, TARGET_CHECK_MS);
363
+ waiting = { id: ask.id, to, finish };
364
+ hooks.waitingOn(to);
365
+ if (signal?.aborted) onAbort();
366
+ else signal?.addEventListener("abort", onAbort, { once: true });
367
+ });
355
368
  const result = { id: ask.id, to, ...outcome };
356
369
  return toolResult(askText(ask, target, outcome), result, result);
357
370
  },