@alphacoder-v0/pi-loops 0.22.8 → 0.24.1
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 +717 -0
- package/CONTRACT.md +7 -7
- package/README.md +114 -78
- package/README.zh-CN.md +62 -41
- package/docs/cli.md +352 -91
- package/docs/configuration.md +86 -24
- package/docs/design.md +404 -61
- package/docs/downstream.md +2 -2
- package/docs/goal.md +12 -3
- package/docs/hooks.md +6 -7
- package/docs/loops.md +288 -59
- package/docs/mcp.md +22 -14
- package/docs/recipes.md +6 -0
- package/docs/screenshot.png +0 -0
- package/docs/triggers.md +120 -26
- package/docs/troubleshooting.md +41 -28
- package/docs/web-ui-parity.md +702 -65
- package/package.json +4 -3
- package/recipes/autoresearch/recipe.toml +1 -0
- package/recipes/changelog-draft/recipe.toml +1 -0
- package/recipes/ci-sweeper/recipe.toml +1 -0
- package/recipes/daily-digest/recipe.toml +1 -0
- package/recipes/deps-sweeper/recipe.toml +1 -0
- package/recipes/ecosystem/recipe.toml +1 -0
- package/recipes/issue-loop/recipe.toml +1 -0
- package/recipes/pr-watch/recipe.toml +1 -0
- package/skills/pi-loops/SKILL.md +6 -4
- package/src/activity.ts +199 -0
- package/src/archive.ts +21 -9
- package/src/args.ts +6 -2
- package/src/browser-open.ts +169 -0
- package/src/cli-entry.mjs +12 -3
- package/src/cli.ts +960 -143
- package/src/config.ts +53 -5
- package/src/conversation-rows.ts +469 -0
- package/src/danger.ts +227 -6
- package/src/engine.ts +1240 -0
- package/src/goal.ts +71 -0
- package/src/held-read.ts +27 -0
- package/src/hooks.ts +49 -18
- package/src/host-control-channel.ts +455 -40
- package/src/host-control.ts +602 -111
- package/src/host-entry.mjs +5 -3
- package/src/host-runtime.ts +455 -42
- package/src/host.ts +659 -123
- package/src/inbox-card.ts +278 -0
- package/src/inbox.ts +139 -62
- package/src/job-edit.ts +7 -1
- package/src/job-health.ts +14 -2
- package/src/job-lines.ts +201 -13
- package/src/job-owner.ts +74 -13
- package/src/job-tune.ts +110 -0
- package/src/lease.ts +537 -0
- package/src/lock.ts +7 -2
- package/src/mcp-pool.ts +66 -14
- package/src/mcp.ts +1 -1
- package/src/open-file.ts +54 -0
- package/src/page-children.ts +1507 -0
- package/src/page-devices.ts +192 -0
- package/src/page-server.ts +3150 -0
- package/src/path-precheck.ts +215 -0
- package/src/paths.ts +24 -0
- package/src/pi-floor.ts +10 -0
- package/src/pi-loops.ts +1370 -473
- package/src/pi-resolver.d.mts +1 -0
- package/src/pi-resolver.mjs +110 -10
- package/src/presence.ts +12 -63
- package/src/protocol.ts +10 -1
- package/src/recipe.ts +43 -1
- package/src/requests.ts +354 -0
- package/src/rule-lines.ts +68 -0
- package/src/run-card.ts +105 -0
- package/src/runner.ts +18 -1
- package/src/schedule.ts +1 -2
- package/src/scheduler.ts +595 -245
- package/src/sdk-runner.ts +321 -27
- package/src/seen.ts +278 -0
- package/src/session-head.ts +21 -0
- package/src/share.ts +12 -2
- package/src/snapshot.ts +3 -2
- package/src/store.ts +81 -8
- package/src/tools.ts +196 -65
- package/src/trigger-runtime.ts +164 -127
- package/src/triggers.ts +39 -7
- package/src/trust.ts +46 -2
- package/src/ui-prefs.ts +22 -7
- package/src/watch-plan.ts +174 -0
- package/src/web.d.mts +3 -0
- package/src/web.mjs +2261 -2340
- package/src/window-executor.ts +459 -0
- package/src/window-guard.ts +158 -0
- package/docs/agents/issue-tracker.md +0 -34
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `pi --mode rpc` child behind the browser page: the process, what it last said, and the feed
|
|
3
|
+
* of its events to the attached browsers.
|
|
4
|
+
*
|
|
5
|
+
* pi in rpc mode is pi with no terminal UI: commands in on stdin as JSON lines, events out on stdout
|
|
6
|
+
* the same way. The browser speaks that protocol through src/page-server.ts, which asks a
|
|
7
|
+
* `PageChild` for everything that belongs to the conversation running in it.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
10
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import type * as http from "node:http";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import type { Readable, Writable } from "node:stream";
|
|
15
|
+
import { PERSON_SEND_GRACE_MS } from "./engine.ts";
|
|
16
|
+
import { entriesReadable as fileEntriesReadable } from "./held-read.ts";
|
|
17
|
+
import { DELIVERY_JOB_ID, holderAlive, type ProcessProbe, procStart, type SessionLease, sessionFileName, systemProbe } from "./lease.ts";
|
|
18
|
+
import { withFileLockSync, writeFileAtomic } from "./lock.ts";
|
|
19
|
+
import { realpathish } from "./paths.ts";
|
|
20
|
+
import { stamp } from "./schedule.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run one of the page's callbacks so that nothing it throws, or rejects with, leaves it.
|
|
24
|
+
*
|
|
25
|
+
* The page runs inside the process that runs every loop. An exception that escapes an HTTP handler,
|
|
26
|
+
* a child's stdout listener or a timer ends that process, and three of those in five minutes pause
|
|
27
|
+
* the loops for half an hour (src/host-control.ts CRASH_BUDGET). A page bug is logged here instead.
|
|
28
|
+
*/
|
|
29
|
+
export function guard<A extends unknown[]>(log: (line: string) => void, name: string, fn: (...args: A) => unknown): (...args: A) => void {
|
|
30
|
+
const report = (err: any) => log(`pi-loops page: ${name}: ${err?.stack ?? err?.message ?? err}`);
|
|
31
|
+
return (...args: A) => {
|
|
32
|
+
try {
|
|
33
|
+
const out = fn(...args);
|
|
34
|
+
if (out instanceof Promise) out.catch(report);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
report(err);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* ------------------------------------------------------------------ pi, in rpc mode */
|
|
42
|
+
|
|
43
|
+
export interface PageChildOptions {
|
|
44
|
+
cwd: string;
|
|
45
|
+
/** The pi arguments it was started with, after `--mode rpc` and `--session`. */
|
|
46
|
+
piArgs: string[];
|
|
47
|
+
version: string;
|
|
48
|
+
log: (line: string) => void;
|
|
49
|
+
onExit: (code: number | null, signal: NodeJS.Signals | null) => void;
|
|
50
|
+
/** Every event pi prints that is not the answer to a command, before it is folded and broadcast. */
|
|
51
|
+
onNote?: (msg: any) => void;
|
|
52
|
+
ring: () => Promise<unknown>;
|
|
53
|
+
/** The longest line read from pi, `PI_LINE_MAX` unless a test sets a smaller one. */
|
|
54
|
+
lineMax?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The longest line the page reads from pi's stdout, in UTF-16 code units: 64 MiB of ASCII. A longer
|
|
59
|
+
* one (an answer with every entry of a very large conversation) is dropped as it arrives rather than
|
|
60
|
+
* held and parsed in the process every page and loop shares.
|
|
61
|
+
*/
|
|
62
|
+
export const PI_LINE_MAX = 64 * 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** A line over the cap: its start, where pi's answer says which command it answers. */
|
|
65
|
+
export interface OverlongLine {
|
|
66
|
+
overlong: true;
|
|
67
|
+
head: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cut pi's stdout into lines. Strict JSONL: split on \n only, since Node's readline also splits on
|
|
72
|
+
* U+2028/U+2029, which are legal inside JSON strings. Only each new chunk is scanned for a newline,
|
|
73
|
+
* so a long line costs its length once, not once per chunk. A line over `max` (the \r of a CRLF line
|
|
74
|
+
* not counted) is reported once, as soon as it passes the cap, and the rest of it is dropped up to
|
|
75
|
+
* its newline.
|
|
76
|
+
*/
|
|
77
|
+
export function jsonlLines(max = PI_LINE_MAX): (chunk: string) => (string | OverlongLine)[] {
|
|
78
|
+
let buf = "";
|
|
79
|
+
let dropping = false;
|
|
80
|
+
return (chunk) => {
|
|
81
|
+
const out: (string | OverlongLine)[] = [];
|
|
82
|
+
let from = 0;
|
|
83
|
+
for (let nl = chunk.indexOf("\n"); nl !== -1; from = nl + 1, nl = chunk.indexOf("\n", from)) {
|
|
84
|
+
const line = buf + chunk.slice(from, nl);
|
|
85
|
+
buf = "";
|
|
86
|
+
if (dropping) dropping = false;
|
|
87
|
+
else if (line.length - (line.endsWith("\r") ? 1 : 0) > max) out.push({ overlong: true, head: line.slice(0, 512) });
|
|
88
|
+
else out.push(line.replace(/\r$/, ""));
|
|
89
|
+
}
|
|
90
|
+
if (dropping) return out;
|
|
91
|
+
buf += chunk.slice(from);
|
|
92
|
+
// A \r waiting for its newline is not counted either.
|
|
93
|
+
if (buf.length - (buf.endsWith("\r") ? 1 : 0) > max) {
|
|
94
|
+
out.push({ overlong: true, head: buf.slice(0, 512) });
|
|
95
|
+
buf = "";
|
|
96
|
+
dropping = true;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The id of the command an overlong line answers, read from its start: pi writes `id` and `type` first. */
|
|
103
|
+
const ANSWER_HEAD = /^\{\s*"(?:id"\s*:\s*"([^"]{1,128})"\s*,\s*"type"\s*:\s*"response"|type"\s*:\s*"response"\s*,\s*"id"\s*:\s*"([^"]{1,128})")/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* pi's stderr, forwarded to ours and kept — bounded — so the page can show the last of it when pi
|
|
107
|
+
* dies. Bounded because a chatty provider error can be very long and nobody reads past the end.
|
|
108
|
+
*/
|
|
109
|
+
const STDERR_KEEP = 8000;
|
|
110
|
+
/** What actually leaves this process: the tail, which is the part that says why pi stopped. */
|
|
111
|
+
const STDERR_SEND = 4000;
|
|
112
|
+
/**
|
|
113
|
+
* The shapes a credential takes in a line of output.
|
|
114
|
+
*
|
|
115
|
+
* `src/redact.ts` is this project's list; this is the subset that matters for a stderr tail — a
|
|
116
|
+
* provider that refused to authenticate prints the key it was given, and an MCP server started by
|
|
117
|
+
* pi prints the Authorization header it was handed — kept here so what the tests pin about the cut
|
|
118
|
+
* and the mask stays in one file. Anything not matched here is bounded rather than masked, which is
|
|
119
|
+
* why the tail is short.
|
|
120
|
+
*/
|
|
121
|
+
const SECRET_SHAPES = [
|
|
122
|
+
/sk-[A-Za-z0-9_-]{20,}/g,
|
|
123
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
124
|
+
/\bgh[ousp]_[A-Za-z0-9]{30,}\b/g,
|
|
125
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
|
|
126
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
|
|
127
|
+
/\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
128
|
+
/Bearer\s+[A-Za-z0-9._-]{16,}/g,
|
|
129
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The last of pi's stderr, capped and redacted, because this is broadcast to every attached browser.
|
|
134
|
+
*
|
|
135
|
+
* It used to go out as it arrived. A provider that refuses to authenticate is one of the commonest
|
|
136
|
+
* reasons pi exits at all, and what it prints is the key it was refused with — so the one event
|
|
137
|
+
* whose whole purpose is to explain a failure was the one event that could carry a credential.
|
|
138
|
+
*/
|
|
139
|
+
/**
|
|
140
|
+
* Redact every credential shape a line of output could carry.
|
|
141
|
+
*
|
|
142
|
+
* A job's prompt and last error, and a rule's condition and action, now leave this process in
|
|
143
|
+
* `/state` in full rather than cut short — and full is what makes a prompt with an API key in it,
|
|
144
|
+
* or an error a provider printed, reach every attached browser unless it is masked first. The same
|
|
145
|
+
* patterns that guard the stderr tail have to guard these, which is why this is one function and
|
|
146
|
+
* not two copies of the list.
|
|
147
|
+
*/
|
|
148
|
+
export function maskSecrets(text: any) {
|
|
149
|
+
// A field the store does not have stays absent: /state said undefined before and still does.
|
|
150
|
+
if (text === undefined || text === null) return text;
|
|
151
|
+
let out = String(text);
|
|
152
|
+
for (const re of SECRET_SHAPES) {
|
|
153
|
+
re.lastIndex = 0;
|
|
154
|
+
out = out.replace(re, "[REDACTED]");
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/* ------------------------------------------------------------------ what the server keeps */
|
|
160
|
+
|
|
161
|
+
/** A request pi waits on for an answer, as opposed to a notice or a status line. */
|
|
162
|
+
export const isDialog = (ev: any) => ev?.type === "extension_ui_request" && (ev.method === "confirm" || ev.method === "select" || ev.method === "input" || ev.method === "editor");
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* How many unanswered dialogs are kept, for the same reason the backlog is bounded.
|
|
166
|
+
*
|
|
167
|
+
* A dialog nobody answers is offered again to every browser that attaches, every eight seconds, for
|
|
168
|
+
* as long as this process lives — so an abandoned one is not merely memory, it is a question that
|
|
169
|
+
* will not stop being asked. Past this the oldest goes, which is the one least likely to still have
|
|
170
|
+
* an extension waiting behind it.
|
|
171
|
+
*/
|
|
172
|
+
const MAX_PENDING_ASKS = 50;
|
|
173
|
+
|
|
174
|
+
/* ------------------------------------------------------------------ the snapshot */
|
|
175
|
+
|
|
176
|
+
export const NO_PI_LOOPS = "pi-loops is not loaded in this session";
|
|
177
|
+
|
|
178
|
+
/** The mark a page child keeps once the variable is gone: pi runs the extension factory again, in the same process, on every session replacement. */
|
|
179
|
+
const PAGE_CHILD_MARK = Symbol.for("pi-loops.page-child");
|
|
180
|
+
/**
|
|
181
|
+
* Whether this process is a page child (started by `ChildPool` with `PI_LOOPS_PAGE_CHILD=1`). The
|
|
182
|
+
* variable is read once and taken out of the environment, so a tool process this pi runs (a `bash`
|
|
183
|
+
* call, a nested `pi -p`) does not inherit it and take itself for a page child; a mark on the
|
|
184
|
+
* process keeps the answer for the factory's next run (a new session, a resume, `/reload`), which
|
|
185
|
+
* would otherwise lose the desk and the lease's `page` mark.
|
|
186
|
+
*/
|
|
187
|
+
export function claimPageChild(env: NodeJS.ProcessEnv = process.env, proc: object = process): boolean {
|
|
188
|
+
const marks = proc as { [PAGE_CHILD_MARK]?: true };
|
|
189
|
+
const pageChild = env.PI_LOOPS_PAGE_CHILD === "1" || marks[PAGE_CHILD_MARK] === true;
|
|
190
|
+
if (pageChild) {
|
|
191
|
+
marks[PAGE_CHILD_MARK] = true;
|
|
192
|
+
delete env.PI_LOOPS_PAGE_CHILD;
|
|
193
|
+
}
|
|
194
|
+
return pageChild;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* How old the command list may be when a chat message is routed on it. `/reload` in the page changes
|
|
198
|
+
* what is loaded, so the list is asked for again after every slash command as well; the age is the
|
|
199
|
+
* floor under that, for a change this process did not see happen.
|
|
200
|
+
*/
|
|
201
|
+
export const COMMANDS_TTL_MS = 30_000;
|
|
202
|
+
|
|
203
|
+
const SESSION_HEAD = 64 * 1024;
|
|
204
|
+
|
|
205
|
+
/** The header, a name if one was set early, and the first thing a person said. */
|
|
206
|
+
export function readHead(file: string) {
|
|
207
|
+
let text;
|
|
208
|
+
let readBytes = 0;
|
|
209
|
+
try {
|
|
210
|
+
const fd = fs.openSync(file, "r");
|
|
211
|
+
try {
|
|
212
|
+
const buf = Buffer.alloc(SESSION_HEAD);
|
|
213
|
+
// `SESSION_HEAD` is a byte window, so the decision to cut is the byte count read, not the
|
|
214
|
+
// decoded string's length: a multi-byte character makes those differ.
|
|
215
|
+
readBytes = fs.readSync(fd, buf, 0, SESSION_HEAD, 0);
|
|
216
|
+
text = buf.toString("utf8", 0, readBytes);
|
|
217
|
+
} finally {
|
|
218
|
+
fs.closeSync(fd);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
const lines = text.split("\n");
|
|
224
|
+
let header;
|
|
225
|
+
try {
|
|
226
|
+
header = JSON.parse(lines[0] ?? "");
|
|
227
|
+
} catch {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
if (header?.type !== "session" || typeof header.id !== "string") return undefined;
|
|
231
|
+
const cut = readBytes >= SESSION_HEAD;
|
|
232
|
+
let name;
|
|
233
|
+
let first;
|
|
234
|
+
let messages = 0;
|
|
235
|
+
// The last line of a partial read is half a line. A file that fitted has no such line, and
|
|
236
|
+
// dropping one there loses a message from a count that could have been exact.
|
|
237
|
+
for (const line of lines.slice(1, cut ? -1 : undefined)) {
|
|
238
|
+
if (!line.trim()) continue;
|
|
239
|
+
let entry;
|
|
240
|
+
try {
|
|
241
|
+
entry = JSON.parse(line);
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// Written by whatever set it — a person, a model, an extension — so it is bounded here the
|
|
246
|
+
// way the first message is, rather than carrying 64KB into the picker.
|
|
247
|
+
if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
|
|
248
|
+
if (entry?.type !== "message") continue;
|
|
249
|
+
messages++;
|
|
250
|
+
if (first || entry.message?.role !== "user") continue;
|
|
251
|
+
const content = entry.message.content;
|
|
252
|
+
const said = typeof content === "string" ? content : (content ?? []).map((c: any) => (typeof c?.text === "string" ? c.text : "")).join(" ");
|
|
253
|
+
// A mention is expanded before it is sent, so the first user message can be a whole file.
|
|
254
|
+
if (said.trim()) first = said.trim().slice(0, 200);
|
|
255
|
+
}
|
|
256
|
+
return { id: header.id, startedAt: header.timestamp, name, first, messages, truncated: cut };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The model a session was last using, as pi would read it back.
|
|
261
|
+
*
|
|
262
|
+
* pi's own rule, walked from the leaf upwards: the newest `model_change` on this branch, or failing
|
|
263
|
+
* that the model the newest assistant message on it was answered by. Upwards rather than through the
|
|
264
|
+
* flat list because `get_entries` hands back the whole tree, abandoned branches included, and the
|
|
265
|
+
* model in an abandoned branch is not the model of this conversation.
|
|
266
|
+
*/
|
|
267
|
+
function lastModelOf(entries: any[], leafId: any) {
|
|
268
|
+
const byId = new Map((entries ?? []).filter((e) => e?.id).map((e) => [e.id, e]));
|
|
269
|
+
for (let e = byId.get(leafId); e; e = e.parentId ? byId.get(e.parentId) : undefined) {
|
|
270
|
+
if (e.type === "model_change" && e.provider && e.modelId) return { provider: e.provider, modelId: e.modelId };
|
|
271
|
+
const m = e.type === "message" ? e.message : undefined;
|
|
272
|
+
if (m?.role === "assistant" && m.provider && m.model) return { provider: m.provider, modelId: m.model };
|
|
273
|
+
}
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** What the snapshot asks of the server: the request rows and the automation panel, which live there. */
|
|
278
|
+
export interface SnapshotExtra {
|
|
279
|
+
requests: (sessionId: any, idle: boolean) => unknown[];
|
|
280
|
+
automation: (cwd: string, sessionId: any) => unknown;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export class PageChild {
|
|
284
|
+
/** The producer identity of this child's rows: one per spawn. */
|
|
285
|
+
readonly instance = randomUUID();
|
|
286
|
+
readonly pi: ChildProcessByStdio<Writable, Readable, Readable>;
|
|
287
|
+
readonly pid: number;
|
|
288
|
+
readonly procStart: string | undefined;
|
|
289
|
+
readonly cwd: string;
|
|
290
|
+
readonly piArgs: string[];
|
|
291
|
+
readonly log: (line: string) => void;
|
|
292
|
+
readonly startedAt = Date.now();
|
|
293
|
+
lastClientAt = Date.now();
|
|
294
|
+
/** What keeps pi busy now (`RPC_TURN_EVENTS`): a run, a compaction, or both. */
|
|
295
|
+
private readonly turn = new Set<"run" | "compaction">();
|
|
296
|
+
/** pi is running a turn or compacting: never idle, whatever its clients. */
|
|
297
|
+
get busy(): boolean {
|
|
298
|
+
return this.turn.size > 0;
|
|
299
|
+
}
|
|
300
|
+
alive = true;
|
|
301
|
+
private readonly opts: PageChildOptions;
|
|
302
|
+
|
|
303
|
+
stderrTail = "";
|
|
304
|
+
/** Characters the keep cut has already thrown away, so the send cut can count everything dropped. */
|
|
305
|
+
stderrDropped = 0;
|
|
306
|
+
|
|
307
|
+
private nextId = 1;
|
|
308
|
+
private readonly pending = new Map<string, (msg: any) => void>();
|
|
309
|
+
/** The tool calls running now, from `tool_execution_start` to `tool_execution_end`. */
|
|
310
|
+
private readonly toolCalls = new Set<string>();
|
|
311
|
+
/**
|
|
312
|
+
* The dialogs asked while a tool ran: pi has answered every one of them by the run's `agent_end`.
|
|
313
|
+
* A dialog a slash command opens during the run (`/session-share`) is not one; it waits for its answer.
|
|
314
|
+
*/
|
|
315
|
+
private readonly toolAsks = new Set<string>();
|
|
316
|
+
|
|
317
|
+
entriesCache: { at: number; value: any } = { at: 0, value: undefined };
|
|
318
|
+
|
|
319
|
+
live = {
|
|
320
|
+
/** What pi itself holds queued (a slash command or template typed while busy): shown beside the rows, cleared with them. */
|
|
321
|
+
queue: { steering: [] as any[], followUp: [] as any[] },
|
|
322
|
+
goal: undefined as any,
|
|
323
|
+
lastPoll: undefined as any,
|
|
324
|
+
runtime: undefined as any,
|
|
325
|
+
lastError: undefined as any,
|
|
326
|
+
pendingAsks: new Map<string, any>(), // dialogs asked while no browser was attached
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
catalog = {
|
|
330
|
+
modelCatalog: [] as any[],
|
|
331
|
+
thinkingLevels: [] as any[],
|
|
332
|
+
commandList: [] as { name: string; description?: string; source?: string }[],
|
|
333
|
+
/**
|
|
334
|
+
* Whether the pi behind this page has pi-loops in it, as its command list says; undefined until the
|
|
335
|
+
* list has been asked for, which is after the address is announced, so a request can arrive first.
|
|
336
|
+
* Unknown is not absent: only a known `false` hides the queue or refuses its routes (`/prompt` asks
|
|
337
|
+
* before it decides). Without the extension nobody drains `requests/`, and `/pi-loops ring` reaches
|
|
338
|
+
* the model as the person's text: chat goes over rpc `prompt` then, as it did before 0.24.
|
|
339
|
+
*/
|
|
340
|
+
piLoopsLoaded: undefined as boolean | undefined,
|
|
341
|
+
commandsAt: 0,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The session's directory, as pi reports it. `@mention` expansion and path completion are anchored
|
|
346
|
+
* here and nowhere else: the browser is told this value and echoes it back on every request, and a
|
|
347
|
+
* root that the caller supplies is not a boundary at all — `isUnder(f, "/")` is true of every file
|
|
348
|
+
* on the disk. Reaching those routes already needs the token, which /rpc turns into the whole
|
|
349
|
+
* session, so this is an invariant kept rather than a hole closed.
|
|
350
|
+
*/
|
|
351
|
+
sessionCwd: string;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Which file the session is being written to, as of the last `get_state`.
|
|
355
|
+
*
|
|
356
|
+
* Everything about starting or resuming a session is anchored to this one value rather than to
|
|
357
|
+
* anything the browser supplies: its directory is where a new session goes and the only place
|
|
358
|
+
* `/sessions` will list, and switching to it is the one destination that is refused (pi treats
|
|
359
|
+
* a switch to the file it is already writing as a brand-new session pointed at that file, which
|
|
360
|
+
* is two sessions with one file between them).
|
|
361
|
+
*/
|
|
362
|
+
sessionFile: string | undefined;
|
|
363
|
+
/** Whether `sessionFile` has been seen on disk: once it has, its going away is a move, not a file pi has yet to write. */
|
|
364
|
+
private fileSeen = false;
|
|
365
|
+
/** pi's session id, from the last `get_state`: the file a row is addressed to. */
|
|
366
|
+
sessionId: string | undefined;
|
|
367
|
+
|
|
368
|
+
/* ------------------------------------------------------------------ http */
|
|
369
|
+
|
|
370
|
+
clients = new Set<http.ServerResponse>();
|
|
371
|
+
backlog: any[] = [];
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Every event carries a number, and the history hand-off says which number it was current at. The
|
|
375
|
+
* browser replays the transcript, then ignores anything from the backlog it already has — exactly,
|
|
376
|
+
* instead of the "drop message_end for the first 300ms" guess this replaces. That guess dropped
|
|
377
|
+
* live events whenever the backlog held a turn that was still running: the tool results and the
|
|
378
|
+
* end-of-message that turns a streamed reply into rendered Markdown both went with it, so you got
|
|
379
|
+
* a page of raw asterisks and tools stuck on "running".
|
|
380
|
+
*/
|
|
381
|
+
eventSeq = 0;
|
|
382
|
+
/**
|
|
383
|
+
* Which run of this process the numbers belong to. Without it, a browser holding number 40 from the
|
|
384
|
+
* process that just exited quietly ignores the first forty events of the one that replaced it —
|
|
385
|
+
* every number is "already seen". A restart is not a gap, it is a different sequence entirely.
|
|
386
|
+
*/
|
|
387
|
+
epoch = randomBytes(6).toString("hex");
|
|
388
|
+
|
|
389
|
+
/** Built once pi has spawned: a child that never started has no pid and no listeners to give. */
|
|
390
|
+
constructor(pi: ChildProcessByStdio<Writable, Readable, Readable>, opts: PageChildOptions) {
|
|
391
|
+
if (pi.pid === undefined) throw new Error("pi has not started");
|
|
392
|
+
this.pi = pi;
|
|
393
|
+
this.opts = opts;
|
|
394
|
+
this.pid = pi.pid;
|
|
395
|
+
this.procStart = procStart(pi.pid);
|
|
396
|
+
this.cwd = opts.cwd;
|
|
397
|
+
this.piArgs = opts.piArgs;
|
|
398
|
+
this.sessionCwd = opts.cwd;
|
|
399
|
+
this.log = opts.log;
|
|
400
|
+
const on = (name: string, fn: (...args: any[]) => unknown) => guard(this.log, name, fn);
|
|
401
|
+
|
|
402
|
+
pi.on(
|
|
403
|
+
"error",
|
|
404
|
+
on("pi error", (err: Error) => this.log(`pi-loops page: pi ${this.pid}: ${err.message}`)),
|
|
405
|
+
);
|
|
406
|
+
pi.on(
|
|
407
|
+
"exit",
|
|
408
|
+
on("pi exit", (code: number | null, signal: NodeJS.Signals | null) => {
|
|
409
|
+
this.alive = false;
|
|
410
|
+
// Nothing will answer now. A command still waiting would hold a browser request open
|
|
411
|
+
// until its own timeout, up to fifteen minutes for a slash command.
|
|
412
|
+
for (const answer of this.pending.values()) answer({ success: false, error: "pi is not running" });
|
|
413
|
+
this.pending.clear();
|
|
414
|
+
// The page has to be told *why*. pi's own reasons for refusing to start — a duplicate extension,
|
|
415
|
+
// a provider that will not authenticate — are on its stderr, and a browser tab that just says
|
|
416
|
+
// "pi exited" sends you to the terminal to find out what a terminal already knew.
|
|
417
|
+
this.broadcast({ type: "pi_exit", code, signal, stderr: this.recentStderr() });
|
|
418
|
+
this.opts.onExit(code, signal);
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
pi.stderr.on(
|
|
423
|
+
"data",
|
|
424
|
+
on("pi stderr", (d: Buffer) => {
|
|
425
|
+
// This process's stderr is host.log now, not a terminal: a provider that refused to
|
|
426
|
+
// authenticate prints the key it was given, so the line is masked before it lands on disk.
|
|
427
|
+
process.stderr.write(maskSecrets(d.toString("utf8")));
|
|
428
|
+
// Characters, not code units: a cut on the code-unit half of an astral character leaves a lone
|
|
429
|
+
// surrogate in the tail, which `textContent` draws as a replacement glyph.
|
|
430
|
+
const all = Array.from(this.stderrTail + d.toString("utf8"));
|
|
431
|
+
this.stderrDropped += Math.max(0, all.length - STDERR_KEEP);
|
|
432
|
+
this.stderrTail = all.slice(-STDERR_KEEP).join("");
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
pi.stdin.on(
|
|
437
|
+
"error",
|
|
438
|
+
on("pi stdin", (err: any) => {
|
|
439
|
+
this.alive = false;
|
|
440
|
+
this.log(`pi-loops web: pi's input closed: ${err?.message ?? err}`);
|
|
441
|
+
}),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Strict JSONL (jsonlLines): one tool result containing U+2028 would otherwise desync the stream
|
|
445
|
+
// for the rest of the session. Decoded as a stream, so a character whose bytes arrive in two
|
|
446
|
+
// chunks is one character and not two replacement glyphs.
|
|
447
|
+
pi.stdout.setEncoding("utf8");
|
|
448
|
+
const lineMax = opts.lineMax ?? PI_LINE_MAX;
|
|
449
|
+
const lines = jsonlLines(lineMax);
|
|
450
|
+
pi.stdout.on(
|
|
451
|
+
"data",
|
|
452
|
+
on("pi stdout", (chunk: string) => {
|
|
453
|
+
for (const line of lines(chunk)) {
|
|
454
|
+
if (typeof line !== "string") {
|
|
455
|
+
this.overlong(line.head, lineMax);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (!line.trim()) continue;
|
|
459
|
+
let msg;
|
|
460
|
+
try {
|
|
461
|
+
msg = JSON.parse(line);
|
|
462
|
+
} catch {
|
|
463
|
+
continue; // never desync the whole session over one unparseable line
|
|
464
|
+
}
|
|
465
|
+
if (!msg || typeof msg !== "object") continue; // a bare value is not an event
|
|
466
|
+
if (msg.type === "response" && msg.id && this.pending.has(msg.id)) {
|
|
467
|
+
this.pending.get(msg.id)?.(msg);
|
|
468
|
+
this.pending.delete(msg.id);
|
|
469
|
+
continue; // a command's own answer belongs to its caller, not to the feed
|
|
470
|
+
}
|
|
471
|
+
this.opts.onNote?.(msg);
|
|
472
|
+
this.observe(msg);
|
|
473
|
+
this.broadcast(msg);
|
|
474
|
+
}
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
recentStderr() {
|
|
480
|
+
// Redact the whole kept tail, then cut by characters. `slice` counted UTF-16 code units, so the
|
|
481
|
+
// served tail could begin on the low half of an astral character — a replacement glyph where the
|
|
482
|
+
// error should start — and the page's own cap at 4000 could never fire: it was always handed 4000
|
|
483
|
+
// code units or fewer, and code points are never more numerous than code units, so nothing ever
|
|
484
|
+
// told the person anything was dropped. The count has to be put here rather than in the page,
|
|
485
|
+
// because `capped` keeps the first n characters and a tail keeps the last.
|
|
486
|
+
const kept = Array.from(maskSecrets(this.stderrTail.trim()) as string);
|
|
487
|
+
if (!this.stderrDropped && kept.length <= STDERR_SEND) return kept.join("");
|
|
488
|
+
const room = STDERR_SEND - 40; // the marker line below is shorter than this
|
|
489
|
+
const shown = kept.slice(-room);
|
|
490
|
+
return `… (${this.stderrDropped + kept.length - shown.length} chars dropped)\n${shown.join("")}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Write one line to pi. Its stdin is a pipe to another process: it can be gone before the `exit`
|
|
495
|
+
* event that sets `alive`, and an EPIPE arrives as an unhandled `error` event on the socket,
|
|
496
|
+
* which ends this process too. A browser front end whose pi died should say so, not die with it.
|
|
497
|
+
*/
|
|
498
|
+
writeToPi(line: string) {
|
|
499
|
+
if (!this.alive) return false;
|
|
500
|
+
try {
|
|
501
|
+
this.pi.stdin.write(line);
|
|
502
|
+
return true;
|
|
503
|
+
} catch (err: any) {
|
|
504
|
+
this.log(`pi-loops web: could not write to pi: ${err?.message ?? err}`);
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** A line from pi over the cap was dropped: the command it answered gets a reason instead of waiting out its timeout. */
|
|
510
|
+
private overlong(head: string, max: number) {
|
|
511
|
+
const m = ANSWER_HEAD.exec(head);
|
|
512
|
+
const id = m?.[1] ?? m?.[2];
|
|
513
|
+
const why = `pi answered with a line over ${Math.round(max / (1024 * 1024))} MiB, longer than the page reads`;
|
|
514
|
+
this.log(`pi-loops web: ${why}${id ? ` (${id})` : ""}; dropped`);
|
|
515
|
+
const answer = id ? this.pending.get(id) : undefined;
|
|
516
|
+
if (!answer || !id) return;
|
|
517
|
+
this.pending.delete(id);
|
|
518
|
+
answer({ success: false, error: why });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Whether this process asks pi for the entries of the conversation it is in (held-read.ts `entriesReadable`). */
|
|
522
|
+
entriesReadable(): boolean {
|
|
523
|
+
if (!this.fileSeen && this.sessionFile && fs.existsSync(this.sessionFile)) this.fileSeen = true;
|
|
524
|
+
return fileEntriesReadable(this.sessionFile, this.fileSeen);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Send one RPC command and wait for the response that carries its id. */
|
|
528
|
+
rpc(command: object, timeoutMs = 60_000): Promise<any> {
|
|
529
|
+
if (!this.alive) return Promise.resolve({ success: false, error: "pi is not running" });
|
|
530
|
+
const id = `web-${this.nextId++}`;
|
|
531
|
+
if (!this.writeToPi(`${JSON.stringify({ ...command, id })}\n`)) return Promise.resolve({ success: false, error: "pi is not running" });
|
|
532
|
+
return new Promise((resolve) => {
|
|
533
|
+
const timer = setTimeout(
|
|
534
|
+
guard(this.log, "rpc timeout", () => {
|
|
535
|
+
// A command that never answers must not hold a browser request open for ever.
|
|
536
|
+
if (this.pending.delete(id)) resolve({ success: false, error: `timed out after ${timeoutMs}ms` });
|
|
537
|
+
}),
|
|
538
|
+
timeoutMs,
|
|
539
|
+
);
|
|
540
|
+
this.pending.set(id, (msg) => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
resolve(msg);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Fold the events that carry state a late-joining browser still needs. */
|
|
548
|
+
observe(ev: any) {
|
|
549
|
+
const turn = (RPC_TURN_EVENTS as Record<string, readonly ["run" | "compaction", boolean]>)[ev.type];
|
|
550
|
+
if (turn) {
|
|
551
|
+
if (turn[1]) this.turn.add(turn[0]);
|
|
552
|
+
else this.turn.delete(turn[0]);
|
|
553
|
+
}
|
|
554
|
+
if (ev.type === "tool_execution_start") this.toolCalls.add(String(ev.toolCallId));
|
|
555
|
+
if (ev.type === "tool_execution_end") this.toolCalls.delete(String(ev.toolCallId));
|
|
556
|
+
if (ev.type === "agent_end") {
|
|
557
|
+
// A tool's timed dialog ends with the tool: an abort or a timeout resolves it inside pi, which
|
|
558
|
+
// tells nobody. Offered again, it would be a card whose answer goes nowhere. A slash command's
|
|
559
|
+
// dialog has no timeout and waits past the run, even when it opened while a tool ran.
|
|
560
|
+
for (const id of this.toolAsks) this.live.pendingAsks.delete(id);
|
|
561
|
+
this.toolAsks.clear();
|
|
562
|
+
this.toolCalls.clear();
|
|
563
|
+
}
|
|
564
|
+
if (ev.type === "queue_update") this.live.queue = { steering: ev.steering ?? [], followUp: ev.followUp ?? [] };
|
|
565
|
+
// pi refused a `sendUserMessage` (compaction in progress, no credentials). The holder finds the
|
|
566
|
+
// row it handed over and never saw start once the grace has passed; ring it then, so the row
|
|
567
|
+
// comes back to the composer without waiting for the tick.
|
|
568
|
+
if (ev.type === "extension_error" && ev.event === "send_user_message") setTimeout(guard(this.log, "ring after a refused send", () => this.opts.ring()), PERSON_SEND_GRACE_MS + 500).unref();
|
|
569
|
+
if (ev.type === "extension_ui_request") {
|
|
570
|
+
if (isDialog(ev)) {
|
|
571
|
+
// pi answers a dialog with a timeout itself when it elapses and tells nobody; past that an
|
|
572
|
+
// answer goes nowhere, so the dialog is not offered again, and one replayed carries what is left.
|
|
573
|
+
const timed = typeof ev.timeout === "number" && ev.timeout > 0;
|
|
574
|
+
const held = timed ? { ...ev, expiresAt: Date.now() + ev.timeout } : ev;
|
|
575
|
+
this.live.pendingAsks.set(ev.id, held);
|
|
576
|
+
if (this.toolCalls.size && timed) this.toolAsks.add(ev.id);
|
|
577
|
+
if (timed) setTimeout(guard(this.log, "dialog timeout", () => this.live.pendingAsks.get(ev.id) === held && this.live.pendingAsks.delete(ev.id)), ev.timeout).unref();
|
|
578
|
+
while (this.live.pendingAsks.size > MAX_PENDING_ASKS) this.live.pendingAsks.delete(this.live.pendingAsks.keys().next().value as string);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (ev.type === "entry_appended") {
|
|
582
|
+
this.entriesCache = { at: 0, value: undefined }; // the session moved; search and undo must re-read
|
|
583
|
+
const entry = ev.entry;
|
|
584
|
+
if (entry?.type !== "custom") return;
|
|
585
|
+
// pi-loops writes its own state into the session as custom entries, so the panel can read it
|
|
586
|
+
// structurally instead of parsing the text it prints for a person.
|
|
587
|
+
if (entry.customType === "goal_state") this.live.goal = entry.data;
|
|
588
|
+
if (entry.customType === "trigger_result") this.live.lastPoll = { ...entry.data, at: entry.timestamp };
|
|
589
|
+
// Which MCP servers actually connected, what they exposed, whether pi-loops runs: state that
|
|
590
|
+
// lives in the pi process and is in no file this side could open.
|
|
591
|
+
if (entry.customType === "pi_loops_snapshot") this.live.runtime = entry.data;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** pi's command list, and from it whether pi-loops is loaded. */
|
|
596
|
+
async refreshCommands() {
|
|
597
|
+
const commands = await this.rpc({ type: "get_commands" }, 10_000);
|
|
598
|
+
if (commands?.success) {
|
|
599
|
+
this.catalog.commandList = (commands.data?.commands ?? []).map((c: any) => ({ name: c.name, description: c.description, source: c.source }));
|
|
600
|
+
this.catalog.piLoopsLoaded = this.catalog.commandList.some((c) => c.name === "pi-loops");
|
|
601
|
+
this.catalog.commandsAt = Date.now();
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async refreshCatalog() {
|
|
606
|
+
const [models, levels] = await Promise.all([
|
|
607
|
+
this.rpc({ type: "get_available_models" }, 10_000),
|
|
608
|
+
// Which thinking levels mean anything for the model in use: the map pi carries has nulls in
|
|
609
|
+
// it, and offering a level a model does not have is offering nothing.
|
|
610
|
+
this.rpc({ type: "get_available_thinking_levels" }, 10_000),
|
|
611
|
+
this.refreshCommands(),
|
|
612
|
+
]);
|
|
613
|
+
if (levels?.success) this.catalog.thinkingLevels = levels.data?.levels ?? [];
|
|
614
|
+
// pi says a great deal more about a model than its name, and the page can use most of it: which
|
|
615
|
+
// of them take images, how big a context is, what a token costs. Keeping only the id meant the
|
|
616
|
+
// picker was 33 identical-looking lines and the attach button was a guess.
|
|
617
|
+
if (models?.success) {
|
|
618
|
+
this.catalog.modelCatalog = (models.data?.models ?? []).map((m: any) => ({
|
|
619
|
+
id: m.id,
|
|
620
|
+
provider: m.provider,
|
|
621
|
+
name: m.name,
|
|
622
|
+
images: Array.isArray(m.input) ? m.input.includes("image") : undefined,
|
|
623
|
+
reasoning: !!m.reasoning,
|
|
624
|
+
contextWindow: m.contextWindow,
|
|
625
|
+
cost: m.cost ? { input: m.cost.input, output: m.cost.output } : undefined,
|
|
626
|
+
}));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* A session that has been running has its snapshot somewhere back in the transcript, and pi-loops
|
|
632
|
+
* only writes a new one when something changes — so read the last one, then ask for a fresh one.
|
|
633
|
+
*/
|
|
634
|
+
async primeRuntime() {
|
|
635
|
+
// Started for a conversation, pi names its file in its first get_state answer.
|
|
636
|
+
if (this.sessionFile === undefined) await this.identify();
|
|
637
|
+
const entries = this.entriesReadable() ? await this.rpc({ type: "get_entries" }, 20_000) : undefined;
|
|
638
|
+
if (entries?.success) {
|
|
639
|
+
for (const e of entries.data?.entries ?? []) {
|
|
640
|
+
if (e?.type === "custom" && e.customType === "pi_loops_snapshot") this.live.runtime = e.data;
|
|
641
|
+
if (e?.type === "custom" && e.customType === "goal_state") this.live.goal = e.data;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (this.catalog.commandList.some((c) => c.name === "cron")) await this.rpc({ type: "prompt", message: "/cron snapshot" }, 20_000);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Which conversation pi is in, asked once it has started: the pool keys a child by it, and a child
|
|
649
|
+
* started for a new conversation has no id until pi has made one.
|
|
650
|
+
*/
|
|
651
|
+
async identify() {
|
|
652
|
+
const state = await this.rpc({ type: "get_state" }, 15_000);
|
|
653
|
+
if (state?.success) this.adopt(state.data ?? {});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** What a `get_state` says about where pi is: the directory, the session id, the file. */
|
|
657
|
+
private adopt(s: any) {
|
|
658
|
+
const cwd = s.cwd || this.cwd;
|
|
659
|
+
this.sessionCwd = cwd;
|
|
660
|
+
if (typeof s.sessionId === "string") this.sessionId = s.sessionId;
|
|
661
|
+
if (typeof s.sessionFile === "string" && s.sessionFile && s.sessionFile !== this.sessionFile) {
|
|
662
|
+
const switched = this.sessionFile !== undefined && realpathish(s.sessionFile) !== realpathish(this.sessionFile);
|
|
663
|
+
this.sessionFile = s.sessionFile;
|
|
664
|
+
this.fileSeen = false;
|
|
665
|
+
if (switched) {
|
|
666
|
+
this.startedOver(s.sessionFile);
|
|
667
|
+
Promise.all([this.refreshCatalog(), this.primeRuntime()]).catch((err) => this.log(`pi-loops web: after a session switch: ${err?.message ?? err}`));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return cwd;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async snapshot(extra: SnapshotExtra) {
|
|
674
|
+
const state = await this.rpc({ type: "get_state" }, 15_000);
|
|
675
|
+
const s = state?.success ? state.data : {};
|
|
676
|
+
const cwd = this.adopt(s);
|
|
677
|
+
return {
|
|
678
|
+
ok: !!state?.success,
|
|
679
|
+
sessionId: s.sessionId,
|
|
680
|
+
sessionName: s.sessionName,
|
|
681
|
+
sessionFile: s.sessionFile,
|
|
682
|
+
cwd,
|
|
683
|
+
model: s.model ? { id: s.model.id, provider: s.model.provider, label: `${s.model.provider}/${s.model.id}` } : undefined,
|
|
684
|
+
modelCatalog: this.catalog.modelCatalog,
|
|
685
|
+
thinkingLevels: this.catalog.thinkingLevels,
|
|
686
|
+
// The page polls this. Comparing these two against what it has received is how it notices
|
|
687
|
+
// that it is no longer being told anything — a dropped stream, a suspended tab, or a server
|
|
688
|
+
// that has been restarted under it.
|
|
689
|
+
seq: this.eventSeq,
|
|
690
|
+
epoch: this.epoch,
|
|
691
|
+
// What is serving this, so a page can tell whether it is the page this server would send.
|
|
692
|
+
version: this.opts.version,
|
|
693
|
+
thinkingLevel: s.thinkingLevel,
|
|
694
|
+
busy: !!s.isStreaming,
|
|
695
|
+
compacting: !!s.isCompacting,
|
|
696
|
+
messageCount: s.messageCount ?? 0,
|
|
697
|
+
requests: extra.requests(s.sessionId, !s.isStreaming && !s.isCompacting),
|
|
698
|
+
queue: this.live.queue,
|
|
699
|
+
goal: this.live.goal,
|
|
700
|
+
lastPoll: this.live.lastPoll,
|
|
701
|
+
runtime: this.live.runtime,
|
|
702
|
+
pendingAsks: [...this.live.pendingAsks.values()].map(({ expiresAt, ...ask }) => (expiresAt === undefined ? ask : { ...ask, timeout: Math.max(1, expiresAt - Date.now()) })),
|
|
703
|
+
automation: extra.automation(cwd, s.sessionId),
|
|
704
|
+
piAlive: this.alive,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/* ------------------------------------------------------------------ starting over */
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Starting a session and going back to one, without leaving the page.
|
|
712
|
+
*
|
|
713
|
+
* Both are `switch_session`, which pi answers by replacing the session inside the process it
|
|
714
|
+
* already has: same model, same MCP connections, same extensions — pi rebuilds them against the
|
|
715
|
+
* new session and pi-loops has handled that path since it was written (`session_shutdown` with a
|
|
716
|
+
* reason that is not "quit", then `session_start`, which is how `/new` and `/resume` behave in the
|
|
717
|
+
* terminal). The clock is not handed to the headless host and no scheduled run is lost: one that
|
|
718
|
+
* the swap aborts gives its slot back and re-fires on the next tick.
|
|
719
|
+
*
|
|
720
|
+
* A path that does not exist yet is how a *new* session is asked for. Nothing is written until the
|
|
721
|
+
* first message, so the new-session button costs an empty file only if you use it.
|
|
722
|
+
*/
|
|
723
|
+
|
|
724
|
+
/** The directory pi keeps this project's sessions in: where the current one is, and nowhere else. */
|
|
725
|
+
sessionsDir() {
|
|
726
|
+
return this.sessionFile ? path.dirname(this.sessionFile) : undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* What is in that directory, newest first.
|
|
731
|
+
*
|
|
732
|
+
* Only the head of each file is read. A session is a transcript — some are megabytes — and this
|
|
733
|
+
* list is drawn every time the dialog opens; what it needs is the header, plus enough of the start
|
|
734
|
+
* to say which conversation this was. pi's own picker prefers a name over the first message when
|
|
735
|
+
* one is set, and so does this, with the caveat that a name set late in a long session is past the
|
|
736
|
+
* part that gets read and shows as the first message instead.
|
|
737
|
+
*/
|
|
738
|
+
listSessionsHere(limit = 40) {
|
|
739
|
+
const dir = this.sessionsDir();
|
|
740
|
+
if (!dir) return [];
|
|
741
|
+
let names;
|
|
742
|
+
try {
|
|
743
|
+
names = fs.readdirSync(dir);
|
|
744
|
+
} catch {
|
|
745
|
+
return []; // nothing saved for this project yet
|
|
746
|
+
}
|
|
747
|
+
const out = [];
|
|
748
|
+
// Sorting by age means looking at all of them, and this runs on the request thread: a directory
|
|
749
|
+
// that has grown past anything anyone would page through is cut off rather than stalling it.
|
|
750
|
+
for (const name of names.slice(0, 2000)) {
|
|
751
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
752
|
+
const file = path.join(dir, name);
|
|
753
|
+
let stat;
|
|
754
|
+
try {
|
|
755
|
+
stat = fs.statSync(file);
|
|
756
|
+
} catch {
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (!stat.isFile()) continue;
|
|
760
|
+
out.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
|
|
761
|
+
}
|
|
762
|
+
out.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
763
|
+
const sessions = [];
|
|
764
|
+
// What the filesystem resolves to, not what the name says: a link in this directory pointing at
|
|
765
|
+
// the open session is otherwise a way to be offered the one destination that is refused.
|
|
766
|
+
const open = this.sessionFile ? realpathish(this.sessionFile) : undefined;
|
|
767
|
+
for (const entry of out.slice(0, limit)) {
|
|
768
|
+
const head = readHead(entry.file);
|
|
769
|
+
if (!head) continue; // not a session file, or not a readable one
|
|
770
|
+
sessions.push({ ...entry, ...head, current: realpathish(entry.file) === open });
|
|
771
|
+
}
|
|
772
|
+
return sessions;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Where a new session goes: pi's own naming, in pi's own directory.
|
|
777
|
+
*
|
|
778
|
+
* The uuid in the name is this side's, and the id pi puts in the header is pi's — they will not be
|
|
779
|
+
* the same string, because pi mints its id when it takes the path and nothing can know it sooner.
|
|
780
|
+
* pi does not mind: `--session <id>`, `-c` and its picker all match on the header, checked against
|
|
781
|
+
* a session file deliberately named after a different uuid.
|
|
782
|
+
*
|
|
783
|
+
* pi-loops did mind, in one place, and it cost every inject-and-run job made in a session started
|
|
784
|
+
* here: `sessionExists` decided by the file name, so ten minutes later the scheduler parked those
|
|
785
|
+
* jobs as belonging to a session that did not exist, and `/cron gc` deletes what it parks. That
|
|
786
|
+
* function reads the header now. Anything else that identifies a session by its file name is
|
|
787
|
+
* wrong about a session this function named.
|
|
788
|
+
*/
|
|
789
|
+
newSessionPath() {
|
|
790
|
+
const dir = this.sessionsDir();
|
|
791
|
+
if (!dir) return undefined;
|
|
792
|
+
return path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}_${randomUUID()}.jsonl`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Put a resumed session back on the model it was having its conversation with.
|
|
797
|
+
*
|
|
798
|
+
* `--model` on the launch command line — which the launcher also supplies from `ui.json`'s remembered
|
|
799
|
+
* choice — is not a one-off: pi re-resolves it every time the session inside the process is replaced,
|
|
800
|
+
* so resuming an earlier session in this window landed it on the model *this process* started with.
|
|
801
|
+
* A fresh `pi --resume` restores the session's own model instead, and a window should not mean
|
|
802
|
+
* something different from a terminal.
|
|
803
|
+
*
|
|
804
|
+
* Best effort by nature: a model this session used and this machine has no credentials for any more
|
|
805
|
+
* is refused by pi, and the session stays on the one it has — which is what the panel then says.
|
|
806
|
+
*/
|
|
807
|
+
async restoreModelOf(file: string) {
|
|
808
|
+
if (!fileEntriesReadable(file)) return; // too big to parse here: it stays on the model it has
|
|
809
|
+
const r = await this.rpc({ type: "get_entries" }, 20_000);
|
|
810
|
+
if (!r?.success) return;
|
|
811
|
+
const want = lastModelOf(r.data?.entries ?? [], r.data?.leafId);
|
|
812
|
+
if (!want) return; // a session nobody ever answered in: nothing to go back to
|
|
813
|
+
const state = await this.rpc({ type: "get_state" }, 15_000);
|
|
814
|
+
const now = state?.success ? state.data?.model : undefined;
|
|
815
|
+
if (now && now.provider === want.provider && now.id === want.modelId) return; // already there
|
|
816
|
+
const answer = await this.rpc({ type: "set_model", provider: want.provider, modelId: want.modelId }, 20_000);
|
|
817
|
+
// Whoever asked for the swap refreshes the catalog right after this, which is where the new
|
|
818
|
+
// model's thinking levels come from; there is nothing to do here on success.
|
|
819
|
+
if (answer?.success) return;
|
|
820
|
+
// Not a failure of the resume: the conversation is back, on a different model. The terminal is
|
|
821
|
+
// where that belongs — the page reads the model it is actually on from /state.
|
|
822
|
+
this.log(`pi-loops web: ${path.basename(file)} was last on ${want.provider}/${want.modelId}, which this session could not go back to: ${answer?.error ?? "pi did not answer"}`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Swap the session under the page.
|
|
827
|
+
*
|
|
828
|
+
* Refused while a turn is running: the swap aborts it, and losing a reply you are waiting for is
|
|
829
|
+
* not something to discover afterwards. The page asks first, which is where the question belongs;
|
|
830
|
+
* this is the check that means it cannot be skipped by a stale tab.
|
|
831
|
+
*/
|
|
832
|
+
async swapSession(file: string | undefined, { restoreModel = false, reopen = false } = {}) {
|
|
833
|
+
if (!this.alive) return { success: false, error: "pi is not running" };
|
|
834
|
+
if (!file && !reopen) return { success: false, error: "this session has no file on disk (pi was started with --no-session)" };
|
|
835
|
+
const before = await this.rpc({ type: "get_state" }, 15_000);
|
|
836
|
+
if (!before?.success) return { success: false, error: before?.error ?? "pi did not answer" };
|
|
837
|
+
if (before.data?.isStreaming || before.data?.isCompacting) return { success: false, error: "a turn is running — stop it first" };
|
|
838
|
+
const current = before.data?.sessionFile;
|
|
839
|
+
// `reopen`: the session pi is in, read again from disk, where another window wrote after pi read it.
|
|
840
|
+
if (reopen) file = current;
|
|
841
|
+
if (!file) return { success: false, error: "this session has no file on disk (pi was started with --no-session)" };
|
|
842
|
+
if (!reopen && current && realpathish(file) === realpathish(current)) return { success: false, error: "this is the session you are already in" };
|
|
843
|
+
const answer = await this.rpc({ type: "switch_session", sessionPath: file }, 60_000);
|
|
844
|
+
if (!answer?.success) return answer ?? { success: false, error: "pi did not answer" };
|
|
845
|
+
if (answer.data?.cancelled) return { success: true, data: { cancelled: true } };
|
|
846
|
+
|
|
847
|
+
this.startedOver(file);
|
|
848
|
+
// Only a resume: a new session is meant to start on whatever this process starts sessions on.
|
|
849
|
+
if (restoreModel) await this.restoreModelOf(file);
|
|
850
|
+
// The rebuilt extensions register their commands again, and pi-loops writes a snapshot for the
|
|
851
|
+
// panel only when something changes — so ask for both rather than showing the old session's.
|
|
852
|
+
await this.refreshCatalog();
|
|
853
|
+
await this.primeRuntime();
|
|
854
|
+
return answer;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Forget what belonged to the session pi was in. A swap the page asked for comes here, and so does
|
|
859
|
+
* one an extension made inside pi (`/inbox open`, `/session-import --resume`), which `snapshot`
|
|
860
|
+
* notices when the file pi reports is not the one this side last knew.
|
|
861
|
+
*/
|
|
862
|
+
startedOver(file: string) {
|
|
863
|
+
// A different session is a different sequence of events: the numbers the page was counting mean
|
|
864
|
+
// nothing against the ones that start now, and the epoch is how it is told that.
|
|
865
|
+
this.epoch = randomBytes(6).toString("hex");
|
|
866
|
+
this.backlog.length = 0;
|
|
867
|
+
this.entriesCache = { at: 0, value: undefined };
|
|
868
|
+
this.live.queue = { steering: [], followUp: [] };
|
|
869
|
+
this.live.goal = undefined;
|
|
870
|
+
this.live.lastPoll = undefined;
|
|
871
|
+
this.live.runtime = undefined;
|
|
872
|
+
this.live.lastError = undefined;
|
|
873
|
+
// Dialogs belong to the session that asked them. The extension that was waiting for an answer
|
|
874
|
+
// went down with the swap, and answering now would be answering nobody.
|
|
875
|
+
this.live.pendingAsks.clear();
|
|
876
|
+
this.sessionFile = file;
|
|
877
|
+
this.fileSeen = false;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
broadcast(event: any) {
|
|
881
|
+
if (event && typeof event === "object") {
|
|
882
|
+
event.seq = ++this.eventSeq;
|
|
883
|
+
event.epoch = this.epoch;
|
|
884
|
+
}
|
|
885
|
+
this.backlog.push(event);
|
|
886
|
+
if (this.backlog.length > 800) this.backlog.shift();
|
|
887
|
+
const data = `data: ${JSON.stringify(event)}\n\n`;
|
|
888
|
+
for (const res of this.clients) {
|
|
889
|
+
try {
|
|
890
|
+
res.write(data);
|
|
891
|
+
} catch {
|
|
892
|
+
this.clients.delete(res);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** The whole session, for search and undo. Typing in the search box must not re-read it per key. */
|
|
898
|
+
async cachedEntries() {
|
|
899
|
+
if (Date.now() - this.entriesCache.at < 3_000 && this.entriesCache.value) return this.entriesCache.value;
|
|
900
|
+
if (!this.entriesReadable()) return { success: false, error: "this conversation is too big to read here, or its file has moved" };
|
|
901
|
+
const r = await this.rpc({ type: "get_entries" }, 30_000);
|
|
902
|
+
this.entriesCache = { at: Date.now(), value: r };
|
|
903
|
+
return r;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/* ------------------------------------------------------------------ the pool: one child per open conversation */
|
|
908
|
+
|
|
909
|
+
/** `pages/<session>.json`: one per running child, so a later pi-loops can stop what an earlier one left. */
|
|
910
|
+
export interface PageRecord {
|
|
911
|
+
v: 1;
|
|
912
|
+
sessionId: string;
|
|
913
|
+
cwd: string;
|
|
914
|
+
pid: number;
|
|
915
|
+
procStart?: string;
|
|
916
|
+
startedAt: string;
|
|
917
|
+
/** Written at most once a minute. */
|
|
918
|
+
lastClientAt: string;
|
|
919
|
+
/** The pi-loops process that started the child. */
|
|
920
|
+
server: { pid: number; procStart?: string };
|
|
921
|
+
/** The pi arguments the child was started with. */
|
|
922
|
+
piArgs: string[];
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** A record as read back from disk; undefined for anything torn or foreign. */
|
|
926
|
+
export function parsePageRecord(text: string): PageRecord | undefined {
|
|
927
|
+
let v: any;
|
|
928
|
+
try {
|
|
929
|
+
v = JSON.parse(text);
|
|
930
|
+
} catch {
|
|
931
|
+
return undefined; // torn, or not ours
|
|
932
|
+
}
|
|
933
|
+
const pid = (n: unknown) => Number.isInteger(n) && (n as number) > 0;
|
|
934
|
+
if (!v || typeof v !== "object" || v.v !== 1 || typeof v.sessionId !== "string" || typeof v.cwd !== "string" || !pid(v.pid)) return undefined;
|
|
935
|
+
if (typeof v.startedAt !== "string" || typeof v.lastClientAt !== "string" || !v.server || !pid(v.server.pid)) return undefined;
|
|
936
|
+
// Read for the orphan it names, so a record without them is still one to stop.
|
|
937
|
+
return { ...v, piArgs: stringList(v.piArgs) ?? [] } as PageRecord;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** `v` when it is a list of strings, else undefined. */
|
|
941
|
+
function stringList(v: unknown): string[] | undefined {
|
|
942
|
+
return Array.isArray(v) && v.every((a) => typeof a === "string") ? v : undefined;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** What the decisions below need to know about a child, and nothing else. */
|
|
946
|
+
export type ChildView = { sessionId: string; clients: number; busy: boolean; pendingAsks: number; unsentPersonRows: number; lastClientAt: number };
|
|
947
|
+
|
|
948
|
+
export const PAGE_IDLE_MS = 15 * 60_000;
|
|
949
|
+
export const PAGE_CAP = 6;
|
|
950
|
+
export const PAGE_STREAMS_PER_CHILD = 16;
|
|
951
|
+
export const PAGE_STREAMS_TOTAL = 32;
|
|
952
|
+
/** Exits of one conversation's pi that stop the page from starting it again on its own. */
|
|
953
|
+
export const PAGE_CRASH_BUDGET = { count: 3, windowMs: 5 * 60_000 };
|
|
954
|
+
|
|
955
|
+
/** `PAGE_IDLE_MS`, or `PI_LOOPS_PAGE_IDLE_MS` when that is a positive number of milliseconds. */
|
|
956
|
+
export function pageIdleMs(env: NodeJS.ProcessEnv): number {
|
|
957
|
+
const told = Number(env.PI_LOOPS_PAGE_IDLE_MS);
|
|
958
|
+
return Number.isFinite(told) && told > 0 ? told : PAGE_IDLE_MS;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Nobody would notice this child stop: no browser attached, no turn running, no dialog waiting and
|
|
963
|
+
* no message of the person's that it still has to ring for. The last one matters because the row's
|
|
964
|
+
* producer is this child: stopped, the row would wait for the next page to offer it.
|
|
965
|
+
*/
|
|
966
|
+
const idle = (c: ChildView) => c.clients === 0 && !c.busy && c.pendingAsks === 0 && c.unsentPersonRows === 0;
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Which children to stop: every idle one nobody has looked at for `idleMs`, and, when `opening` names
|
|
970
|
+
* a conversation the pool does not have and the pool is at `cap`, the least recently used idle ones
|
|
971
|
+
* to make room. `full` when there is no room to make.
|
|
972
|
+
*/
|
|
973
|
+
export function decideChildStops(children: readonly ChildView[], now: number, opts: { idleMs: number; cap: number; opening?: string }): { stop: string[]; full?: true } {
|
|
974
|
+
const stop = children.filter((c) => idle(c) && now - c.lastClientAt > opts.idleMs).map((c) => c.sessionId);
|
|
975
|
+
if (opts.opening === undefined || children.some((c) => c.sessionId === opts.opening)) return { stop };
|
|
976
|
+
const left = children.filter((c) => !stop.includes(c.sessionId));
|
|
977
|
+
const over = left.length - opts.cap + 1;
|
|
978
|
+
if (over <= 0) return { stop };
|
|
979
|
+
const spare = left.filter(idle).sort((a, b) => a.lastClientAt - b.lastClientAt);
|
|
980
|
+
stop.push(...spare.slice(0, over).map((c) => c.sessionId));
|
|
981
|
+
return spare.length >= over ? { stop } : { stop, full: true };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* What to do with a record found on start, judging pids by the rule a lease holder is judged by.
|
|
986
|
+
* `ours`: this process wrote it, or another pi-loops that is still running does, which stops its own
|
|
987
|
+
* children (two `pi-loops --web` share one loops directory). `gone`: its pid is not that child any
|
|
988
|
+
* more. Otherwise the child of a pi-loops that is gone is still running, with nobody on its stdio:
|
|
989
|
+
* stop it. It is never adopted, since its pipes died with the process that held them.
|
|
990
|
+
*/
|
|
991
|
+
export function decideOrphan(rec: PageRecord, self: { pid: number; procStart?: string }, probe: ProcessProbe): "ours" | "gone" | "wait-then-stop" {
|
|
992
|
+
const alive = (pid: number, start: string | undefined) => holderAlive({ kind: "terminal", pid, instance: "", procStart: start, acquiredAt: rec.startedAt, pushServers: [] }, probe);
|
|
993
|
+
if (rec.server.pid === self.pid && (rec.server.procStart === undefined || self.procStart === undefined || rec.server.procStart === self.procStart)) return "ours";
|
|
994
|
+
if (rec.server.pid !== self.pid && alive(rec.server.pid, rec.server.procStart)) return "ours";
|
|
995
|
+
return alive(rec.pid, rec.procStart) ? "wait-then-stop" : "gone";
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* What opening a conversation does: use the child it has; wait while pi-loops runs a turn in it, since
|
|
1000
|
+
* starting pi there would take the conversation from that turn; refuse after `PAGE_CRASH_BUDGET`
|
|
1001
|
+
* exits, which a person restarts by hand; otherwise start one.
|
|
1002
|
+
*
|
|
1003
|
+
* A turn is pi-loops's `heldTurn`, or, before that is known, a live pi-loops holder whose lease says
|
|
1004
|
+
* `busy`: it takes the lease first and starts the turn after it has claimed the job.
|
|
1005
|
+
*/
|
|
1006
|
+
export function decideOpen(
|
|
1007
|
+
i: { child?: ChildView; heldTurn?: { runId: string; label: string }; lease?: Partial<Pick<SessionLease, "holder">>; fileMissing?: boolean; crashes: readonly number[]; now: number },
|
|
1008
|
+
probe: ProcessProbe = systemProbe,
|
|
1009
|
+
): { kind: "reuse" } | { kind: "start" } | { kind: "wait-background"; runId: string; label: string } | { kind: "wait-terminal"; pid: number } | { kind: "crash-looping" } {
|
|
1010
|
+
if (i.child) return { kind: "reuse" };
|
|
1011
|
+
if (i.heldTurn) return { kind: "wait-background", runId: i.heldTurn.runId, label: i.heldTurn.label };
|
|
1012
|
+
const holder = i.lease?.holder;
|
|
1013
|
+
if (holder?.kind === "host" && holder.busy && holderAlive(holder, probe)) return { kind: "wait-background", runId: holder.busy.runId, label: holder.busy.jobId === DELIVERY_JOB_ID ? DELIVERY_LABEL : holder.busy.runId.slice(0, 12) };
|
|
1014
|
+
// A terminal's new conversation, before its first answer: there is no file for a page pi to open,
|
|
1015
|
+
// and one started anyway would be another conversation under this one's address.
|
|
1016
|
+
if (i.fileMissing && holder?.kind === "terminal" && holder.page !== true && holderAlive(holder, probe)) return { kind: "wait-terminal", pid: holder.pid };
|
|
1017
|
+
if (i.crashes.filter((at) => i.now - at < PAGE_CRASH_BUDGET.windowMs).length >= PAGE_CRASH_BUDGET.count) return { kind: "crash-looping" };
|
|
1018
|
+
return { kind: "start" };
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* How to run pi as a child: `PI_BIN` when it is set, else this Node with pi's own `bin.pi`. Never a
|
|
1023
|
+
* `pi` found on PATH: a LaunchAgent's PATH is `/usr/bin:/bin`. `piPackage` is src/pi-resolver.mjs's.
|
|
1024
|
+
*/
|
|
1025
|
+
export function piCommand(env: NodeJS.ProcessEnv, piPackage: () => string): { command: string; args: string[] } {
|
|
1026
|
+
if (env.PI_BIN) return { command: env.PI_BIN, args: [] };
|
|
1027
|
+
const dir = piPackage();
|
|
1028
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
1029
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
|
|
1030
|
+
if (typeof bin !== "string") throw new Error(`${dir}/package.json names no bin.pi`);
|
|
1031
|
+
return { command: process.execPath, args: [path.join(dir, bin)] };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export interface ChildPoolOptions {
|
|
1035
|
+
/** The loops directory: `pages/` and `pages.lock` live in it. */
|
|
1036
|
+
dir: string;
|
|
1037
|
+
command: () => { command: string; args: string[] };
|
|
1038
|
+
/** The environment every child starts with. */
|
|
1039
|
+
env: NodeJS.ProcessEnv;
|
|
1040
|
+
version: string;
|
|
1041
|
+
log: (line: string) => void;
|
|
1042
|
+
ring: (child: PageChild) => Promise<unknown>;
|
|
1043
|
+
onNote?: (child: PageChild, msg: any) => void;
|
|
1044
|
+
/** After the pool has dropped the child and its record. */
|
|
1045
|
+
onExit?: (child: PageChild, code: number | null, signal: NodeJS.Signals | null) => void;
|
|
1046
|
+
/** The person rows this child has written and not handed over: a child with any is not idle. */
|
|
1047
|
+
unsent?: (child: PageChild) => number;
|
|
1048
|
+
probe?: ProcessProbe;
|
|
1049
|
+
now?: () => number;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
export interface OpenHow {
|
|
1053
|
+
sessionId?: string;
|
|
1054
|
+
sessionFile?: string;
|
|
1055
|
+
cwd: string;
|
|
1056
|
+
piArgs: string[];
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* pi's rpc events that start or end what keeps a child busy. A run lasts until `agent_settled`, not
|
|
1061
|
+
* `agent_end`: pi retries, compacts and continues after `agent_end`. A compaction can come with no
|
|
1062
|
+
* run at all (`/compact`). The lease follows the same rule from inside pi (src/lease.ts TURN_EVENTS).
|
|
1063
|
+
*/
|
|
1064
|
+
export const RPC_TURN_EVENTS = {
|
|
1065
|
+
agent_start: ["run", true],
|
|
1066
|
+
agent_settled: ["run", false],
|
|
1067
|
+
compaction_start: ["compaction", true],
|
|
1068
|
+
compaction_end: ["compaction", false],
|
|
1069
|
+
} as const;
|
|
1070
|
+
|
|
1071
|
+
/** What the waiting page calls pi-loops delivering what is queued for a conversation: no run, so nothing to stop. */
|
|
1072
|
+
export const DELIVERY_LABEL = "a message delivery";
|
|
1073
|
+
|
|
1074
|
+
/** A key no conversation has: what a conversation without an id yet asks the cap for. */
|
|
1075
|
+
const NO_ID_YET = "\u0000new";
|
|
1076
|
+
|
|
1077
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* One `pi --mode rpc` per open conversation, keyed by its session id: `new-<pid>` until pi has said
|
|
1081
|
+
* which session it is in. Each running child has a record in `pages/`, written under `pages.lock`.
|
|
1082
|
+
*/
|
|
1083
|
+
export class ChildPool {
|
|
1084
|
+
readonly idleMs: number;
|
|
1085
|
+
private readonly opts: ChildPoolOptions;
|
|
1086
|
+
private readonly probe: ProcessProbe;
|
|
1087
|
+
private readonly now: () => number;
|
|
1088
|
+
private readonly self: { pid: number; procStart?: string };
|
|
1089
|
+
private readonly children = new Map<string, PageChild>();
|
|
1090
|
+
private readonly starting = new Map<string, Promise<PageChild>>();
|
|
1091
|
+
/**
|
|
1092
|
+
* Conversations whose pi now says it is in another: the id a page asked for, and the one pi chose.
|
|
1093
|
+
* A conversation pi never wrote a file for has nothing to resume, so pi starts a new one for it,
|
|
1094
|
+
* and every later request for the old id has to reach that pi rather than start another.
|
|
1095
|
+
*/
|
|
1096
|
+
private readonly moved = new Map<string, string>();
|
|
1097
|
+
/** A pi started for a conversation with no file yet, and that conversation's id: only its first move is a `moved` one. */
|
|
1098
|
+
private readonly startedFresh = new WeakMap<PageChild, string>();
|
|
1099
|
+
/** Orphans of an earlier pi-loops still being stopped, by session: opening one waits for it. */
|
|
1100
|
+
private readonly reaping = new Map<string, Promise<void>>();
|
|
1101
|
+
/** Exits nobody asked for, by session, and what pi said last: the crash budget and its page. */
|
|
1102
|
+
private readonly exits = new Map<string, { at: number[]; stderr: string }>();
|
|
1103
|
+
/** Children this pool asked to stop: their exit is not a crash. */
|
|
1104
|
+
private readonly asked = new WeakSet<PageChild>();
|
|
1105
|
+
/** When each child's record last had its `lastClientAt` written. */
|
|
1106
|
+
private readonly written = new WeakMap<PageChild, number>();
|
|
1107
|
+
/** Each child's first `get_state`, settled once pi has said which conversation it is in, or could not. */
|
|
1108
|
+
private readonly naming = new WeakMap<PageChild, Promise<void>>();
|
|
1109
|
+
private readonly reaped: Promise<void>;
|
|
1110
|
+
|
|
1111
|
+
constructor(opts: ChildPoolOptions) {
|
|
1112
|
+
this.opts = opts;
|
|
1113
|
+
this.probe = opts.probe ?? systemProbe;
|
|
1114
|
+
this.now = opts.now ?? Date.now;
|
|
1115
|
+
this.idleMs = pageIdleMs(opts.env);
|
|
1116
|
+
this.self = { pid: process.pid, procStart: procStart(process.pid) };
|
|
1117
|
+
this.reaped = this.reap();
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private recordFile(key: string) {
|
|
1121
|
+
return path.join(this.opts.dir, "pages", `${sessionFileName(key)}.json`);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
private underLock<T>(fn: () => T): T {
|
|
1125
|
+
return withFileLockSync(path.join(this.opts.dir, "pages.lock"), fn);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private writeRecord(key: string, child: PageChild) {
|
|
1129
|
+
const rec: PageRecord = { v: 1, sessionId: key, cwd: child.cwd, pid: child.pid, procStart: child.procStart, startedAt: stamp(child.startedAt), lastClientAt: stamp(child.lastClientAt), server: this.self, piArgs: child.piArgs };
|
|
1130
|
+
this.underLock(() => writeFileAtomic(this.recordFile(key), `${JSON.stringify(rec, null, 2)}\n`, 0o600));
|
|
1131
|
+
this.written.set(child, this.now());
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private argsFile(sessionId: string) {
|
|
1135
|
+
return path.join(this.opts.dir, "pages", "args", `${sessionFileName(sessionId)}.json`);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* The pi arguments of the launch that opened a conversation, kept in `pages/args/<session>.json`
|
|
1140
|
+
* after its child and its record are gone: a child the page starts again after an idle stop, a
|
|
1141
|
+
* restart or a wait gets them. Written by a start and by `page-open`, never from HTTP.
|
|
1142
|
+
*/
|
|
1143
|
+
recordArgs(sessionId: string, piArgs: string[]) {
|
|
1144
|
+
writeFileAtomic(this.argsFile(sessionId), `${JSON.stringify(piArgs)}\n`, 0o600);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** The arguments `recordArgs` kept for a conversation; none for one it never saw. */
|
|
1148
|
+
argsOf(sessionId: string): string[] {
|
|
1149
|
+
let text;
|
|
1150
|
+
try {
|
|
1151
|
+
text = fs.readFileSync(this.argsFile(sessionId), "utf8");
|
|
1152
|
+
} catch {
|
|
1153
|
+
return []; // never opened with any, or opened before this was kept
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
return stringList(JSON.parse(text)) ?? [];
|
|
1157
|
+
} catch {
|
|
1158
|
+
return []; // torn: pi's own defaults, as for a conversation never opened here
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** Only the record of `pid`: a child started since under the same key has written its own. */
|
|
1163
|
+
private removeRecord(key: string, pid: number) {
|
|
1164
|
+
this.underLock(() => {
|
|
1165
|
+
const file = this.recordFile(key);
|
|
1166
|
+
let text;
|
|
1167
|
+
try {
|
|
1168
|
+
text = fs.readFileSync(file, "utf8");
|
|
1169
|
+
} catch {
|
|
1170
|
+
return; // already gone
|
|
1171
|
+
}
|
|
1172
|
+
const rec = parsePageRecord(text);
|
|
1173
|
+
if (!rec || rec.pid === pid) fs.rmSync(file, { force: true });
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** The child for a conversation, started if there is none; a start already under way is shared. */
|
|
1178
|
+
async open(how: OpenHow): Promise<PageChild> {
|
|
1179
|
+
if (how.sessionId !== undefined) {
|
|
1180
|
+
const id = sessionFileName(how.sessionId);
|
|
1181
|
+
// The conversation has its own file now: it opens as itself, not as the one pi started for it.
|
|
1182
|
+
if (how.sessionFile !== undefined && fs.existsSync(how.sessionFile)) this.moved.delete(id);
|
|
1183
|
+
const have = this.get(id);
|
|
1184
|
+
if (have) return have;
|
|
1185
|
+
const pending = this.starting.get(id);
|
|
1186
|
+
if (pending) return pending;
|
|
1187
|
+
const started = this.start(how);
|
|
1188
|
+
this.starting.set(id, started);
|
|
1189
|
+
started.then(
|
|
1190
|
+
() => this.starting.delete(id),
|
|
1191
|
+
() => this.starting.delete(id),
|
|
1192
|
+
);
|
|
1193
|
+
return started;
|
|
1194
|
+
}
|
|
1195
|
+
return this.start(how);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private async start(how: OpenHow): Promise<PageChild> {
|
|
1199
|
+
const orphan = how.sessionId === undefined ? undefined : this.reaping.get(how.sessionId);
|
|
1200
|
+
if (orphan) await orphan;
|
|
1201
|
+
const room = decideChildStops(this.views(), this.now(), { idleMs: this.idleMs, cap: PAGE_CAP, opening: how.sessionId ?? NO_ID_YET });
|
|
1202
|
+
await Promise.all(room.stop.map((id) => this.stop(id, "to make room")));
|
|
1203
|
+
if (room.full) throw new Error(`${PAGE_CAP} conversations are open in the page and none of them is idle; close one to open another`);
|
|
1204
|
+
|
|
1205
|
+
const { command, args } = this.opts.command();
|
|
1206
|
+
// pi writes a conversation's file with its first answer, so a lease can name one that is not
|
|
1207
|
+
// there. `--session` on that path starts another conversation in it; without it, pi starts a
|
|
1208
|
+
// new one in the project, in a file of its own, and the pool follows it there (`moved`).
|
|
1209
|
+
const resumable = how.sessionFile !== undefined && fs.existsSync(how.sessionFile);
|
|
1210
|
+
if (how.sessionFile && !resumable) this.opts.log(`pi-loops page: ${how.sessionId?.slice(0, 8) ?? "a conversation"} has no file yet (${how.sessionFile}); pi starts a new conversation in ${how.cwd}`);
|
|
1211
|
+
const argv = [...args, "--mode", "rpc", ...(resumable ? ["--session", how.sessionFile as string] : []), ...how.piArgs];
|
|
1212
|
+
// A process group of its own (POSIX): a terminal's Ctrl-C goes to the whole foreground group,
|
|
1213
|
+
// and pi in rpc mode has no SIGINT handler, so it died before anyone could ask it to quit. pi
|
|
1214
|
+
// handles SIGTERM as `/quit`, which is the only signal this side sends it. Windows keeps the
|
|
1215
|
+
// arrangement it had: `detached` there is a new console window, not a new group.
|
|
1216
|
+
const pi = spawn(command, argv, { cwd: how.cwd, env: { ...this.opts.env, PI_LOOPS_PAGE_CHILD: "1" }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
1217
|
+
const child = await new Promise<PageChild>((resolve, reject) => {
|
|
1218
|
+
const failed = (err: Error) => {
|
|
1219
|
+
const line = `could not start pi: ${err.message}; set PI_BIN to pi's path, or PATH in the LaunchAgent that starts pi-loops`;
|
|
1220
|
+
this.opts.log(line);
|
|
1221
|
+
reject(new Error(line));
|
|
1222
|
+
};
|
|
1223
|
+
pi.once("error", failed);
|
|
1224
|
+
pi.once("spawn", () => {
|
|
1225
|
+
pi.off("error", failed);
|
|
1226
|
+
try {
|
|
1227
|
+
resolve(this.adopt(pi, how));
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
reject(err);
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
});
|
|
1233
|
+
if (how.sessionId !== undefined && !resumable) this.startedFresh.set(child, sessionFileName(how.sessionId));
|
|
1234
|
+
this.naming.set(
|
|
1235
|
+
child,
|
|
1236
|
+
child
|
|
1237
|
+
.identify()
|
|
1238
|
+
.then(() => void this.follow(child))
|
|
1239
|
+
.catch((err: any) => this.opts.log(`pi-loops page: asking pi ${child.pid} which session it is in: ${err?.message ?? err}`)),
|
|
1240
|
+
);
|
|
1241
|
+
// What the page offers from the first load: the models, the thinking levels, the commands it
|
|
1242
|
+
// routes a slash on, and the snapshot pi-loops last wrote into this conversation.
|
|
1243
|
+
child
|
|
1244
|
+
.refreshCatalog()
|
|
1245
|
+
.then(() => child.primeRuntime())
|
|
1246
|
+
.catch((err: any) => this.opts.log(`pi-loops page: asking pi ${child.pid} what it offers: ${err?.message ?? err}`));
|
|
1247
|
+
return child;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** A spawned pi, as a child of this pool: keyed, recorded, and dropped again when it exits. */
|
|
1251
|
+
private adopt(pi: ChildProcessByStdio<Writable, Readable, Readable>, how: OpenHow): PageChild {
|
|
1252
|
+
const child: PageChild = new PageChild(pi, {
|
|
1253
|
+
cwd: how.cwd,
|
|
1254
|
+
piArgs: how.piArgs,
|
|
1255
|
+
version: this.opts.version,
|
|
1256
|
+
log: this.opts.log,
|
|
1257
|
+
ring: () => this.opts.ring(child),
|
|
1258
|
+
onNote: this.opts.onNote ? (msg) => this.opts.onNote?.(child, msg) : undefined,
|
|
1259
|
+
onExit: (code, signal) => this.exited(child, code, signal),
|
|
1260
|
+
});
|
|
1261
|
+
const key = how.sessionId ?? `new-${child.pid}`;
|
|
1262
|
+
this.children.set(key, child);
|
|
1263
|
+
this.writeRecord(key, child);
|
|
1264
|
+
if (how.sessionId !== undefined) this.recordArgs(key, how.piArgs);
|
|
1265
|
+
return child;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
private exited(child: PageChild, code: number | null, signal: NodeJS.Signals | null) {
|
|
1269
|
+
const key = this.keyOf(child);
|
|
1270
|
+
if (key !== undefined) {
|
|
1271
|
+
this.children.delete(key);
|
|
1272
|
+
this.removeRecord(key, child.pid);
|
|
1273
|
+
// The pi a conversation moved to is gone: the next open of it starts as it would have.
|
|
1274
|
+
for (const [from, to] of this.moved) if (to === key) this.moved.delete(from);
|
|
1275
|
+
if (!this.asked.has(child)) {
|
|
1276
|
+
const was = this.exits.get(key);
|
|
1277
|
+
this.exits.set(key, { at: [...(was?.at ?? []), this.now()].slice(-PAGE_CRASH_BUDGET.count * 2), stderr: child.recentStderr() });
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
this.opts.onExit?.(child, code, signal);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** The key a conversation's child is under: its own id, or the one its pi moved to (`moved`) while that pi runs. */
|
|
1284
|
+
private keyFor(sessionId: string): string {
|
|
1285
|
+
if (this.children.has(sessionId)) return sessionId;
|
|
1286
|
+
const to = this.moved.get(sessionId);
|
|
1287
|
+
return to !== undefined && this.children.has(to) ? to : sessionId;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
get(sessionId: string): PageChild | undefined {
|
|
1291
|
+
return this.children.get(this.keyFor(sessionId));
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
has(sessionId: string): boolean {
|
|
1295
|
+
return this.children.has(this.keyFor(sessionId)) || this.starting.has(sessionId);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Forget where a conversation's pi moved to: it has a file of its own now (`moved`). */
|
|
1299
|
+
forgetMove(sessionId: string): void {
|
|
1300
|
+
this.moved.delete(sessionId);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
list(): PageChild[] {
|
|
1304
|
+
return [...this.children.values()];
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* The key a child has once pi has answered its first `get_state` (at most 15 s): the session id, or
|
|
1309
|
+
* `new-<pid>` still when pi named none; undefined when it exited first.
|
|
1310
|
+
*/
|
|
1311
|
+
async named(child: PageChild): Promise<string | undefined> {
|
|
1312
|
+
await this.naming.get(child);
|
|
1313
|
+
return this.keyOf(child);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** The key the pool has this child under; undefined once it has exited. */
|
|
1317
|
+
keyOf(child: PageChild): string | undefined {
|
|
1318
|
+
for (const [key, c] of this.children) if (c === child) return key;
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** A browser asked for this conversation: it is in use. On disk at most once a minute. */
|
|
1323
|
+
touch(sessionId: string) {
|
|
1324
|
+
const key = this.keyFor(sessionId);
|
|
1325
|
+
const child = this.children.get(key);
|
|
1326
|
+
if (!child) return;
|
|
1327
|
+
const now = this.now();
|
|
1328
|
+
child.lastClientAt = now;
|
|
1329
|
+
if (now - (this.written.get(child) ?? 0) >= 60_000) this.writeRecord(key, child);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** Move a child to the conversation pi now says it is in. Refused when another child has that one. */
|
|
1333
|
+
rekey(from: string, to: string): boolean {
|
|
1334
|
+
const child = this.children.get(from);
|
|
1335
|
+
if (!child || from === to) return false;
|
|
1336
|
+
try {
|
|
1337
|
+
sessionFileName(to);
|
|
1338
|
+
} catch {
|
|
1339
|
+
this.opts.log(`pi-loops page: pi ${child.pid} says it is in session ${JSON.stringify(to)}, which is not a usable id; it stays at ${from}`);
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
const other = this.children.get(to);
|
|
1343
|
+
if (other && other !== child) {
|
|
1344
|
+
this.opts.log(`pi-loops page: pi ${child.pid} moved to ${to}, which pi ${other.pid} already has open; it stays at ${from}`);
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
this.children.delete(from);
|
|
1348
|
+
this.children.set(to, child);
|
|
1349
|
+
// Asked for a conversation that had no file, pi started a new one: keep the way to it. Any other
|
|
1350
|
+
// move (a /resume, a new session) leaves the old conversation to be opened as itself.
|
|
1351
|
+
if (this.startedFresh.get(child) === from) this.moved.set(from, to);
|
|
1352
|
+
this.startedFresh.delete(child);
|
|
1353
|
+
this.writeRecord(to, child);
|
|
1354
|
+
this.recordArgs(to, child.piArgs);
|
|
1355
|
+
this.removeRecord(from, child.pid);
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Re-key a child whose pi has said it is in a conversation other than its key — the first answer of
|
|
1361
|
+
* a new one, or a switch pi made — and tell its pages where they are now. True when it moved.
|
|
1362
|
+
*/
|
|
1363
|
+
follow(child: PageChild): boolean {
|
|
1364
|
+
const from = this.keyOf(child);
|
|
1365
|
+
const to = child.sessionId;
|
|
1366
|
+
if (from === undefined || to === undefined || from === to || !this.rekey(from, to)) return false;
|
|
1367
|
+
child.broadcast({ type: "page_moved", sessionId: to });
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
views(): ChildView[] {
|
|
1372
|
+
return [...this.children].map(([sessionId, c]) => ({
|
|
1373
|
+
sessionId,
|
|
1374
|
+
clients: c.clients.size,
|
|
1375
|
+
busy: c.busy,
|
|
1376
|
+
pendingAsks: c.live.pendingAsks.size,
|
|
1377
|
+
unsentPersonRows: this.opts.unsent?.(c) ?? 0,
|
|
1378
|
+
lastClientAt: c.lastClientAt,
|
|
1379
|
+
}));
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Stop every child `decideChildStops` calls idle for too long; the sessions stopped. */
|
|
1383
|
+
async sweep(now = this.now()): Promise<string[]> {
|
|
1384
|
+
const { stop } = decideChildStops(this.views(), now, { idleMs: this.idleMs, cap: PAGE_CAP });
|
|
1385
|
+
await Promise.all(stop.map((id) => this.stop(id, "idle")));
|
|
1386
|
+
return stop;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** SIGTERM, which pi handles as `/quit`; SIGKILL after 15 seconds. Its exit drops the record. */
|
|
1390
|
+
async stop(sessionId: string, why: string): Promise<void> {
|
|
1391
|
+
const child = this.children.get(this.keyFor(sessionId));
|
|
1392
|
+
if (!child) return;
|
|
1393
|
+
this.opts.log(`pi-loops page: stopping pi ${child.pid} of ${sessionId} (${why})`);
|
|
1394
|
+
await this.terminate(child, 15_000);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/** Every child at once, for shutdown: 8 seconds, then SIGKILL. */
|
|
1398
|
+
async stopAll(): Promise<void> {
|
|
1399
|
+
await Promise.all(this.list().map((child) => this.terminate(child, 8_000)));
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
private terminate(child: PageChild, graceMs: number): Promise<void> {
|
|
1403
|
+
this.asked.add(child);
|
|
1404
|
+
if (child.pi.exitCode !== null || child.pi.signalCode !== null) return Promise.resolve();
|
|
1405
|
+
return new Promise((resolve) => {
|
|
1406
|
+
const kill = setTimeout(
|
|
1407
|
+
guard(this.opts.log, "stop", () => {
|
|
1408
|
+
this.opts.log(`pi-loops page: pi ${child.pid} did not quit in ${graceMs / 1000}s; killing it`);
|
|
1409
|
+
child.pi.kill("SIGKILL");
|
|
1410
|
+
}),
|
|
1411
|
+
graceMs,
|
|
1412
|
+
);
|
|
1413
|
+
child.pi.once("exit", () => {
|
|
1414
|
+
clearTimeout(kill);
|
|
1415
|
+
resolve();
|
|
1416
|
+
});
|
|
1417
|
+
child.pi.kill("SIGTERM");
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/** Exit times nobody asked for, oldest first: what `decideOpen` counts. */
|
|
1422
|
+
crashes(sessionId: string): number[] {
|
|
1423
|
+
return [...(this.exits.get(sessionId)?.at ?? [])];
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/** pi's last words before its last unasked exit, redacted and cut. */
|
|
1427
|
+
lastWords(sessionId: string): string {
|
|
1428
|
+
return this.exits.get(sessionId)?.stderr ?? "";
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Start again: a person asked for it, so the count starts over. */
|
|
1432
|
+
clearCrashes(sessionId: string) {
|
|
1433
|
+
this.exits.delete(sessionId);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
/** The reap that construction started; settled when every orphan found then is stopped. */
|
|
1437
|
+
reapOrphans(): Promise<void> {
|
|
1438
|
+
return this.reaped;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Records an earlier pi-loops left: its children are stopped, never adopted. A record whose pid is no
|
|
1443
|
+
* longer that process is deleted; a live one gets SIGTERM, then SIGKILL after 15 seconds, each only
|
|
1444
|
+
* while pid and start time still say it is that child.
|
|
1445
|
+
*/
|
|
1446
|
+
private reap(): Promise<void> {
|
|
1447
|
+
const dir = path.join(this.opts.dir, "pages");
|
|
1448
|
+
let names: string[];
|
|
1449
|
+
try {
|
|
1450
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".json"));
|
|
1451
|
+
} catch {
|
|
1452
|
+
return Promise.resolve(); // no pages/ yet: nothing was ever left
|
|
1453
|
+
}
|
|
1454
|
+
const each = names.map((name) => {
|
|
1455
|
+
const file = path.join(dir, name);
|
|
1456
|
+
let rec: PageRecord | undefined;
|
|
1457
|
+
try {
|
|
1458
|
+
rec = parsePageRecord(fs.readFileSync(file, "utf8"));
|
|
1459
|
+
} catch {
|
|
1460
|
+
return Promise.resolve(); // removed while this looked
|
|
1461
|
+
}
|
|
1462
|
+
if (!rec) {
|
|
1463
|
+
this.opts.log(`pi-loops page: ${name} is not a page record; removed`);
|
|
1464
|
+
fs.rmSync(file, { force: true });
|
|
1465
|
+
return Promise.resolve();
|
|
1466
|
+
}
|
|
1467
|
+
const found = rec;
|
|
1468
|
+
const verdict = decideOrphan(found, this.self, this.probe);
|
|
1469
|
+
if (verdict === "ours") return Promise.resolve();
|
|
1470
|
+
const stopped = verdict === "gone" ? Promise.resolve() : this.stopOrphan(found);
|
|
1471
|
+
const done = stopped.then(() => {
|
|
1472
|
+
this.reaping.delete(found.sessionId);
|
|
1473
|
+
this.underLock(() => {
|
|
1474
|
+
const now = (() => {
|
|
1475
|
+
try {
|
|
1476
|
+
return parsePageRecord(fs.readFileSync(file, "utf8"));
|
|
1477
|
+
} catch {
|
|
1478
|
+
return undefined; // already gone
|
|
1479
|
+
}
|
|
1480
|
+
})();
|
|
1481
|
+
if (now?.pid === found.pid) fs.rmSync(file, { force: true });
|
|
1482
|
+
});
|
|
1483
|
+
});
|
|
1484
|
+
this.reaping.set(found.sessionId, done);
|
|
1485
|
+
return done;
|
|
1486
|
+
});
|
|
1487
|
+
return Promise.all(each).then(
|
|
1488
|
+
() => undefined,
|
|
1489
|
+
(err: any) => this.opts.log(`pi-loops page: stopping what an earlier pi-loops left: ${err?.message ?? err}`),
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
private async stopOrphan(rec: PageRecord) {
|
|
1494
|
+
const still = () => decideOrphan(rec, this.self, this.probe) === "wait-then-stop";
|
|
1495
|
+
const signal = (sig: NodeJS.Signals) => {
|
|
1496
|
+
try {
|
|
1497
|
+
process.kill(rec.pid, sig);
|
|
1498
|
+
} catch (err: any) {
|
|
1499
|
+
if (err?.code !== "ESRCH") this.opts.log(`pi-loops page: ${sig} to pi ${rec.pid}: ${err?.message ?? err}`);
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
this.opts.log(`pi-loops page: stopping pi ${rec.pid} of ${rec.sessionId}, left running by an earlier pi-loops`);
|
|
1503
|
+
signal("SIGTERM");
|
|
1504
|
+
for (const deadline = Date.now() + 15_000; still() && Date.now() < deadline; ) await sleep(100);
|
|
1505
|
+
if (still()) signal("SIGKILL");
|
|
1506
|
+
}
|
|
1507
|
+
}
|