pi-session-mail 0.2.1 → 0.3.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,29 @@ follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - `session_mail_list`, `session_mail_send`, `session_mail_ask` and
14
+ `session_mail_reply` declare an `outputSchema` and return matching
15
+ `structuredContent`, so a codemode script gets objects instead of text:
16
+ `{ sessions: [{ id, name?, cwd, state, waitingOn?, self }] }` from the list,
17
+ `{ id, to, running }` from a send, `{ id, to, outcome, status?, body? }`
18
+ from an ask (one variant per `outcome`: `answered`, `timed-out`,
19
+ `stopped-running`, `stopped-by-user`) and `{ id, ask, to }` from a reply.
20
+ The text the model sees is unchanged.
21
+ - The four tools declare all four MCP tool annotations: the list is read-only
22
+ and idempotent; send, ask and reply are not read-only and not destructive;
23
+ reply is idempotent, since a repeat is refused; none reaches an open world.
24
+
25
+ ## [0.2.2] - 2026-10-02
26
+
27
+ ### Fixed
28
+
29
+ - The README links `CONTRIBUTING.md` and `SECURITY.md` on GitHub, since
30
+ neither is in the npm package and their relative links 404 on pi.dev.
31
+
9
32
  ## [0.2.1] - 2026-10-02
10
33
 
11
34
  ### Fixed
@@ -111,7 +134,9 @@ author's dotfiles; the changes below are against that copy.
111
134
  - CI on Node 22.19 and 24: lint, typecheck, tests, and a check of the
112
135
  published file list.
113
136
 
114
- [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.1...HEAD
137
+ [Unreleased]: https://github.com/gvanderclay/pi-session-mail/compare/v0.3.0...HEAD
138
+ [0.3.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.2...v0.3.0
139
+ [0.2.2]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.1...v0.2.2
115
140
  [0.2.1]: https://github.com/gvanderclay/pi-session-mail/compare/v0.2.0...v0.2.1
116
141
  [0.2.0]: https://github.com/gvanderclay/pi-session-mail/compare/v0.1.0...v0.2.0
117
142
  [0.1.0]: https://github.com/gvanderclay/pi-session-mail/releases/tag/v0.1.0
package/README.md CHANGED
@@ -470,10 +470,10 @@ while the package is at 0.x.
470
470
 
471
471
  ## More
472
472
 
473
- - [CONTRIBUTING.md](CONTRIBUTING.md) explains how to set up, test and propose a
473
+ - [CONTRIBUTING.md](https://github.com/gvanderclay/pi-session-mail/blob/main/CONTRIBUTING.md) explains how to set up, test and propose a
474
474
  change.
475
475
  - [CHANGELOG.md](CHANGELOG.md) lists what changed in each release.
476
- - [SECURITY.md](SECURITY.md) explains how to report a security problem.
476
+ - [SECURITY.md](https://github.com/gvanderclay/pi-session-mail/blob/main/SECURITY.md) explains how to report a security problem.
477
477
 
478
478
  ## License
479
479
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-session-mail",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A file mailbox between Pi sessions on the same machine",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/tools.ts CHANGED
@@ -118,11 +118,64 @@ function checkHops(hops: number, hooks: ToolHooks): void {
118
118
  );
119
119
  }
120
120
 
121
- /** The one result every tool returns. */
122
- function toolResult(text: string, details: unknown = {}) {
123
- return { content: [{ type: "text" as const, text }], details };
121
+ /**
122
+ * The one result every tool returns. `structuredContent` is what codemode scripts
123
+ * receive; it must match the tool's `outputSchema`, which Pi does not check.
124
+ */
125
+ function toolResult<D, S>(text: string, details: D, structuredContent: S) {
126
+ return { content: [{ type: "text" as const, text }], details, structuredContent };
124
127
  }
125
128
 
129
+ /** Hints for a tool that adds mail and never deletes or overwrites any, on this machine only. */
130
+ const SENDS_MAIL = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
131
+
132
+ const SessionList = Type.Object({
133
+ sessions: Type.Array(
134
+ Type.Object({
135
+ id: Type.String({ description: "The session's full id; pass it as `to`" }),
136
+ name: Type.Optional(Type.String({ description: "The session name; absent when none is set" })),
137
+ cwd: Type.String(),
138
+ state: Type.Union([Type.Literal("idle"), Type.Literal("busy")]),
139
+ waitingOn: Type.Optional(
140
+ Type.String({ description: "The full id of the session this one's ask waits on; absent when none" }),
141
+ ),
142
+ self: Type.Boolean({ description: "True for the calling session" }),
143
+ }),
144
+ ),
145
+ });
146
+
147
+ const SendResult = Type.Object({
148
+ id: Type.String({ description: "The message's id" }),
149
+ to: Type.String({ description: "The recipient's full id" }),
150
+ running: Type.Boolean({ description: "False when the message waits in a closed session's inbox" }),
151
+ });
152
+
153
+ const asked = {
154
+ id: Type.String({ description: "The ask's id" }),
155
+ to: Type.String({ description: "The asked session's full id" }),
156
+ };
157
+
158
+ /** One variant per {@link AskOutcome}, told apart by `outcome`. */
159
+ const AskResult = Type.Union([
160
+ Type.Object({
161
+ ...asked,
162
+ outcome: Type.Literal("answered"),
163
+ status: Type.String({
164
+ description: '"done" for an answer; any other status means the run ended otherwise, and body is not an answer',
165
+ }),
166
+ body: Type.String(),
167
+ }),
168
+ Type.Object({ ...asked, outcome: Type.Literal("timed-out") }),
169
+ Type.Object({ ...asked, outcome: Type.Literal("stopped-running") }),
170
+ Type.Object({ ...asked, outcome: Type.Literal("stopped-by-user") }),
171
+ ]);
172
+
173
+ const ReplyResult = Type.Object({
174
+ id: Type.String({ description: "The reply's id" }),
175
+ ask: Type.String({ description: "The id of the ask it answers" }),
176
+ to: Type.String({ description: "The asker's full id" }),
177
+ });
178
+
126
179
  /** One running session as the list shows it. */
127
180
  function describe(record: RunningRecord, byAddress: ReadonlyMap<string, RunningRecord>, me: string): string {
128
181
  const waiting = record.waitingOn === "" ? "" : `, waiting on ${waitLabel(record.waitingOn, byAddress)}`;
@@ -170,16 +223,31 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
170
223
  description:
171
224
  "List the Pi sessions running on this machine, in every agent root: each one's name (or short id when it has none), full id, working directory, idle or busy state, and whom it is waiting on. The calling session is marked. A session is addressed by its name, its full id, or an id prefix of at least 8 characters.",
172
225
  parameters: Type.Object({}),
226
+ outputSchema: SessionList,
227
+ // Listing deletes the records of sessions whose process is gone, which is housekeeping, not a change.
228
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
173
229
  async execute(_toolCallId: string, _params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
174
230
  const me = ctx.sessionManager.getSessionId();
175
231
  const running = listRunning();
176
- if (running.length === 0) return toolResult("No Pi sessions are running with a mailbox.", { sessions: [] });
232
+ if (running.length === 0)
233
+ return toolResult("No Pi sessions are running with a mailbox.", { sessions: [] }, { sessions: [] });
177
234
  const byAddress = new Map(running.map((record) => [record.address, record]));
178
235
  const lines = running.map((record) => describe(record, byAddress, me));
179
236
  const count = `${running.length} running session${running.length === 1 ? "" : "s"}:`;
180
- return toolResult([count, ...lines].join("\n"), {
181
- sessions: running.map((record) => ({ ...record, self: record.address === me })),
182
- });
237
+ return toolResult(
238
+ [count, ...lines].join("\n"),
239
+ { sessions: running.map((record) => ({ ...record, self: record.address === me })) },
240
+ {
241
+ sessions: running.map(({ address, name, cwd, state, waitingOn }) => ({
242
+ id: address,
243
+ ...(name === undefined ? {} : { name }),
244
+ cwd,
245
+ state,
246
+ ...(waitingOn === "" ? {} : { waitingOn }),
247
+ self: address === me,
248
+ })),
249
+ },
250
+ );
183
251
  },
184
252
  });
185
253
 
@@ -196,6 +264,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
196
264
  description: "The message text. The recipient sees it labelled as coming from this session.",
197
265
  }),
198
266
  }),
267
+ outputSchema: SendResult,
268
+ annotations: SENDS_MAIL,
199
269
  async execute(
200
270
  _toolCallId: string,
201
271
  params: { to: string; message: string },
@@ -216,7 +286,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
216
286
  target === undefined
217
287
  ? `Message ${envelope.id} left for ${to}, which is not running: it waits in that session's inbox and is delivered when the session starts or resumes.`
218
288
  : `Sent message ${envelope.id} to ${label(target)} (${to}). It expects no answer; any answer arrives as a message.`;
219
- return toolResult(text, { id: envelope.id, to, running: target !== undefined });
289
+ const result = { id: envelope.id, to, running: target !== undefined };
290
+ return toolResult(text, result, result);
220
291
  },
221
292
  });
222
293
 
@@ -234,6 +305,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
234
305
  "The question. The recipient sees it labelled as coming from this session, with this session waiting.",
235
306
  }),
236
307
  }),
308
+ outputSchema: AskResult,
309
+ annotations: SENDS_MAIL,
237
310
  async execute(
238
311
  _toolCallId: string,
239
312
  params: { to: string; message: string },
@@ -283,7 +356,8 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
283
356
  if (signal?.aborted) onAbort();
284
357
  else signal?.addEventListener("abort", onAbort, { once: true });
285
358
  });
286
- return toolResult(askText(ask, target, outcome), { id: ask.id, to, ...outcome });
359
+ const result = { id: ask.id, to, ...outcome };
360
+ return toolResult(askText(ask, target, outcome), result, result);
287
361
  },
288
362
  });
289
363
 
@@ -296,6 +370,9 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
296
370
  ask: Type.String({ description: "The ask id, as given in the ask's label." }),
297
371
  message: Type.String({ description: "The answer." }),
298
372
  }),
373
+ outputSchema: ReplyResult,
374
+ // A repeated reply to the same ask is refused and sends nothing.
375
+ annotations: { ...SENDS_MAIL, idempotentHint: true },
299
376
  async execute(
300
377
  _toolCallId: string,
301
378
  params: { ask: string; message: string },
@@ -310,11 +387,12 @@ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
310
387
  hooks.sent();
311
388
  const sender = listRunning().find((record) => record.address === answer.to);
312
389
  const who = sender === undefined ? answer.to : `${label(sender)} (${answer.to})`;
313
- return toolResult(`Answered ask ${id} from ${who}; this run's last message will not be sent for it.`, {
314
- id: answer.id,
315
- ask: id,
316
- to: answer.to,
317
- });
390
+ const result = { id: answer.id, ask: id, to: answer.to };
391
+ return toolResult(
392
+ `Answered ask ${id} from ${who}; this run's last message will not be sent for it.`,
393
+ result,
394
+ result,
395
+ );
318
396
  },
319
397
  });
320
398