pi-session-mail 0.1.0 → 0.2.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 +86 -1
- package/README.md +280 -157
- package/package.json +3 -6
- package/src/config.ts +46 -0
- package/{index.ts → src/index.ts} +139 -134
- package/{running.ts → src/running.ts} +118 -10
- package/src/settle.ts +82 -0
- package/src/store.ts +412 -0
- package/{tools.ts → src/tools.ts} +80 -40
- package/store.ts +0 -222
|
@@ -23,10 +23,21 @@
|
|
|
23
23
|
// `pi.events` (below).
|
|
24
24
|
//
|
|
25
25
|
// The hook contracts live in this package's README.
|
|
26
|
-
import { type FSWatcher, watch } from "node:fs";
|
|
26
|
+
import { type FSWatcher, statSync, watch } from "node:fs";
|
|
27
|
+
import { dirname } from "node:path";
|
|
27
28
|
|
|
28
29
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
isRunning,
|
|
32
|
+
label,
|
|
33
|
+
listRunning,
|
|
34
|
+
type RunningRecord,
|
|
35
|
+
removeRecord,
|
|
36
|
+
resolveTo,
|
|
37
|
+
type State,
|
|
38
|
+
writeRecord,
|
|
39
|
+
} from "./running.ts";
|
|
40
|
+
import { type SettleInput, settle } from "./settle.ts";
|
|
30
41
|
import {
|
|
31
42
|
boxPath,
|
|
32
43
|
claim,
|
|
@@ -36,51 +47,45 @@ import {
|
|
|
36
47
|
findSent,
|
|
37
48
|
isAddress,
|
|
38
49
|
listNew,
|
|
50
|
+
pruneClosed,
|
|
39
51
|
readEnvelope,
|
|
40
52
|
type SentCopy,
|
|
41
53
|
send,
|
|
42
54
|
} from "./store.ts";
|
|
43
|
-
import { registerTools, type Tools } from "./tools.ts";
|
|
55
|
+
import { readSettings, registerTools, type Tools } from "./tools.ts";
|
|
44
56
|
|
|
45
57
|
const CUSTOM_TYPE = "mailbox";
|
|
46
58
|
const STATUS_KEY = "mailbox";
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
47
60
|
/** Fallback rescan interval; `fs.watch` on macOS drops and coalesces events. */
|
|
48
61
|
const POLL_MS = 1000;
|
|
49
62
|
/** Inbound bodies are cut at this many UTF-8 bytes. */
|
|
50
63
|
const BODY_CAP = 32 * 1024;
|
|
51
64
|
/** Each request quoted in a reply is cut at this many UTF-8 bytes. */
|
|
52
65
|
const QUOTE_CAP = 2 * 1024;
|
|
53
|
-
/** Reply body when the run ended with no assistant text. */
|
|
54
|
-
const NO_ANSWER = "(The session settled with no answer text.)";
|
|
55
|
-
/** Reply body of a run the user stopped, with any partial answer text after it. */
|
|
56
|
-
const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
|
|
57
|
-
/** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
|
|
58
|
-
const TOOK_OVER =
|
|
59
|
-
"(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
|
|
60
|
-
/** Opening line of the reply body of a run that ended on an error. */
|
|
61
|
-
const ERRORED = (error: string) =>
|
|
62
|
-
`(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
|
|
63
|
-
/** Body of a `failed` reply to requests that never entered the conversation. */
|
|
64
|
-
const UNSEEN =
|
|
65
|
-
"(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
|
|
66
|
-
|
|
67
66
|
/** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
|
|
68
67
|
function cap(text: string, max: number): string | undefined {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
|
|
73
|
-
return bytes.subarray(0, end).toString("utf8");
|
|
68
|
+
// `encodeInto` stops before a character that does not fit, so the cut is on a boundary.
|
|
69
|
+
const { read } = new TextEncoder().encodeInto(text, new Uint8Array(max));
|
|
70
|
+
return read === text.length ? undefined : text.slice(0, read);
|
|
74
71
|
}
|
|
75
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Text from outside this session (an id, a name, a working directory) with every
|
|
75
|
+
* control character (C0, DEL, C1) and the line and paragraph separators
|
|
76
|
+
* U+2028/U+2029 replaced by a space, so it cannot start a line of its own and
|
|
77
|
+
* pass for a `[mailbox]` line.
|
|
78
|
+
*/
|
|
79
|
+
const plain = (text: string): string => text.replace(/[\p{Cc}\u2028\u2029]/gu, " ");
|
|
80
|
+
|
|
76
81
|
/** A reply quotes each request it answers: the id, the copy's path, then the capped body. */
|
|
77
82
|
function quoteRequest(request: SentCopy): string {
|
|
78
83
|
const cut = cap(request.envelope.body, QUOTE_CAP);
|
|
79
84
|
const body =
|
|
80
85
|
cut === undefined
|
|
81
86
|
? request.envelope.body
|
|
82
|
-
: `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${request.path}`;
|
|
83
|
-
return `[mailbox] Your request ${request.envelope.id}, quoted from ${request.path}:\n${body
|
|
87
|
+
: `${cut}\n[mailbox] Request cut at 2 KiB; the full copy is ${plain(request.path)}`;
|
|
88
|
+
return `[mailbox] Your request ${plain(request.envelope.id)}, quoted from ${plain(request.path)}:\n${body
|
|
84
89
|
.split("\n")
|
|
85
90
|
.map((line) => `> ${line}`)
|
|
86
91
|
.join("\n")}`;
|
|
@@ -93,10 +98,11 @@ function quoteRequest(request: SentCopy): string {
|
|
|
93
98
|
function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy[]; quotes: string[] } {
|
|
94
99
|
const requests: SentCopy[] = [];
|
|
95
100
|
const quotes: string[] = [];
|
|
101
|
+
const copies = findSent(me, ids);
|
|
96
102
|
for (const id of ids) {
|
|
97
|
-
const copy =
|
|
103
|
+
const copy = copies.get(id);
|
|
98
104
|
if (copy === undefined) {
|
|
99
|
-
quotes.push(`[mailbox] Your request ${id} has no copy in sent/; only its id is known.`);
|
|
105
|
+
quotes.push(`[mailbox] Your request ${plain(id)} has no copy in sent/; only its id is known.`);
|
|
100
106
|
continue;
|
|
101
107
|
}
|
|
102
108
|
requests.push(copy);
|
|
@@ -111,10 +117,9 @@ function requestQuotes(me: string, ids: readonly string[]): { requests: SentCopy
|
|
|
111
117
|
* directory. No distrust wording: with it, models refused every peer request
|
|
112
118
|
* as a possible injection (spec Q37). Safety lives outside the model.
|
|
113
119
|
*/
|
|
114
|
-
function senderLine(address: string): { line: string; name: string } {
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const name = label(record ?? { address });
|
|
120
|
+
function senderLine(address: string, record: RunningRecord | undefined): { line: string; name: string } {
|
|
121
|
+
const where = record === undefined ? "" : `, working in ${plain(record.cwd)}`;
|
|
122
|
+
const name = plain(label(record ?? { address }));
|
|
118
123
|
return { line: `[mailbox] From ${name} (${address}${where}), another Pi session on this machine.`, name };
|
|
119
124
|
}
|
|
120
125
|
|
|
@@ -135,29 +140,30 @@ function inboundText(
|
|
|
135
140
|
path: string,
|
|
136
141
|
quotes: readonly string[],
|
|
137
142
|
opts: { lateAnswer: boolean; byUser: boolean },
|
|
143
|
+
senderRecord: RunningRecord | undefined,
|
|
138
144
|
): string {
|
|
139
145
|
const { lateAnswer, byUser } = opts;
|
|
140
|
-
const sender = senderLine(envelope.from);
|
|
146
|
+
const sender = senderLine(envelope.from, senderRecord);
|
|
147
|
+
const id = plain(envelope.id);
|
|
148
|
+
const replyIds = envelope.in_reply_to.map(plain);
|
|
141
149
|
const header = [sender.line];
|
|
142
150
|
if (envelope.kind === "request") header.push("Your final answer this turn goes back to it automatically.");
|
|
143
151
|
if (envelope.kind === "ask")
|
|
144
152
|
header.push(
|
|
145
|
-
`It is waiting for your answer to its ask ${
|
|
153
|
+
`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.`,
|
|
146
154
|
);
|
|
147
155
|
if (lateAnswer) header.push("It answers an ask of yours that has stopped waiting, so it arrives as a message.");
|
|
148
156
|
if (envelope.kind === "message")
|
|
149
157
|
header.push(`It expects no answer; if one is wanted, send it with session_mail_send to ${envelope.from}.`);
|
|
150
158
|
if (envelope.in_reply_to.length > 0)
|
|
151
|
-
header.push(
|
|
152
|
-
`It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${envelope.in_reply_to.join(", ")}.`,
|
|
153
|
-
);
|
|
159
|
+
header.push(`It is a reply to your request${envelope.in_reply_to.length > 1 ? "s" : ""} ${replyIds.join(", ")}.`);
|
|
154
160
|
if (byUser)
|
|
155
161
|
header.push("The user made that request, typing it with /mailbox or through an extension such as delegate.");
|
|
156
162
|
if (envelope.in_reply_to.length > 0 && envelope.status !== "done")
|
|
157
|
-
header.push(`Its status is "${envelope.status}", not "done": it is not an answer.`);
|
|
163
|
+
header.push(`Its status is "${plain(String(envelope.status))}", not "done": it is not an answer.`);
|
|
158
164
|
const cut = cap(envelope.body, BODY_CAP);
|
|
159
165
|
const body =
|
|
160
|
-
cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${path}`;
|
|
166
|
+
cut === undefined ? envelope.body : `${cut}\n\n[mailbox] Body cut at 32 KiB; the full envelope is ${plain(path)}`;
|
|
161
167
|
return [header.join(" "), ...quotes, body, endLine(sender.name)].join("\n\n");
|
|
162
168
|
}
|
|
163
169
|
|
|
@@ -166,8 +172,9 @@ function inboundText(
|
|
|
166
172
|
* synchronous code before `emit` returns; do all work before any `await`.
|
|
167
173
|
*
|
|
168
174
|
* - `message:send`: the caller emits `{ to, body }`; `pi-session-mail` writes a
|
|
169
|
-
* request
|
|
170
|
-
*
|
|
175
|
+
* request, stamped with the run's hop count during a run and 0 when idle,
|
|
176
|
+
* and sets `envelope` (or `error`) on the same object. Neither set means no
|
|
177
|
+
* provider is installed.
|
|
171
178
|
* - `message:inbound`: emitted for every claimed envelope, requests and
|
|
172
179
|
* replies alike, before injection; `requests` holds a reply's request
|
|
173
180
|
* copies from this session's `sent/`, and a listener sets `handled` to show
|
|
@@ -185,22 +192,15 @@ type Message = { role?: string; content?: unknown; stopReason?: unknown; errorMe
|
|
|
185
192
|
|
|
186
193
|
/** The last assistant message in a run's transcript, if any. */
|
|
187
194
|
function lastAssistant(messages: readonly unknown[]): Message | undefined {
|
|
188
|
-
|
|
189
|
-
const message = messages[i] as Message;
|
|
190
|
-
if (message?.role === "assistant") return message;
|
|
191
|
-
}
|
|
192
|
-
return undefined;
|
|
195
|
+
return (messages as Message[]).findLast((message) => message?.role === "assistant");
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
/** The text of the last assistant message that has any; undefined when none has. */
|
|
196
199
|
function lastAssistantText(messages: readonly unknown[]): string | undefined {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (text !== undefined) return text;
|
|
202
|
-
}
|
|
203
|
-
return undefined;
|
|
200
|
+
const message = (messages as Message[]).findLast(
|
|
201
|
+
(candidate) => candidate?.role === "assistant" && assistantText(candidate) !== undefined,
|
|
202
|
+
);
|
|
203
|
+
return message === undefined ? undefined : assistantText(message);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/** The text of an assistant message; undefined when it has none. */
|
|
@@ -227,12 +227,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
227
227
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
228
228
|
/** Envelope ids already handed to Pi in this process. */
|
|
229
229
|
const delivered = new Set<string>();
|
|
230
|
-
/** The kind of each envelope this session has claimed, by id, so a reply can say why it is refused. */
|
|
231
|
-
const receivedKinds = new Map<string, Envelope["kind"]>();
|
|
232
230
|
/** Request ids awaiting this session's reply, keyed by sender. In memory only. */
|
|
233
231
|
let owed = new Map<string, string[]>();
|
|
234
|
-
/** Ask ids awaiting this session's answer, keyed by sender; answered apart from requests. */
|
|
235
|
-
let owedAsks = new Map<string, string[]>();
|
|
236
232
|
/** Owed request ids whose message entered the conversation (or a listener took over). */
|
|
237
233
|
let seen = new Set<string>();
|
|
238
234
|
/**
|
|
@@ -254,8 +250,8 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
254
250
|
* steered into it. Mail sent from the turn carries it.
|
|
255
251
|
*/
|
|
256
252
|
let hops = 0;
|
|
257
|
-
/**
|
|
258
|
-
let configWarned =
|
|
253
|
+
/** The problems with `session-mail.json` this session has already warned about. */
|
|
254
|
+
let configWarned = new Set<string>();
|
|
259
255
|
/** What this session's running-session record says. */
|
|
260
256
|
let name: string | undefined;
|
|
261
257
|
let cwd = "";
|
|
@@ -285,6 +281,19 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
285
281
|
|
|
286
282
|
const warn = (message: string) => ctx?.ui.notify(`mailbox: ${message}`, "warning");
|
|
287
283
|
|
|
284
|
+
/** The address folder is left as found; tell the user how to tighten it. */
|
|
285
|
+
function warnIfLoose(me: string) {
|
|
286
|
+
const folder = dirname(boxPath(me, "new"));
|
|
287
|
+
let mode: number;
|
|
288
|
+
try {
|
|
289
|
+
mode = statSync(folder).mode & 0o777;
|
|
290
|
+
} catch {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if ((mode & 0o077) === 0) return;
|
|
294
|
+
warn(`${folder} is mode ${mode.toString(8)}, open to other users; run: chmod 700 ${folder}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
288
297
|
/** Footer entry `✉ N pending · N read · N awaiting`, non-zero parts only; hidden when all are zero. */
|
|
289
298
|
function updateStatus() {
|
|
290
299
|
if (!ctx?.hasUI || address === undefined) return;
|
|
@@ -315,7 +324,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
315
324
|
|
|
316
325
|
function deliver(me: string, envelope: Envelope, path: string) {
|
|
317
326
|
delivered.add(envelope.id);
|
|
318
|
-
|
|
327
|
+
tools?.recordKind(envelope);
|
|
319
328
|
// The answer to a waiting ask is that tool call's result, and nothing else.
|
|
320
329
|
if (tools?.takeAnswer(envelope)) return;
|
|
321
330
|
const { requests, quotes } = requestQuotes(me, envelope.in_reply_to);
|
|
@@ -329,7 +338,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
329
338
|
// Only requests arm an answer: replies are never answered, and messages
|
|
330
339
|
// expect none. A request arms one even when a listener took over its display.
|
|
331
340
|
if (envelope.kind === "request") owed.set(envelope.from, [...(owed.get(envelope.from) ?? []), envelope.id]);
|
|
332
|
-
|
|
341
|
+
tools?.armAsk(envelope);
|
|
333
342
|
// Mail that wakes or steers the session raises its count; a reply shown
|
|
334
343
|
// quietly starts no turn, but one a listener took over may.
|
|
335
344
|
if (wakes || inbound.handled) hops = Math.max(hops, envelope.hops + 1);
|
|
@@ -342,7 +351,13 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
342
351
|
pi.sendMessage(
|
|
343
352
|
{
|
|
344
353
|
customType: CUSTOM_TYPE,
|
|
345
|
-
content: inboundText(
|
|
354
|
+
content: inboundText(
|
|
355
|
+
envelope,
|
|
356
|
+
path,
|
|
357
|
+
quotes,
|
|
358
|
+
{ lateAnswer, byUser },
|
|
359
|
+
listRunning().find((r) => r.address === envelope.from),
|
|
360
|
+
),
|
|
346
361
|
display: true,
|
|
347
362
|
details: { id: envelope.id },
|
|
348
363
|
},
|
|
@@ -382,8 +397,11 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
382
397
|
try {
|
|
383
398
|
if (address === undefined) throw new Error("no active session has a mailbox address");
|
|
384
399
|
if (typeof payload.body !== "string") throw new Error("body must be a string");
|
|
385
|
-
//
|
|
386
|
-
|
|
400
|
+
// During a run the caller is the model acting through an extension, so the
|
|
401
|
+
// request continues the run's chain; idle, it acts for the user and starts a
|
|
402
|
+
// fresh one. It is never refused here: the receiving side enforces the limit.
|
|
403
|
+
const hopsNow = state === "busy" ? hops : 0;
|
|
404
|
+
payload.envelope = send(address, payload.to as string, payload.body, { kind: "request", hops: hopsNow });
|
|
387
405
|
updateStatus();
|
|
388
406
|
} catch (err) {
|
|
389
407
|
payload.error = (err as Error).message;
|
|
@@ -399,11 +417,27 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
399
417
|
payload.scanned = true;
|
|
400
418
|
});
|
|
401
419
|
|
|
420
|
+
/** Warn about a problem with `session-mail.json`, once per session. */
|
|
421
|
+
function configProblem(message: string) {
|
|
422
|
+
if (configWarned.has(message)) return;
|
|
423
|
+
configWarned.add(message);
|
|
424
|
+
warn(message);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Remove closed sessions' folders that are older than `pruneAfterDays`; a failure warns and never throws. */
|
|
428
|
+
function pruneOld(me: string) {
|
|
429
|
+
try {
|
|
430
|
+
const { pruneAfterDays } = readSettings(configProblem);
|
|
431
|
+
pruneClosed({ own: me, isRunning, cutoffMs: Date.now() - pruneAfterDays * DAY_MS });
|
|
432
|
+
} catch (err) {
|
|
433
|
+
warn(`could not prune old mailbox folders: ${(err as Error).message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
402
437
|
pi.on("session_start", async (_event, context) => {
|
|
403
438
|
stop();
|
|
404
439
|
ctx = context;
|
|
405
440
|
owed = new Map();
|
|
406
|
-
owedAsks = new Map();
|
|
407
441
|
seen = new Set();
|
|
408
442
|
held = new Set();
|
|
409
443
|
lastAnswer = undefined;
|
|
@@ -413,15 +447,25 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
413
447
|
hops = 0;
|
|
414
448
|
tools?.abandonAsk();
|
|
415
449
|
waitingOn = "";
|
|
416
|
-
configWarned =
|
|
450
|
+
configWarned = new Set();
|
|
417
451
|
const id = context.sessionManager.getSessionId();
|
|
418
452
|
if (!isAddress(id)) {
|
|
419
453
|
address = undefined;
|
|
454
|
+
tools?.setAddress(undefined);
|
|
420
455
|
warn(`session id ${JSON.stringify(id)} is not a usable address; the mailbox is off`);
|
|
421
456
|
return;
|
|
422
457
|
}
|
|
458
|
+
try {
|
|
459
|
+
ensureBoxes(id);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
address = undefined;
|
|
462
|
+
tools?.setAddress(undefined);
|
|
463
|
+
warn(`mailbox is off: ${(err as Error).message}`);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
423
466
|
address = id;
|
|
424
|
-
|
|
467
|
+
tools?.setAddress(id);
|
|
468
|
+
warnIfLoose(id);
|
|
425
469
|
name = context.sessionManager.getSessionName?.() || undefined;
|
|
426
470
|
cwd = context.cwd;
|
|
427
471
|
state = context.isIdle?.() === false ? "busy" : "idle";
|
|
@@ -435,6 +479,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
435
479
|
pendingScan = setImmediate(() => {
|
|
436
480
|
pendingScan = undefined;
|
|
437
481
|
scan();
|
|
482
|
+
pruneOld(id);
|
|
438
483
|
});
|
|
439
484
|
pendingScan.unref?.();
|
|
440
485
|
try {
|
|
@@ -499,52 +544,25 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
499
544
|
// queued follow-up after `agent_end` would otherwise get a premature answer.
|
|
500
545
|
pi.on("agent_settled", async () => {
|
|
501
546
|
const me = address;
|
|
502
|
-
// A stopped or errored run notes how it ended, then gives what it had.
|
|
503
|
-
const opening = lastAborted ? STOPPED : lastError !== undefined ? ERRORED(lastError) : undefined;
|
|
504
|
-
const body =
|
|
505
|
-
opening === undefined
|
|
506
|
-
? (lastAnswer ?? NO_ANSWER)
|
|
507
|
-
: lastAnswer === undefined
|
|
508
|
-
? opening
|
|
509
|
-
: `${opening}\n\n${lastAnswer}`;
|
|
510
|
-
const status = lastAborted ? "stopped" : lastError !== undefined ? "failed" : "done";
|
|
511
|
-
// A request held across a stop is answered by the run that completes it, saying the user took over.
|
|
512
|
-
const tookOverBody = `${TOOK_OVER}\n\n${lastAnswer ?? NO_ANSWER}`;
|
|
513
547
|
// Answers carry the settled turn's count; the next turn counts afresh.
|
|
514
548
|
const answerHops = hops;
|
|
515
549
|
hops = 0;
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
550
|
+
const end: SettleInput["end"] = lastAborted
|
|
551
|
+
? { kind: "stopped" }
|
|
552
|
+
: lastError !== undefined
|
|
553
|
+
? { kind: "failed", error: lastError }
|
|
554
|
+
: { kind: "done" };
|
|
555
|
+
const decision = settle({
|
|
556
|
+
requests: owed,
|
|
557
|
+
asks: tools?.takeOwedAsks() ?? new Map(),
|
|
558
|
+
seen,
|
|
559
|
+
held,
|
|
560
|
+
end,
|
|
561
|
+
answer: lastAnswer,
|
|
562
|
+
});
|
|
563
|
+
owed = decision.owed;
|
|
564
|
+
held = decision.held;
|
|
525
565
|
seen = new Set();
|
|
526
|
-
held = new Set();
|
|
527
|
-
const replies: { to: string; ids: string[]; body: string; status: Envelope["status"] }[] = [];
|
|
528
|
-
for (const [to, ids] of requests) {
|
|
529
|
-
const unseen = ids.filter((id) => !read.has(id) && !wasHeld.has(id));
|
|
530
|
-
const keep = ids.filter(
|
|
531
|
-
(id) => !unseen.includes(id) && (lastAborted || (lastError !== undefined && wasHeld.has(id))),
|
|
532
|
-
);
|
|
533
|
-
const answer = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
|
|
534
|
-
if (keep.length > 0) owed.set(to, keep);
|
|
535
|
-
for (const id of keep) held.add(id);
|
|
536
|
-
const plain = answer.filter((id) => !wasHeld.has(id));
|
|
537
|
-
const tookOver = answer.filter((id) => wasHeld.has(id));
|
|
538
|
-
if (plain.length > 0) replies.push({ to, ids: plain, body, status });
|
|
539
|
-
if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
|
|
540
|
-
if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
|
|
541
|
-
}
|
|
542
|
-
for (const [to, ids] of asks) {
|
|
543
|
-
const done = ids.filter((id) => read.has(id));
|
|
544
|
-
const failed = ids.filter((id) => !read.has(id));
|
|
545
|
-
if (done.length > 0) replies.push({ to, ids: done, body, status });
|
|
546
|
-
if (failed.length > 0) replies.push({ to, ids: failed, body: UNSEEN, status: "failed" });
|
|
547
|
-
}
|
|
548
566
|
lastAnswer = undefined;
|
|
549
567
|
lastAborted = false;
|
|
550
568
|
lastError = undefined;
|
|
@@ -552,7 +570,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
552
570
|
state = "idle";
|
|
553
571
|
announce();
|
|
554
572
|
if (me === undefined) return;
|
|
555
|
-
for (const reply of replies) {
|
|
573
|
+
for (const reply of decision.replies) {
|
|
556
574
|
const { to } = reply;
|
|
557
575
|
try {
|
|
558
576
|
// Answers are never refused by the hop limit.
|
|
@@ -575,6 +593,7 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
575
593
|
}
|
|
576
594
|
}
|
|
577
595
|
address = undefined;
|
|
596
|
+
tools?.setAddress(undefined);
|
|
578
597
|
});
|
|
579
598
|
|
|
580
599
|
tools = registerTools(pi, {
|
|
@@ -583,36 +602,13 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
583
602
|
waitingOn = to;
|
|
584
603
|
announce();
|
|
585
604
|
},
|
|
586
|
-
reply: (id, body) => {
|
|
587
|
-
const me = address;
|
|
588
|
-
if (me === undefined) throw new Error("this session has no mailbox address; nothing was sent");
|
|
589
|
-
const kind = receivedKinds.get(id);
|
|
590
|
-
if (kind === undefined)
|
|
591
|
-
throw new Error(`no mail with id ${JSON.stringify(id)} reached this session; nothing was sent`);
|
|
592
|
-
if (kind !== "ask")
|
|
593
|
-
throw new Error(
|
|
594
|
-
`${id} is a ${kind}, not an ask; nothing was sent. ${kind === "request" ? "A request is answered automatically when this run settles." : kind === "message" ? "A message expects no answer; session_mail_send sends one if it is wanted." : "A reply is never answered."}`,
|
|
595
|
-
);
|
|
596
|
-
const from = [...owedAsks].find(([, ids]) => ids.includes(id))?.[0];
|
|
597
|
-
// An answered ask, by this tool or at settle, is no longer owed.
|
|
598
|
-
if (from === undefined) throw new Error(`ask ${id} is already answered; nothing was sent`);
|
|
599
|
-
// Answers carry the turn's count and are never refused by the hop limit.
|
|
600
|
-
const answer = send(me, from, body, { kind: "reply", hops, inReplyTo: [id], status: "done" });
|
|
601
|
-
const rest = (owedAsks.get(from) ?? []).filter((owedId) => owedId !== id);
|
|
602
|
-
if (rest.length > 0) owedAsks.set(from, rest);
|
|
603
|
-
else owedAsks.delete(from);
|
|
604
|
-
return answer;
|
|
605
|
-
},
|
|
606
605
|
hops: () => hops,
|
|
607
|
-
configProblem
|
|
608
|
-
if (configWarned) return;
|
|
609
|
-
configWarned = true;
|
|
610
|
-
warn(message);
|
|
611
|
-
},
|
|
606
|
+
configProblem,
|
|
612
607
|
});
|
|
613
608
|
|
|
614
609
|
pi.registerCommand("mailbox", {
|
|
615
|
-
description:
|
|
610
|
+
description:
|
|
611
|
+
"Show this session's mailbox address and name, send a request: /mailbox <name or id> <text>, or remove closed sessions' empty mailbox folders: /mailbox prune",
|
|
616
612
|
handler: async (args, context) => {
|
|
617
613
|
const me = context.sessionManager.getSessionId();
|
|
618
614
|
const trimmed = args.trim();
|
|
@@ -624,6 +620,15 @@ export default function mailbox(pi: ExtensionAPI) {
|
|
|
624
620
|
);
|
|
625
621
|
return;
|
|
626
622
|
}
|
|
623
|
+
if (trimmed === "prune") {
|
|
624
|
+
try {
|
|
625
|
+
const removed = pruneClosed({ own: me, isRunning });
|
|
626
|
+
context.ui.notify(`Removed ${removed} mailbox folders of closed sessions`, "info");
|
|
627
|
+
} catch (err) {
|
|
628
|
+
context.ui.notify(`mailbox: ${(err as Error).message}`, "error");
|
|
629
|
+
}
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
627
632
|
const match = /^(\S+)\s+([\s\S]+)$/.exec(trimmed);
|
|
628
633
|
if (!match) {
|
|
629
634
|
context.ui.notify("Usage: /mailbox <name or id> <text>", "error");
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Running-session records and `to` resolution. Internal to `pi-session-mail`: tests
|
|
2
|
-
// reach it
|
|
2
|
+
// reach it through the extension's registration function, apart from the start-token
|
|
3
|
+
// probe and its parsers, which `test/start-token.test.ts` tests directly.
|
|
3
4
|
//
|
|
4
5
|
// Each running session announces itself in `<root>/running/<address>.json`:
|
|
5
6
|
// its address, Pi session name, working directory, process id, idle or busy,
|
|
6
7
|
// the address it waits on, and when the record last changed. A record whose
|
|
7
|
-
// process is gone
|
|
8
|
-
//
|
|
8
|
+
// process is gone, or whose pid now belongs to a process that started at
|
|
9
|
+
// another time, counts as not running, and the reader that finds it deletes it.
|
|
10
|
+
// The root is the mail root, so every agent directory sees every session.
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
9
12
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
13
|
import { join } from "node:path";
|
|
11
14
|
|
|
@@ -19,6 +22,11 @@ export type RunningRecord = {
|
|
|
19
22
|
name?: string;
|
|
20
23
|
cwd: string;
|
|
21
24
|
pid: number;
|
|
25
|
+
/**
|
|
26
|
+
* An opaque token for the process's start time, so a reused pid is told from the
|
|
27
|
+
* original process. Absent in records written by 0.1.0 and where no probe works.
|
|
28
|
+
*/
|
|
29
|
+
started?: string;
|
|
22
30
|
state: State;
|
|
23
31
|
/** The address this session's pending ask waits on; empty when none. */
|
|
24
32
|
waitingOn: string;
|
|
@@ -27,12 +35,14 @@ export type RunningRecord = {
|
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
/** How many leading characters of a session id name it in lists and errors. */
|
|
30
|
-
|
|
38
|
+
const SHORT_ID = 8;
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
const shortId = (address: string) => address.slice(0, SHORT_ID);
|
|
33
41
|
|
|
34
42
|
/** A record's display label: its session name, or its short id when it has none. */
|
|
35
|
-
export
|
|
43
|
+
export function label(record: Pick<RunningRecord, "address" | "name">): string {
|
|
44
|
+
return record.name ?? shortId(record.address);
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
const runningDir = () => join(mailRoot(), "running");
|
|
38
48
|
|
|
@@ -41,8 +51,78 @@ const recordPath = (address: string) => {
|
|
|
41
51
|
return join(runningDir(), `${address}.json`);
|
|
42
52
|
};
|
|
43
53
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The start-time token in a `/proc/<pid>/stat` line: field 22. The command name
|
|
56
|
+
* (field 2) is in parentheses and may itself hold spaces and parentheses, so
|
|
57
|
+
* the fields are counted from the last `)`.
|
|
58
|
+
*/
|
|
59
|
+
export function parseProcStat(line: string): string | undefined {
|
|
60
|
+
const rest = line.slice(line.lastIndexOf(")") + 1).trim();
|
|
61
|
+
if (rest === "" || !line.includes(")")) return undefined;
|
|
62
|
+
const token = rest.split(/\s+/)[19]; // field 3 is the first after the name
|
|
63
|
+
return token !== undefined && /^\d+$/.test(token) ? token : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse `ps -o pid=,lstart=` output into a pid -> start token map; unparseable lines are skipped. */
|
|
67
|
+
export function parsePsStarts(output: string): Map<number, string> {
|
|
68
|
+
const out = new Map<number, string>();
|
|
69
|
+
for (const line of output.split("\n")) {
|
|
70
|
+
const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
71
|
+
if (match !== null) out.set(Number(match[1]), match[2]);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The start tokens of `pids`, for those the probe could read. Linux reads
|
|
78
|
+
* `/proc`; macOS runs one `ps` for all of them, in UTC and the C locale so
|
|
79
|
+
* every session renders a start time the same way; elsewhere, or on any error,
|
|
80
|
+
* the result lacks the pid, which callers treat as unknown.
|
|
81
|
+
*/
|
|
82
|
+
export function startTokens(pids: readonly number[]): Map<number, string> {
|
|
83
|
+
const out = new Map<number, string>();
|
|
84
|
+
if (pids.length === 0) return out;
|
|
85
|
+
if (process.platform === "linux") {
|
|
86
|
+
for (const pid of pids) {
|
|
87
|
+
try {
|
|
88
|
+
const token = parseProcStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
89
|
+
if (token !== undefined) out.set(pid, token);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
} else if (process.platform === "darwin") {
|
|
93
|
+
let stdout = "";
|
|
94
|
+
try {
|
|
95
|
+
stdout = execFileSync("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
timeout: 2000,
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// ps exits 1 when some pid is gone but still prints the others.
|
|
103
|
+
const partial = (err as { stdout?: unknown }).stdout;
|
|
104
|
+
if (typeof partial === "string") stdout = partial;
|
|
105
|
+
}
|
|
106
|
+
for (const [pid, token] of parsePsStarts(stdout)) out.set(pid, token);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let ownToken: { value: string | undefined } | undefined;
|
|
112
|
+
|
|
113
|
+
/** This process's start token, probed once; undefined when it cannot be read. */
|
|
114
|
+
function ownStartToken(): string | undefined {
|
|
115
|
+
ownToken ??= { value: startTokens([process.pid]).get(process.pid) };
|
|
116
|
+
return ownToken.value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write `record` owner-only, through a temporary file so a reader never sees half of it.
|
|
121
|
+
* A record for this process gets this process's start token.
|
|
122
|
+
*/
|
|
123
|
+
export function writeRecord(given: RunningRecord): void {
|
|
124
|
+
const started = given.pid === process.pid && given.started === undefined ? ownStartToken() : undefined;
|
|
125
|
+
const record = started === undefined ? given : { ...given, started };
|
|
46
126
|
mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
|
|
47
127
|
const path = recordPath(record.address);
|
|
48
128
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
@@ -79,6 +159,7 @@ function readRecord(path: string): RunningRecord | undefined {
|
|
|
79
159
|
...(typeof value.name === "string" && value.name !== "" ? { name: value.name } : {}),
|
|
80
160
|
cwd: value.cwd,
|
|
81
161
|
pid: value.pid,
|
|
162
|
+
...(typeof value.started === "string" && value.started !== "" ? { started: value.started } : {}),
|
|
82
163
|
state: value.state === "busy" ? "busy" : "idle",
|
|
83
164
|
waitingOn: typeof value.waitingOn === "string" ? value.waitingOn : "",
|
|
84
165
|
updated: typeof value.updated === "string" ? value.updated : "",
|
|
@@ -88,6 +169,24 @@ function readRecord(path: string): RunningRecord | undefined {
|
|
|
88
169
|
}
|
|
89
170
|
}
|
|
90
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether `address` has a live running record, read fresh from its one file. The
|
|
174
|
+
* rules are `listRunning`'s: the pid is alive and, when both tokens are known,
|
|
175
|
+
* the start token matches. A missing or unreadable record is not running; a
|
|
176
|
+
* failed probe falls back to the pid alone. Never throws and deletes nothing.
|
|
177
|
+
*/
|
|
178
|
+
export function isRunning(address: string): boolean {
|
|
179
|
+
try {
|
|
180
|
+
const record = readRecord(recordPath(address));
|
|
181
|
+
if (record === undefined || record.address !== address || !alive(record.pid)) return false;
|
|
182
|
+
if (record.started === undefined) return true;
|
|
183
|
+
const live = startTokens([record.pid]).get(record.pid);
|
|
184
|
+
return live === undefined || live === record.started;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
91
190
|
/** Every running session's record, sorted by label; records of dead processes are deleted. */
|
|
92
191
|
export function listRunning(): RunningRecord[] {
|
|
93
192
|
let names: string[];
|
|
@@ -96,12 +195,21 @@ export function listRunning(): RunningRecord[] {
|
|
|
96
195
|
} catch {
|
|
97
196
|
return [];
|
|
98
197
|
}
|
|
99
|
-
const
|
|
198
|
+
const found: { path: string; record: RunningRecord }[] = [];
|
|
100
199
|
for (const name of names) {
|
|
101
200
|
const path = join(runningDir(), name);
|
|
102
201
|
const record = readRecord(path);
|
|
103
202
|
if (record === undefined || `${record.address}.json` !== name) continue; // not ours to judge or delete
|
|
104
|
-
|
|
203
|
+
found.push({ path, record });
|
|
204
|
+
}
|
|
205
|
+
const tokens = startTokens(
|
|
206
|
+
found.filter(({ record }) => record.started !== undefined && alive(record.pid)).map(({ record }) => record.pid),
|
|
207
|
+
);
|
|
208
|
+
const out: RunningRecord[] = [];
|
|
209
|
+
for (const { path, record } of found) {
|
|
210
|
+
const live = tokens.get(record.pid);
|
|
211
|
+
// A token we cannot read is unknown, which falls back to the pid-only check.
|
|
212
|
+
if (!alive(record.pid) || (record.started !== undefined && live !== undefined && live !== record.started)) {
|
|
105
213
|
rmSync(path, { force: true });
|
|
106
214
|
continue;
|
|
107
215
|
}
|