pi-session-mail 0.1.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/tools.ts ADDED
@@ -0,0 +1,307 @@
1
+ // The model-facing tools of `pi-session-mail`. Internal to the package: tests reach
2
+ // them only through the extension's registration function.
3
+ //
4
+ // `session_mail_list` reports every running Pi session on this machine, in
5
+ // every agent directory, and marks the calling one. `session_mail_send` leaves a plain
6
+ // message, which wakes or steers its recipient and expects no answer.
7
+ // `session_mail_ask` leaves an ask and waits, one at a time, for the answer,
8
+ // which comes back as its result rather than as a message.
9
+ // `session_mail_reply` answers an ask this session received, mid-run. Mail a
10
+ // tool sends carries the turn's hop count and is refused once the count
11
+ // reaches `hopLimit` from `<agent dir>/session-mail.json`.
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+
18
+ import { label, listRunning, type RunningRecord, resolveTo } from "./running.ts";
19
+ import { type Envelope, send } from "./store.ts";
20
+
21
+ /** What the tools need from the extension around them. */
22
+ export type ToolHooks = {
23
+ /** Called after a tool wrote mail, so the footer can count it. */
24
+ sent: () => void;
25
+ /** The current turn's hop count, which mail sent now carries. */
26
+ hops: () => number;
27
+ /** Report a broken `session-mail.json`; the extension shows one warning per session. */
28
+ configProblem: (message: string) => void;
29
+ /** The address this session's ask now waits on, or "" when it waits on none; written to its running record. */
30
+ waitingOn: (address: string) => void;
31
+ /** Answer the open ask `id` this session received with `body` at once; throws, sending nothing, when it cannot. */
32
+ reply: (id: string, body: string) => Envelope;
33
+ };
34
+
35
+ /** What the extension needs from the tools: the ask that is waiting. */
36
+ export type Tools = {
37
+ /** Hand a reply to the waiting ask it answers; false when it answers none, so it is delivered as usual. */
38
+ takeAnswer: (envelope: Envelope) => boolean;
39
+ /** Stop waiting, as when the session shuts down. */
40
+ abandonAsk: () => void;
41
+ };
42
+
43
+ /** How long an ask waits for its answer (spec Q13). */
44
+ const ASK_TIMEOUT_MS = 10 * 60 * 1000;
45
+ /** How often a waiting ask checks that its target still runs. */
46
+ const TARGET_CHECK_MS = 1000;
47
+
48
+ /** How a waiting ask ended. */
49
+ type AskOutcome =
50
+ | { outcome: "answered"; status: string; body: string }
51
+ | { outcome: "timed-out" }
52
+ | { outcome: "stopped-running" }
53
+ | { outcome: "stopped-by-user" };
54
+
55
+ type WaitingAsk = { id: string; to: string; finish: (outcome: AskOutcome) => void };
56
+
57
+ /** The ask tool's result text for an outcome. */
58
+ function askText(ask: Envelope, target: string, outcome: AskOutcome): string {
59
+ const later = "A later answer arrives as a message.";
60
+ switch (outcome.outcome) {
61
+ case "answered":
62
+ return outcome.status === "done"
63
+ ? `${target} answered your ask ${ask.id}:\n\n${outcome.body}`
64
+ : `${target} ended its run with status "${outcome.status}", not "done": this is not an answer to your ask ${ask.id}.\n\n${outcome.body}`;
65
+ case "timed-out":
66
+ return `No answer yet: ${target} did not answer your ask ${ask.id} within 10 minutes, so this session stopped waiting. ${later}`;
67
+ case "stopped-running":
68
+ return `${target} stopped running before it answered your ask ${ask.id}, so this session stopped waiting. If it resumes and answers, the answer arrives as a message.`;
69
+ case "stopped-by-user":
70
+ return `The user stopped the wait for your ask ${ask.id} to ${target}. ${later}`;
71
+ }
72
+ }
73
+
74
+ /** The hop limit when `session-mail.json` is missing or broken (spec Q14). */
75
+ const DEFAULT_HOP_LIMIT = 5;
76
+
77
+ /**
78
+ * `hopLimit` from `<agent dir>/session-mail.json`, read at each send. A
79
+ * missing file, or one without `hopLimit`, means the default; an unreadable
80
+ * or invalid one means the default and a problem report.
81
+ */
82
+ function hopLimit(hooks: ToolHooks): number {
83
+ const path = join(getAgentDir(), "session-mail.json");
84
+ let text: string;
85
+ try {
86
+ text = readFileSync(path, "utf8");
87
+ } catch (err) {
88
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT")
89
+ hooks.configProblem(
90
+ `could not read ${path} (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT}`,
91
+ );
92
+ return DEFAULT_HOP_LIMIT;
93
+ }
94
+ let config: unknown;
95
+ try {
96
+ config = JSON.parse(text);
97
+ } catch (err) {
98
+ hooks.configProblem(
99
+ `${path} is not valid JSON (${(err as Error).message}); using the hop limit ${DEFAULT_HOP_LIMIT}`,
100
+ );
101
+ return DEFAULT_HOP_LIMIT;
102
+ }
103
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
104
+ hooks.configProblem(`${path} is not a JSON object; using the hop limit ${DEFAULT_HOP_LIMIT}`);
105
+ return DEFAULT_HOP_LIMIT;
106
+ }
107
+ const limit = (config as { hopLimit?: unknown }).hopLimit;
108
+ if (limit === undefined) return DEFAULT_HOP_LIMIT;
109
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) {
110
+ hooks.configProblem(
111
+ `hopLimit in ${path} must be a positive integer, not ${JSON.stringify(limit)}; using ${DEFAULT_HOP_LIMIT}`,
112
+ );
113
+ return DEFAULT_HOP_LIMIT;
114
+ }
115
+ return limit;
116
+ }
117
+
118
+ /** Refuse, before anything is written, once the turn's hop count has reached the limit. */
119
+ function checkHops(hops: number, hooks: ToolHooks): void {
120
+ const limit = hopLimit(hooks);
121
+ if (hops < limit) return;
122
+ throw new Error(
123
+ `nothing was sent: this turn is ${hops} hops into a chain of sessions waking each other with nobody typing, and the hop limit of ${limit} is reached. A person typing in either session starts the count again.`,
124
+ );
125
+ }
126
+
127
+ /** The one result every tool returns. */
128
+ function toolResult(text: string, details: unknown = {}) {
129
+ return { content: [{ type: "text" as const, text }], details };
130
+ }
131
+
132
+ /** One running session as the list shows it. */
133
+ function describe(record: RunningRecord, byAddress: ReadonlyMap<string, RunningRecord>, me: string): string {
134
+ const waiting = record.waitingOn === "" ? "" : `, waiting on ${waitLabel(record.waitingOn, byAddress)}`;
135
+ const self = record.address === me ? " (this session)" : "";
136
+ return `- ${label(record)}${self}\n id: ${record.address}\n cwd: ${record.cwd}\n state: ${record.state}${waiting}`;
137
+ }
138
+
139
+ function waitLabel(address: string, byAddress: ReadonlyMap<string, RunningRecord>): string {
140
+ const target = byAddress.get(address);
141
+ return target === undefined ? address : `${label(target)} (${address})`;
142
+ }
143
+
144
+ export function registerTools(pi: ExtensionAPI, hooks: ToolHooks): Tools {
145
+ /** The one ask of this session that is waiting for its answer (spec Q20). */
146
+ let waiting: WaitingAsk | undefined;
147
+
148
+ pi.registerTool({
149
+ name: "session_mail_list",
150
+ label: "Session mail: list",
151
+ description:
152
+ "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.",
153
+ parameters: Type.Object({}),
154
+ async execute(_toolCallId: string, _params: unknown, _signal: unknown, _onUpdate: unknown, ctx: ExtensionContext) {
155
+ const me = ctx.sessionManager.getSessionId();
156
+ const running = listRunning();
157
+ if (running.length === 0) return toolResult("No Pi sessions are running with a mailbox.", { sessions: [] });
158
+ const byAddress = new Map(running.map((record) => [record.address, record]));
159
+ const lines = running.map((record) => describe(record, byAddress, me));
160
+ const count = `${running.length} running session${running.length === 1 ? "" : "s"}:`;
161
+ return toolResult([count, ...lines].join("\n"), {
162
+ sessions: running.map((record) => ({ ...record, self: record.address === me })),
163
+ });
164
+ },
165
+ });
166
+
167
+ pi.registerTool({
168
+ name: "session_mail_send",
169
+ label: "Session mail: send",
170
+ description:
171
+ "Send a message to another Pi session on this machine. A running recipient that is idle starts a turn on it; a busy one reads it at its next gap between tool calls. The message expects no answer and nothing is sent back automatically; the recipient answers, if at all, with its own session_mail_send. `to` is a running session's name, an id prefix of at least 8 characters, or a full session id; a closed session is reached only by its full id, and the message waits in its inbox until it starts. session_mail_list shows who is running.",
172
+ parameters: Type.Object({
173
+ to: Type.String({
174
+ description: "The recipient: a running session's name, an id prefix of 8+ characters, or a full session id.",
175
+ }),
176
+ message: Type.String({
177
+ description: "The message text. The recipient sees it labelled as coming from this session.",
178
+ }),
179
+ }),
180
+ async execute(
181
+ _toolCallId: string,
182
+ params: { to: string; message: string },
183
+ _signal: unknown,
184
+ _onUpdate: unknown,
185
+ ctx: ExtensionContext,
186
+ ) {
187
+ const me = ctx.sessionManager.getSessionId();
188
+ const body = typeof params.message === "string" ? params.message.trim() : "";
189
+ if (body === "") throw new Error("the message is empty; nothing was sent");
190
+ const to = resolveTo(typeof params.to === "string" ? params.to : "", me);
191
+ const hops = hooks.hops();
192
+ checkHops(hops, hooks);
193
+ const envelope = send(me, to, body, { kind: "message", hops });
194
+ hooks.sent();
195
+ const target = listRunning().find((record) => record.address === to);
196
+ const text =
197
+ target === undefined
198
+ ? `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.`
199
+ : `Sent message ${envelope.id} to ${label(target)} (${to}). It expects no answer; any answer arrives as a message.`;
200
+ return toolResult(text, { id: envelope.id, to, running: target !== undefined });
201
+ },
202
+ });
203
+
204
+ pi.registerTool({
205
+ name: "session_mail_ask",
206
+ label: "Session mail: ask",
207
+ description:
208
+ "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.",
209
+ parameters: Type.Object({
210
+ to: Type.String({
211
+ description: "The running session to ask: its name, an id prefix of 8+ characters, or its full session id.",
212
+ }),
213
+ message: Type.String({
214
+ description:
215
+ "The question. The recipient sees it labelled as coming from this session, with this session waiting.",
216
+ }),
217
+ }),
218
+ async execute(
219
+ _toolCallId: string,
220
+ params: { to: string; message: string },
221
+ signal: AbortSignal | undefined,
222
+ _onUpdate: unknown,
223
+ ctx: ExtensionContext,
224
+ ) {
225
+ const me = ctx.sessionManager.getSessionId();
226
+ if (waiting !== undefined)
227
+ throw new Error(
228
+ `nothing was sent: your ask ${waiting.id} to ${waiting.to} is still waiting, and only one ask waits at a time`,
229
+ );
230
+ const body = typeof params.message === "string" ? params.message.trim() : "";
231
+ if (body === "") throw new Error("the question is empty; nothing was sent");
232
+ const to = resolveTo(typeof params.to === "string" ? params.to : "", me);
233
+ const record = listRunning().find((r) => r.address === to);
234
+ if (record === undefined)
235
+ throw new Error(
236
+ `nothing was sent: ${to} is not running, so it cannot answer; session_mail_send leaves a message that waits for it`,
237
+ );
238
+ const target = `${label(record)} (${to})`;
239
+ if (record.waitingOn === me)
240
+ throw new Error(
241
+ `nothing was sent: ${target} is waiting on an answer from this session, so asking it back would leave both waiting; answer it first`,
242
+ );
243
+ const hops = hooks.hops();
244
+ checkHops(hops, hooks);
245
+ const ask = send(me, to, body, { kind: "ask", hops });
246
+ hooks.sent();
247
+ const outcome = await new Promise<AskOutcome>((resolve) => {
248
+ const finish = (result: AskOutcome) => {
249
+ if (waiting?.id !== ask.id) return;
250
+ waiting = undefined;
251
+ clearTimeout(timer);
252
+ clearInterval(check);
253
+ signal?.removeEventListener("abort", onAbort);
254
+ hooks.waitingOn("");
255
+ resolve(result);
256
+ };
257
+ const onAbort = () => finish({ outcome: "stopped-by-user" });
258
+ const timer = setTimeout(() => finish({ outcome: "timed-out" }), ASK_TIMEOUT_MS);
259
+ const check = setInterval(() => {
260
+ if (!listRunning().some((r) => r.address === to)) finish({ outcome: "stopped-running" });
261
+ }, TARGET_CHECK_MS);
262
+ waiting = { id: ask.id, to, finish };
263
+ hooks.waitingOn(to);
264
+ if (signal?.aborted) onAbort();
265
+ else signal?.addEventListener("abort", onAbort, { once: true });
266
+ });
267
+ return toolResult(askText(ask, target, outcome), { id: ask.id, to, ...outcome });
268
+ },
269
+ });
270
+
271
+ pi.registerTool({
272
+ name: "session_mail_reply",
273
+ label: "Session mail: reply",
274
+ description:
275
+ "Answer an ask another Pi session sent this session, at once, without ending this run. The asker is waiting, and gets the message as the answer to its ask; the answer this run's last message would otherwise send is then skipped for that ask. `ask` is the ask id its label gives. Each ask is answered once; requests are answered automatically when the run settles, and messages expect no answer.",
276
+ parameters: Type.Object({
277
+ ask: Type.String({ description: "The ask id, as given in the ask's label." }),
278
+ message: Type.String({ description: "The answer." }),
279
+ }),
280
+ async execute(_toolCallId: string, params: { ask: string; message: string }) {
281
+ const id = typeof params.ask === "string" ? params.ask.trim() : "";
282
+ const body = typeof params.message === "string" ? params.message.trim() : "";
283
+ if (body === "") throw new Error("the answer is empty; nothing was sent");
284
+ const answer = hooks.reply(id, body);
285
+ hooks.sent();
286
+ const sender = listRunning().find((record) => record.address === answer.to);
287
+ const who = sender === undefined ? answer.to : `${label(sender)} (${answer.to})`;
288
+ return toolResult(`Answered ask ${id} from ${who}; this run's last message will not be sent for it.`, {
289
+ id: answer.id,
290
+ ask: id,
291
+ to: answer.to,
292
+ });
293
+ },
294
+ });
295
+
296
+ return {
297
+ takeAnswer(envelope) {
298
+ if (waiting === undefined || envelope.kind !== "reply" || !envelope.in_reply_to.includes(waiting.id))
299
+ return false;
300
+ waiting.finish({ outcome: "answered", status: envelope.status, body: envelope.body });
301
+ return true;
302
+ },
303
+ abandonAsk() {
304
+ waiting?.finish({ outcome: "stopped-by-user" });
305
+ },
306
+ };
307
+ }